diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 93ddf685..59875ad9 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1028,13 +1028,38 @@ - 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -## 2026-05-26 跳一跳地块图集改为专用 2x3 六格切分 +## 2026-05-28 跳一跳重设计为 5x5 地块图集与弹弓拖拽 -- 背景:跳一跳创作在地块生图阶段误用了通用系列素材图集 helper,`item_names.len() > grid_size` 的校验会让 6 个地块类型在 `grid_size = 3` 时直接失败;即使绕过校验,通用 helper 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型。 -- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`。 -- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径。 -- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 +- 背景:旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。 +- 决策:`jump-hop` v1 创作端只保留主题输入;image2 生成一张 `5x5`、共 25 个 2D 地块图标的图集,后端按均匀网格切出 25 个 `JumpHopTileAsset`。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按住后拉蓄力、松手反向弹出,前端提交 `chargeMs + dragVectorX + dragVectorY`,后端裁决落点。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。 +- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。 +- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。 +- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-06-01 跳一跳运行态地块视觉尺寸和命中半径统一放大一倍 + +- 背景:当前跳一跳运行态里地块视觉尺寸偏小,玩家反馈“很难跳上去”,但仅放大前端展示会造成画面和后端裁决脱节。 +- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2;前端平台渲染抽成统一尺寸 helper,保证单测可以直接校验放大结果。 +- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、对应定向测试。 +- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-02 跳一跳起跳距离减半并加入飞行动画缓冲 + +- 背景:用户反馈当前跳跃到目标位置需要拖得太远,且松手后缺少角色翻腾到目标地块的过渡动画,导致跳跃手感偏硬。 +- 决策:`jump-hop` 的 `chargeToDistanceRatio` 统一从 `0.004` 提升到 `0.008`,让同等跳跃距离所需拖动距离减半;前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测落点后若新 run 尚未返回,必须停在预测落点等待,再进入约 `1440ms` 的相机层推进过渡。推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。 +- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。 +- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG + +- 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。 +- 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色沿拖拽方向明显拉长,落地后向反方向回弹两次。`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。 +- 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。 +- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 # 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 3dea8cd4..2c9ed44c 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1564,14 +1564,45 @@ - 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。 - 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 跳一跳地块图集不要套通用系列素材 n 行模型 +## 跳一跳地块图集固定走 5x5 地块池 -- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者即使绕过报错也只生成了 atlas 预览路径,地块切片没有真正落盘。 -- 原因:跳一跳地块只有 6 个固定 tileType,但旧实现把它塞进通用系列素材 helper,并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`。 -- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt,按 `start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind。 -- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`。 +- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。 +- 原因:旧模板先后尝试过通用系列素材 helper 和 `2x3` 六格固定 tileType,但当前跳一跳已经重设计为“主题 -> 5x5 地块图集 -> 25 个等权地块池 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。 +- 处理:跳一跳地块固定生成一张 `5x5` 主题图集,后端按均匀网格切出 25 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列`、`start / normal / target / finish / bonus / accent` 六格口径。 +- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 25 个独立 `JumpHopTileAsset`,运行态无限路径从地块池随机取材。 - 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 跳一跳地块切片不要按 tileType 复用资产槽位 + +- 现象:跳一跳生成完成后,运行态看起来仍像在显示默认几何地块,或者地块图片在加载时频闪;结果页地块池也可能只看到少量重复素材。 +- 原因:`tileType` 只是路径平台的玩法类型标签,25 个 atlas 切片里会重复出现 `normal / target / bonus / accent` 等类型。若后端持久化时用 `tileType` 生成 slot/path,同类型切片会写入同一个 `/generated-jump-hop-assets///image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。 +- 处理:后端切图后必须按 atlas 单元格写入 `tile-01` 到 `tile-25` 的唯一 slot/path;前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。 +- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键。 +- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`。 + +## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影 + +- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。 +- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。 +- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。 +- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。 +- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`。 + +## 跳一跳落点辅助和后端裁决必须统一坐标换算 + +- 现象:落点辅助标识已经压在目标地块图片上,松手后后端仍判定失败,玩家看到的是“明明瞄准了却没落上去”。 +- 原因:前端辅助标识使用屏幕像素坐标绘制,而后端裁决使用世界坐标。屏幕 y 轴向下为正、世界 y 轴向上为正;同时屏幕 x/y 每个世界单位对应的像素比例不同。若前端直接把屏幕像素拖拽向量发给后端,辅助点和后端落点方向会不一致。 +- 处理:前端运行态保留原始屏幕拖拽向量用于画弹弓和辅助点,但提交后端前必须按当前地块到目标地块的屏幕跨度 / 世界跨度把 x、y 分别换算成世界尺度一致的向量;后端继续只负责反向弹射和落点裁决。 +- 验证:前端回归测试要同时覆盖辅助点完整拖拽到目标地块,以及提交给后端的向量已完成世界尺度换算;后端领域测试覆盖屏幕向后下拉时应向世界 y 正方向跳出并命中。 +- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`server-rs/crates/module-jump-hop/src/application.rs`。 + +## 跳一跳创作入口旧文案先查 SpacetimeDB 配置 + +- 现象:`JumpHopWorkspace` 已只剩主题输入,但创作 Tab 的跳一跳模板卡仍显示旧的“俯视角跳跃闯关”或拼图参考图。 +- 原因:创作入口卡片事实源是 SpacetimeDB `creation_entry_type_config` 和 `/api/creation-entry/config`,前端只做展示派生;如果只改工作台、PRD 或前端组件,已有库里的旧入口行不会自动变化。当前 `api-server` 读取入口配置时优先订阅缓存,缓存命中后不会再走 procedure 播种,所以只把迁移写在 `get_creation_entry_config` 里不够。 +- 处理:同步更新 `module-runtime` 默认入口种子,并在 `spacetime-module/src/runtime/creation_entry_config.rs` 加只命中旧系统默认值的迁移;同时在 `spacetime-client` 的入口配置读模型里做同一条旧系统默认行的读路径纠偏。跳一跳当前默认值为 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`。 +- 验证:本地 `GET /api/creation-entry/config` 的 `jump-hop` 项应返回新 subtitle 和新 imageSrc;若仍旧,检查本地 SpacetimeDB 是否已发布当前 `spacetime-module`,以及后台是否手动覆盖过入口配置。若缓存路径和 procedure 路径返回不一致,优先怀疑读模型映射没做纠偏,而不是前端展示层。 + ## image2 dry-run 带参考图时不要直接打印 data URL - 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。 @@ -1650,6 +1681,22 @@ - 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。 - 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 旧创作入口先确认是不是旧 worktree 在响应 + +- 现象:浏览器里明明还看到跳一跳旧入口,比如 `俯视角跳跃闯关` 和 `puzzle.webp`,但当前 worktree 里已经改成了 `主题驱动平台跳跃` 和 `jump-hop.webp`。 +- 原因:本机常同时存在两个开发栈,旧 worktree 可能还在占用 `3000/8082/3101/3102`,而当前 worktree 可能跑在另一组端口。只看页面文案就下结论,容易把旧进程误认成当前改动没生效。 +- 处理:先用 `Get-NetTCPConnection` / `Get-CimInstance Win32_Process` 确认端口对应的可执行文件和命令行,再分别请求 `/api/creation-entry/config` 比对旧端口与当前 worktree 端口。必要时以当前 worktree 的实际端口为准重新打开页面。 +- 验证:旧端口返回旧跳一跳入口,当前 worktree 端口返回新跳一跳入口;两边的 `api-server` / `vite-cli` 命令行应指向不同仓库路径。 +- 关联:`scripts/dev.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 3001 无法访问先查旧 worktree 占端口和 SpacetimeDB 版本 + +- 现象:`http://127.0.0.1:3001/` 打不开,但 `3000 / 3101 / 8082` 仍有进程;`npm run dev` 直接退出,没有把新栈拉起来。 +- 原因:旧 worktree 的 `api-server`、`spacetime-standalone` 和 Vite 还活着,或者当前 worktree 的本机 SpacetimeDB CLI 默认版本低于仓库锁定版本,`scripts/dev.mjs` 会先校验版本再启动并直接报错退出。 +- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list` 和 `spacetime version use 2.3.0`,确认本机 CLI/standalone 与仓库一致后重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`。 +- 验证:`http://127.0.0.1:3001/`、`http://127.0.0.1:8083/healthz`、`http://127.0.0.1:3103/v1/ping` 都返回 200,且进程命令行指向当前 worktree 路径而不是别的仓库。 +- 关联:`scripts/dev.mjs`、`.hermes/shared-memory/pitfalls.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 微信历史孤儿作品不要让新注册账号顶替 - 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。 @@ -1665,3 +1712,19 @@ - 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。 - 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。 - 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。 + +## 跳一跳飞行动画不要直接用最新 run 重绘地块窗口 + +- 现象:跳一跳松手后如果后端很快返回下一帧 run,地块窗口会立刻前移,角色翻腾动画看起来像没播放;若同时刷新图片资产,还可能被误认为地块频闪。 +- 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。 +- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前窗口内插值飞向目标地块;约 `300ms` 后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。推进期间地块 DOM 层和 Three.js 角色层必须统一包在同一个 camera layer 下移动,旧当前地块用相机偏移自然离开视野,新预览地块从上方露出;不要再让 p1/p2 各自 top/left 过渡。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,从旧目标地块位置斜向滑到新当前地块聚焦位置,避免先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。 +- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,动画结束后进入 `data-platform-advancing=true`,Three 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`。 +- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`server-rs/crates/module-jump-hop/src/application.rs`。 + +## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码 + +- 现象:本地用 `@'...'@ | node -` 跑 VectorEngine / gpt-image-2 live 验证时,`request.json` 里的中文 prompt 可能全部变成 `????`,生成图会变成完全不相关的 UI、建筑海报或其它随机内容,容易误判为模型不服从提示词。 +- 原因:Windows PowerShell 管道到 Node stdin 时可能按本机非 UTF-8 编码传输脚本文本,JS 源码里的中文字符串在进入 Node 前已经损坏;Rust 后端真实请求不会走这条编码路径。 +- 处理:含中文提示词的 live 验证优先写成 UTF-8 `.mjs` 文件再执行,或使用能确认 UTF-8 的运行入口;执行后先检查本次 `request.json` 是否保留真实中文,再判断生图质量。不要基于 `????` prompt 生成的图片调整项目提示词。 +- 验证:生成前后检查 `request.json`,其中 `prompt` 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。 +- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md`、`server-rs/crates/api-server/src/jump_hop.rs`。 diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index a3ab635a..6bbc45af 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -2,491 +2,193 @@ ## 1. 目标 -新增一个可创作、可试玩、可发布的玩法模板: +`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `5x5` 地块资源图集,切成 25 个 2D 地块素材;运行态使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色,并和这些 2D 地块资产组成无限平台流。 -```text -跳一跳 -``` +首版目标: -本模板参考拼图模板的创作闭环,沿用“创作入口 -> 生成过程页 -> 结果页 -> 试玩 -> 发布”的平台链路,但玩法本体改为俯视角 / 等距视角的跳跃闯关。 - -首版要求: - -1. 初始草稿生成时,角色形象单独调用一次生图; -2. 初始草稿生成时,地块只调用一次生图,输出 3D 视图的 2D 图片图集; -3. 运行态不接真实 3D 网格,不生成 GLB / glTF; -4. 作品可以直接进入试玩和发布。 +1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生; +2. image2 只生成一张 `5x5` 地块图集,后端均匀切成 25 张 PNG; +3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG; +4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块; +5. 操作方式为按住屏幕向后拖动蓄力,松手后角色向拖拽反方向弹出; +6. 只要落点未命中下一个地块,本局立即失败并冻结计时; +7. 成绩记录成功跳跃次数和游戏时长; +8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。 ## 2. 模板定位 -模板 ID: +- 模板 ID:`jump-hop` +- 展示名:`跳一跳` +- 工程域:`jump-hop` +- 创作入口卡:`subtitle = 主题驱动平台跳跃`,`imageSrc = /creation-type-references/jump-hop.webp` +- 运行态:`DOM 平台 / DOM 角色 + Three.js 透明扩展层 + DOM HUD` +- 画面比例:移动端竖屏优先,桌面端居中承载 `9:16` +- 素材策略:2D 地块图集 + 陶泥儿 logo 透明角色 +- 渲染分层:生成地块切片必须由 DOM 平台层直接渲染为图片;角色必须由 DOM 透明 PNG 层渲染并保持最高层级,Three.js 透明画布只作为后续扩展层,不能把地块图片或角色回退为 WebGL 占位材质 + +本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。 + +## 3. 创作工具平台接入声明 + +- 工作台模式:表单输入创作工作台 +- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 +- 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色 +- 系列素材槽位: + - `batchId = jump-hop-tile-atlas` + - `sheetSpec = 5x5 / 1:1 / PNG / 纯绿色绿幕背景 / 后端切图透明化` + - `slotSpecs = tile-01 ... tile-25`,每个 slot 必须对应唯一 OSS path / `assetObjectId` + - 切图规则:按原图宽高均分为 5 行 5 列,从上到下、从左到右切出 25 张 PNG;每格透明化后只保留最大的 alpha 连通主体,再裁边并补透明安全边,避免相邻格越界碎片或方形杂边进入 tile + - 透明化规则:生成时要求绿幕背景,后端上传 OSS 前抠成透明 PNG,并清理与主体分离的小型残片 + - 失败回写:生成失败时 session 保持 failed,可从生成页重试 + - 局部重生成:结果页允许重生成地块图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存 +- API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*` +- 业务真相:后端裁决落点、失败、成功跳跃次数、冻结时长和排行榜 +- 创作工具模式例外:无 +- 验证命令:`npm run check:encoding`、`npm run typecheck`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml` + +## 4. 创作输入 + +主题是唯一必填项。工作台不展示角色提示词、地块提示词、风格卡、难度卡、终点氛围或规则说明。 + +提交后系统自动派生: + +1. 作品标题:主题为空白修剪后的短标题,默认前缀不外露; +2. 作品简介:基于主题生成一句短简介; +3. 标签:`跳一跳`、`休闲` 和主题关键词; +4. 地块提示词:围绕主题生成 25 个风格一致的俯视角清爽游戏化 2D 平台素材,每一块都是符合主题的单独可跳跃平台;实际 image2 prompt 使用“独立可落脚平台素材 / 平台裸素材 / 完整平台”措辞,不再把正向主体描述成图标集或游戏界面资源; +5. 初始平台流参数:固定 v1 标准参数,不让创作者手工调规则。 + +## 5. 地块图集 + +image2 只生成一张 `1:1` 图片,画面为 `5x5` 均匀分布平台裸素材;实际提示词必须先约束“画面只包含 25 个独立跳一跳可落脚平台素材”,并明确不是游戏界面、棋盘、背包、装备栏或图标集页面。 + +图集要求: + +1. 每格只放一个完整地块资源; +2. 资源为纯 2D 平面素材,但要表现为符合主题且有设计感的俯视角清爽游戏化立体感平台,有顶面、主体内部明暗和清晰轮廓;主题元素必须直接成为平台主体,例如“水果”应生成苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台; +3. 25 个地块来自同一主题、同一光向和同一材质体系; +4. 背景为纯绿色绿幕,方便后端透明化; +5. 不包含角色、文字、水印、UI、游戏面板、棋盘、背包、装备栏、按钮、标题、外层边框、网格线、场景背景、落地投影、接触阴影、方形阴影、方形底板、白底、灰底或黑底; +6. 地块不能跨格、贴边或进入相邻格,主体必须居中并保留至少 18% 纯绿色安全留白;每个平台之间只能是纯绿色空白,不画容器框或棋盘格。 + +切片顺序固定为: ```text -jump-hop +tile-01 tile-02 tile-03 tile-04 tile-05 +tile-06 tile-07 tile-08 tile-09 tile-10 +tile-11 tile-12 tile-13 tile-14 tile-15 +tile-16 tile-17 tile-18 tile-19 tile-20 +tile-21 tile-22 tile-23 tile-24 tile-25 ``` -用户展示名: +运行态随机使用这 25 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。 + +## 6. 运行态规则 + +### 6.1 平台流 + +运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 3 个地块可见: + +1. 当前地块; +2. 目标地块; +3. 下一预览地块。 + +服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。前端只展示服务端快照,不自行生成正式路径。 + +### 6.2 操作 + +1. 用户按住当前地块或画面; +2. 向后拖动形成蓄力向量; +3. 松手后角色沿拖拽反方向弹出; +4. 拖拽距离决定力度,拖拽方向决定落点方向; +5. 力度和方向都由前端提交给后端裁决。 + +手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.008`。该值表示同等世界跳跃距离只需要旧版 `0.004` 配置的一半屏幕拖动距离;旧作品运行时若仍携带 `0.004`,开局归一化为 `0.008`。 + +松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画;角色从当前地块弹向预测落点,蓄力阶段角色应沿拖拽方向明显拉长,落地后再向反方向回弹两次。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端返回的最新 run,并进入约 `1440ms` 的相机推进过渡。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块随相机推进自然离开视野,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。 + +### 6.3 判定 + +1. 目标永远是当前地块后的下一个地块; +2. 落点进入下一个地块落地半径,则成功; +3. 落点未进入下一个地块落地半径,则失败; +4. 失败后状态改为 `failed`,计时冻结; +5. v1 没有通关状态、combo、perfect 或生命数。 + +### 6.4 计分与时间 + +- 成功跳跃次数:每成功落到下一个地块后 `+1`; +- 游戏时长:`startedAtMs` 到 `finishedAtMs`,失败时冻结; +- 运行中时长由前端根据服务端 `startedAtMs` 展示; +- 失败后只展示冻结时长。 + +## 7. 排行榜 + +排行榜按作品维度生成。每位玩家只保留 1 条最佳记录。 + +排序规则固定为: ```text -跳一跳 +successfulJumpCount desc -> durationMs asc -> updatedAt asc ``` -体验关键词: - -1. 俯视角; -2. 等距感地块; -3. 单局闯关; -4. 长按蓄力,松手起跳; -5. 轻量休闲。 - -首版采用竖屏优先的移动端体验,桌面端保持居中展示,画面比例以 `9:16` 为主。参考图的核心视觉要点是: - -1. 大面积留白或浅色渐变背景; -2. 角色站在单个地块上; -3. 地块有明显顶面、侧面和投影; -4. 整体是俯视角 / 等距视角,而不是横版平台跳跃; -5. UI 克制,只保留必要控制,不堆说明文案。 - -## 3. 与拼图模板的复用边界 - -可以复用: - -1. 创作入口和模板分流; -2. 生成过程页; -3. 结果页的草稿保存、返回编辑、试玩、发布、分享链路; -4. 作品架展示和草稿恢复口径; -5. 平台统一的发布与公开展示流程。 - -不复用: - -1. 拼图关卡切片逻辑; -2. 拼图拖拽拼块逻辑; -3. 拼图 UI 背景和多关卡编辑结构; -4. 任何方格拼合语义。 - -## 4. 工程接入范围 - -首版需要做到完整玩法闭环,不只做入口占位。 - -新增前端阶段: - -```text -jump-hop-workspace -jump-hop-generating -jump-hop-result -jump-hop-runtime -jump-hop-gallery-detail -``` - -新增前端组件建议: - -1. `src/components/jump-hop-creation/JumpHopWorkspace.tsx`; -2. `src/components/jump-hop-result/JumpHopResultView.tsx`; -3. `src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`; -4. `src/services/jump-hop/jumpHopClient.ts`。 - -新增共享契约建议: - -1. `packages/shared/src/contracts/jumpHop.ts`; -2. `server-rs/crates/shared-contracts/src/jump_hop.rs`。 - -新增后端模块建议: - -1. `server-rs/crates/module-jump-hop`:纯领域规则,包含路径生成、蓄力换算、落点判定、通关 / 失败状态机; -2. `server-rs/crates/api-server/src/jump_hop.rs` 和 `src/jump_hop/` 子模块:HTTP handler、生成编排、资产保存和 DTO 映射; -3. `server-rs/crates/spacetime-module/src/jump_hop.rs`:session、work profile、runtime run、公开 view 和 reducer / procedure; -4. `server-rs/crates/spacetime-client/src/jump_hop.rs`:api-server 访问 SpacetimeDB 的 facade; -5. `server-rs/crates/api-server/src/modules/jump_hop.rs`:路由挂载。 - -入口配置事实源必须走 SpacetimeDB `creation_entry_type_config` 默认种子和后台配置接口,不新增前端硬编码入口配置。 - -## 5. 创作输入 - -创作者需要填写以下内容: - -1. 作品主题描述,必填; -2. 角色形象描述,必填; -3. 地块风格卡,必选; -4. 难度,必选; -5. 可选的终点氛围或节奏偏好。 - -推荐的最小输入形态是: - -1. 一句话主题; -2. 角色一句话描述; -3. 风格卡; -4. 难度卡。 - -不在首版开放手工拖拽平台编辑器。平台路径、地块间距和终点位置由系统自动生成,创作者只负责风格与难度选择。 - -### 5.1 地块风格卡 - -建议提供以下风格: - -1. 极简积木; -2. 纸模玩具; -3. 霓虹玻璃; -4. 森林石块; -5. 未来金属; -6. 自定义。 - -### 5.2 难度 - -建议提供以下离散档位: - -1. 轻松; -2. 标准; -3. 进阶; -4. 挑战。 - -难度主要影响: - -1. 平台路径长度; -2. 平台间距; -3. 可落点容差; -4. 完美落点窗口; -5. 终点前的节奏变化。 - -## 6. 生成规则 - -本模板必须把生图责任拆成两条独立链路: - -### 6.1 角色形象只生一次 - -角色形象必须只调用一次生图,输出一张可直接进入运行态的主角色图。 - -角色图要求: - -1. 单人主角; -2. 全身可见; -3. 透明背景; -4. 角色站姿或轻微前倾姿态; -5. 镜头和透视必须匹配俯视角场景; -6. 不要求多视角,不要求多帧动画图集。 - -角色图生成后作为作品级锚点资产使用,结果页、封面合成、试玩和发布都复用同一张图。后续如果只修改标题、标签、难度或路径,不应默认重新生角色。只有用户在结果页明确点击“重生成角色”时,才允许再调用一次角色生图。 - -### 6.2 地块只生一次图集 - -地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。 - -地块图集要求: - -1. 统一使用等距 / 俯视角; -2. 必须表现出顶面、侧面和投影; -3. 必须与角色图保持同一光向; -4. 必须有清晰的立体层次,但仍然是 2D 图片; -5. 六格必须按固定顺序包含以下地块类型: - - 起点地块; - - 普通地块; - - 目标地块; - - 终点地块; - - 奖励地块; - - 视觉强调地块。 - -固定格位为: - -| 格位 | tileType | 语义 | -| --- | --- | --- | -| 第 1 行第 1 列 | `start` | 起点地块 | -| 第 1 行第 2 列 | `normal` | 普通地块 | -| 第 1 行第 3 列 | `target` | 目标地块 | -| 第 2 行第 1 列 | `finish` | 终点地块 | -| 第 2 行第 2 列 | `bonus` | 奖励地块 | -| 第 2 行第 3 列 | `accent` | 视觉强调地块 | - -图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。 - -### 6.3 不新增第三次生成 - -首版不把封面、分享海报、路径预览再拆成第三次图像生成。封面和分享图必须由角色图 + 地块图集在本地或后端轻量合成,不额外增加新的角色生图次数。 - -### 6.4 路径元数据 - -除图片资产外,系统还必须生成跳跃路径元数据: - -1. 平台序列; -2. 平台中心点; -3. 平台宽度; -4. 平台间距; -5. 终点索引; -6. 评分和容差参数。 - -路径由领域规则自动生成,创作者不直接编辑坐标。路径元数据不依赖 LLM 或图片生成。 - -### 6.5 推荐的难度区间 - -| 难度 | 平台数量 | 平台间距 | 节奏 | -| --- | ---: | --- | --- | -| 轻松 | 12 - 14 | 短 | 宽容 | -| 标准 | 16 - 18 | 中 | 稳定 | -| 进阶 | 20 - 24 | 中长 | 紧凑 | -| 挑战 | 26 - 32 | 长 | 高压 | - -平台宽度和容差由系统按难度自动缩放,不要求创作者手工填写。 - -## 7. 契约草案 - -### 7.1 草稿结构 - -`JumpHopDraft` 至少包含: - -1. `templateId = "jump-hop"`; -2. `templateName = "跳一跳"`; -3. `profileId`; -4. `workTitle`; -5. `workDescription`; -6. `themeTags`; -7. `difficulty`; -8. `stylePreset`; -9. `characterPrompt`; -10. `tilePrompt`; -11. `characterAsset`; -12. `tileAtlasAsset`; -13. `tileAssets[]`; -14. `path`; -15. `coverComposite`; -16. `generationStatus`。 - -### 7.2 资产结构 - -`JumpHopCharacterAsset` 至少包含: - -1. `assetId`; -2. `imageSrc`; -3. `imageObjectKey`; -4. `assetObjectId`; -5. `generationProvider`; -6. `prompt`; -7. `width`; -8. `height`。 - -`JumpHopTileAsset` 至少包含: - -1. `tileType`; -2. `imageSrc`; -3. `imageObjectKey`; -4. `assetObjectId`; -5. `sourceAtlasCell`; -6. `visualWidth`; -7. `visualHeight`; -8. `topSurfaceRadius`; -9. `landingRadius`。 - -`tileType` 首版限定: - -```text -start | normal | target | finish | bonus | accent -``` - -### 7.3 路径结构 - -`JumpHopPath` 至少包含: - -1. `seed`; -2. `difficulty`; -3. `platforms[]`; -4. `finishIndex`; -5. `cameraPreset`; -6. `scoring`。 - -`JumpHopPlatform` 至少包含: - -1. `platformId`; -2. `tileType`; -3. `x`; -4. `y`; -5. `width`; -6. `height`; -7. `landingRadius`; -8. `perfectRadius`; -9. `scoreValue`。 - -### 7.4 运行态快照 - -`JumpHopRunSnapshot` 至少包含: - -1. `runId`; -2. `profileId`; -3. `status = playing | failed | cleared`; -4. `currentPlatformIndex`; -5. `score`; -6. `combo`; -7. `lastJump`; -8. `startedAtMs`; -9. `finishedAtMs`。 - -`lastJump` 至少包含: - -1. `chargeMs`; -2. `jumpDistance`; -3. `targetPlatformIndex`; -4. `landedX`; -5. `landedY`; -6. `result = miss | hit | perfect | finish`。 - -## 8. API 草案 - -HTTP 路由建议: - -```text -POST /api/creation/jump-hop/sessions -GET /api/creation/jump-hop/sessions/{sessionId} -POST /api/creation/jump-hop/sessions/{sessionId}/actions -POST /api/creation/jump-hop/works/{profileId}/publish -GET /api/runtime/jump-hop/works/{profileId} -POST /api/runtime/jump-hop/runs -POST /api/runtime/jump-hop/runs/{runId}/jump -POST /api/runtime/jump-hop/runs/{runId}/restart -GET /api/runtime/jump-hop/gallery -GET /api/runtime/jump-hop/gallery/{publicWorkCode} -``` - -动作类型建议: - -```text -compile-draft -regenerate-character -regenerate-tiles -update-work-meta -update-difficulty -``` - -`compile-draft` 是长耗时动作。前端进入生成页后必须持久化 `generationStatus=generating`,刷新后能从作品架恢复生成页。失败前需要复读 session;如果后端已经完成草稿并写回资产,前端按成功收尾。 - -## 9. SpacetimeDB 表和 view - -建议新增表: - -1. `jump_hop_agent_session`; -2. `jump_hop_work_profile`; -3. `jump_hop_runtime_run`; -4. `jump_hop_event`; -5. `jump_hop_leaderboard_entry`,首版可暂不对外展示; -6. `jump_hop_gallery_view`; -7. `jump_hop_gallery_card_view`。 - -表结构新增字段必须按 SpacetimeDB 迁移规则放在结构体末尾并设置明确默认值。新增或调整表、reducer、procedure、view 后必须同步 `migration.rs`、表目录、生成 bindings,并执行 `npm run check:spacetime-schema`。 - -公开列表主路径应优先订阅 `jump_hop_gallery_card_view` 后在 `api-server` 本地 cache 构造列表响应,不要让每个 HTTP 请求都调用 SpacetimeDB procedure 组装全量列表。 - -## 10. 结果页能力 - -结果页必须展示: - -1. 作品标题; -2. 作品简介; -3. 角色形象; -4. 地块图集; -5. 路径预览; -6. 标签; -7. 试玩; -8. 发布; -9. 返回编辑。 - -结果页还必须支持: - -1. 单独重生成角色; -2. 单独重生成地块图集; -3. 单独修改标题和简介; -4. 单独调整标签和难度。 - -结果页不应强制再走一次封面生图。封面只做合成,不新增图像生成调用。 - -## 11. 运行态规则 - -运行态采用 2D 表现,但画面视觉上必须保留参考图那种俯视角 / 等距感。 - -### 11.1 核心玩法 - -1. 玩家长按蓄力; -2. 松手后角色按蓄力长度起跳; -3. 跳跃距离决定是否落到下一个地块; -4. 落在目标区域内判定成功; -5. 落在地块外或越界判定失败; -6. 到达终点地块判定通关。 - -### 11.2 判定规则 - -1. 只做一个当前局面的起跳判定; -2. 不做复杂连招动作树; -3. 不新增生命数、体力、回合数; -4. 不新增计时赛作为首版核心规则; -5. 不把前端动画结果当成最终真相,通关与失败必须能回写运行态状态。 - -### 11.3 角色动画 - -角色不需要多帧生图,运行态只通过位移、缩放、轻微旋转和投影变化表达: - -1. 蓄力时轻微压缩; -2. 起跳时向上抬升; -3. 空中保持可读轮廓; -4. 落地时轻微弹性回弹; -5. 失败时从地块边缘跌落。 - -### 11.4 摄像机与构图 - -1. 相机以当前角色和下一地块为中心; -2. 至少保证下一个落点一直可见; -3. 画面要留出顶部和底部的 UI 安全区; -4. 不要把地块做得太满,保留参考图那种疏朗感。 - -### 11.5 UI - -运行态 UI 只保留必要元素: - -1. 分数; -2. 暂停; -3. 重新开始; -4. 分享; -5. 结算按钮。 - -不默认展示大段规则说明。首进如果需要引导,只能用一次轻量提示,不允许常驻一屏的说明文案。 - -## 12. 视觉规范 - -本模板的视觉目标是“像 3D,但仍是 2D 图片”。 - -必须遵守: - -1. 平台有明确厚度; -2. 侧面可见分层或材质变化; -3. 投影统一且方向一致; -4. 背景干净,颜色克制; -5. 角色尺寸在小屏上依然可读; -6. 地块不能出现过多文字、按钮或装饰信息; -7. 不能把运行态做成重 UI 面板。 - -建议的背景策略: - -1. 以静态浅色渐变或纯色背景为主; -2. 不把背景也做成每次都生成的重资产; -3. 让地块和角色成为画面的第一视觉焦点。 - -## 13. 发布后体验 - -发布后的作品必须支持: - -1. 进入作品架和公开展示; -2. 分享; -3. 试玩; -4. 重新进入结果页编辑。 - -发布后的卡片封面应优先由角色图和地块图合成,不要求单独再生成封面图。 - -首版不新增排行榜、回放和对局对抗。后续如要扩展排行,可另起版本,不要塞进首版模板范围。 - -## 14. 验收 - -1. 创作入口能看到 `跳一跳` 模板; -2. 创作者可以填写主题、角色描述、风格和难度; -3. 提交后只生成一次角色图和一次地块图集; -4. 结果页能看到角色图、地块图集和路径预览; -5. 结果页可单独重生成角色或地块; -6. 试玩进入跳一跳运行态; -7. 长按蓄力、松手起跳、落点判定、失败和通关都可用; -8. 作品可以保存、发布和分享; -9. 前端不直接读取或暴露生图密钥; -10. 发布后的封面不依赖第三次额外生图。 -11. `npm run check:spacetime-schema` 在 schema 变更后通过; -12. `npm run check:encoding` 通过。 +展示字段: + +1. rank; +2. playerId; +3. successfulJumpCount; +4. durationMs; +5. updatedAt。 + +草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。 + +## 8. 结果页 + +结果页展示: + +1. 陶泥儿 logo 透明角色预览; +2. 25 个地块资源池预览; +3. 首屏 3 块平台预览; +4. 试玩; +5. 发布; +6. 返回编辑; +7. 重生成地块。 + +结果页不再展示角色图片生成槽位,也不提供独立角色重生成。 + +## 9. 契约要点 + +公开语义保留: + +1. `themeText`; +2. `tileAtlasAsset`; +3. `tileAssets[]`; +4. `defaultCharacter`; +5. `path.platforms[]` 作为服务端路径缓冲; +6. `currentPlatformIndex`; +7. `successfulJumpCount`; +8. `startedAtMs` / `finishedAtMs` / `durationMs`; +9. `leaderboard`。 + +旧语义处理: + +1. `characterAsset` 仅作为角色描述兼容字段,不再表示生成图片;前端固定使用陶泥儿 logo 透明 PNG; +2. `score` 兼容映射为成功跳跃次数; +3. `combo` 固定为 0,不作为公开玩法语义; +4. `cleared` 状态不再由 v1 产生; +5. 旧 finite path 只作为服务端路径缓冲兼容形态。 + +## 10. 验收 + +1. 创作页只显示主题输入; +2. 生成链路只调用一次地块图集 image2,不再调用角色生图; +3. 地块图集为 `5x5`,后端切出 25 个地块 PNG; +4. 结果页不依赖旧角色图片槽; +5. 运行态为竖屏俯视角,首屏保持 3 个地块可见; +6. 拖拽方向和力度会影响落点; +7. 未落到下一个地块立即失败; +8. 成功跳跃次数累加,失败后计时冻结; +9. 排行榜按成功跳跃次数优先排序; +10. 作品可保存、发布、分享并从公开入口启动。 +11. 运行态地块必须显示 `tileAssets[]` 中的生成切片图片;拖拽蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台图片层或 DOM 角色层。 +12. 同等跳跃距离的拖动距离必须比旧 `0.004` 系数缩短一半,松手后必须先看到角色飞行动画,再看到地块窗口前移。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index edf312bd..5a4a9e27 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -404,6 +404,12 @@ npm run check:server-rs-ddd - Rust 结构体:`JumpHopEventRow` - 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` +### `jump_hop_leaderboard_entry` + +- Rust 结构体:`JumpHopLeaderboardEntryRow` +- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` +- 说明:跳一跳作品维度排行榜 read model,每个 `profile_id + player_id` 只保留 1 条最佳记录;排序口径为成功跳跃次数降序、游戏时长升序、更新时间升序,草稿试玩不作为公开排行榜语义。 + ### `jump_hop_runtime_run` - Rust 结构体:`JumpHopRuntimeRunRow` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index d5207f03..e4981107 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -122,23 +122,31 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 -首版定位为俯视角 / 等距视角 2D 休闲跳跃模板,链路对齐拼图的创作闭环: +当前定位为竖屏俯视角 2D 平台跳跃模板,链路对齐平台创作闭环: ```text -创作入口 -> 模板输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态 +创作入口 -> 主题输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态 ``` +创作入口配置事实源仍是 SpacetimeDB `creation_entry_type_config`:默认 `visible=true`、`open=true`、`badge=可创建`、`subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`。旧库中仍停留在 `subtitle=俯视角跳跃闯关` 且 `image_src=/creation-type-references/puzzle.webp` 的系统默认行会在入口配置播种流程中自动迁移;同时 `spacetime-client` 的入口配置读模型也会对同一条旧系统默认行做纠偏,避免订阅缓存长期回放老口径。后台手动改过的跳一跳入口配置不被覆盖。 + 素材生成规则固定为: -1. 初始草稿生成时,角色形象单独调用一次生图; -2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集; -3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG; -4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图; -5. 显式重生成角色或地块时,只重生成对应资产槽位。 +1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生; +2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色; +3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、纯绿色绿幕背景的主题地块图集; +4. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; +5. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽。 -运行态规则真相必须沉到 `module-jump-hop`,前端只做蓄力表现、角色位移、投影和落地反馈。通关、失败、分数、combo、运行态快照和发布作品状态以后端为准。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 +运行态规则真相必须沉到 `module-jump-hop`,前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 -平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 +每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长;每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`。 + +运行态渲染分层固定为:DOM 平台层直接使用 `tileAssets[]` 的生成切片图片显示地块,图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存;DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持最高层级;Three.js 透明画布仅作为后续扩展层。拖拽蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布或平台图片层,否则会造成地块和角色层频闪。 + +跳一跳当前拖拽手感统一采用 `chargeToDistanceRatio=0.008`,用于把同等跳跃距离所需拖拽距离缩短到旧 `0.004` 的一半;如果历史路径仍保存旧系数,`start_run` 会在开局归一化到新系数。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画:蓄力时角色沿拖拽方向明显拉长,角色弹向预测落点,落地后向反方向回弹两次;动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端最新 run,并用约 `1440ms` 的相机层推进过渡承接新窗口。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。 + +平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。 diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 19fafe66..2127fd7f 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -24,7 +24,6 @@ export type JumpHopTileType = export type JumpHopActionType = | 'compile-draft' - | 'regenerate-character' | 'regenerate-tiles' | 'update-work-meta' | 'update-difficulty'; @@ -35,19 +34,21 @@ export type JumpHopJumpResult = 'miss' | 'hit' | 'perfect' | 'finish'; export interface JumpHopWorkspaceCreateRequest { templateId: string; - workTitle: string; - workDescription: string; - themeTags: string[]; - difficulty: JumpHopDifficulty; - stylePreset: JumpHopStylePreset; - characterPrompt: string; - tilePrompt: string; + themeText: string; + workTitle?: string; + workDescription?: string; + themeTags?: string[]; + difficulty?: JumpHopDifficulty; + stylePreset?: JumpHopStylePreset; + characterPrompt?: string; + tilePrompt?: string; endMoodPrompt?: string | null; } export interface JumpHopActionRequest { actionType: JumpHopActionType; profileId?: string | null; + themeText?: string | null; workTitle?: string | null; workDescription?: string | null; themeTags?: string[] | null; @@ -73,12 +74,23 @@ export interface JumpHopCharacterAsset { height: number; } +export interface JumpHopDefaultCharacter { + characterId: string; + displayName: string; + modelKind: 'builtin-three'; + bodyColor: string; + accentColor: string; +} + export interface JumpHopTileAsset { tileType: JumpHopTileType; + tileId?: string; imageSrc: string; imageObjectKey: string; assetObjectId: string; sourceAtlasCell: string; + atlasRow?: number; + atlasCol?: number; visualWidth: number; visualHeight: number; topSurfaceRadius: number; @@ -126,11 +138,13 @@ export interface JumpHopDraftResponse { templateId: string; templateName: string; profileId: string | null; + themeText: string; workTitle: string; workDescription: string; themeTags: string[]; difficulty: JumpHopDifficulty; stylePreset: JumpHopStylePreset; + defaultCharacter?: JumpHopDefaultCharacter | null; characterPrompt: string; tilePrompt: string; endMoodPrompt: string | null; @@ -167,6 +181,7 @@ export interface JumpHopWorkSummaryResponse { profileId: string; ownerUserId: string; sourceSessionId: string | null; + themeText: string; workTitle: string; workDescription: string; themeTags: string[]; @@ -185,6 +200,7 @@ export interface JumpHopWorkProfileResponse { summary: JumpHopWorkSummaryResponse; draft: JumpHopDraftResponse; path: JumpHopPath; + defaultCharacter?: JumpHopDefaultCharacter | null; characterAsset: JumpHopCharacterAsset; tileAtlasAsset: JumpHopCharacterAsset; tileAssets: JumpHopTileAsset[]; @@ -208,6 +224,7 @@ export interface JumpHopGalleryCardResponse { profileId: string; ownerUserId: string; authorDisplayName: string; + themeText: string; workTitle: string; workDescription: string; coverImageSrc: string | null; @@ -237,6 +254,8 @@ export interface JumpHopRuntimeRunSnapshotResponse { ownerUserId: string; status: JumpHopRunStatus; currentPlatformIndex: number; + successfulJumpCount: number; + durationMs: number; score: number; combo: number; path: JumpHopPath; @@ -251,10 +270,13 @@ export interface JumpHopRunResponse { export interface JumpHopStartRunRequest { profileId: string; + runtimeMode?: 'draft' | 'published'; } export interface JumpHopJumpRequest { - chargeMs: number; + dragDistance: number; + dragVectorX?: number; + dragVectorY?: number; clientEventId: string; } @@ -265,3 +287,17 @@ export interface JumpHopRestartRunRequest { export interface JumpHopJumpResponse { run: JumpHopRuntimeRunSnapshotResponse; } + +export interface JumpHopLeaderboardEntry { + rank: number; + playerId: string; + successfulJumpCount: number; + durationMs: number; + updatedAt: string; +} + +export interface JumpHopLeaderboardResponse { + profileId: string; + items: JumpHopLeaderboardEntry[]; + viewerBest?: JumpHopLeaderboardEntry | null; +} diff --git a/public/branding/jump-hop-taonier-character.png b/public/branding/jump-hop-taonier-character.png new file mode 100644 index 00000000..0dcbaf41 Binary files /dev/null and b/public/branding/jump-hop-taonier-character.png differ diff --git a/public/creation-type-references/jump-hop.webp b/public/creation-type-references/jump-hop.webp new file mode 100644 index 00000000..b4e6c7b2 Binary files /dev/null and b/public/creation-type-references/jump-hop.webp differ diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 5aa1028d..08adcaa2 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -94,13 +94,11 @@ pub async fn generate_character_visual( .map_err(|error| character_visual_error_response(&request_context, error))?; let result = async { - let settings = require_openai_image_settings(&state)? - .with_external_api_audit_context( - &request_context, - Some(owner_user_id.clone()), - Some(character_id.clone()), - ) - ; + let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( + &request_context, + Some(owner_user_id.clone()), + Some(character_id.clone()), + ); let http_client = build_openai_image_http_client(&settings)?; state @@ -324,10 +322,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile( &model, &prompt, )?; - let settings = require_openai_image_settings(state)?.with_external_api_audit_actor( - Some(owner_user_id.to_string()), - Some(character_id.clone()), - ); + let settings = require_openai_image_settings(state)? + .with_external_api_audit_actor(Some(owner_user_id.to_string()), Some(character_id.clone())); let http_client = build_openai_image_http_client(&settings)?; state .ai_task_service() diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index ab0e06fc..27a3b127 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -255,6 +255,29 @@ mod tests { ); } + #[test] + fn test_creation_entry_config_response_updates_jump_hop_metadata() { + let config = test_creation_entry_config_response(); + let jump_hop = config + .creation_types + .iter() + .find(|item| item.id == "jump-hop") + .expect("test creation entry config should include jump-hop"); + + assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}"); + assert!(jump_hop.visible); + assert!(jump_hop.open); + assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}"); + assert_eq!( + jump_hop.subtitle, + "\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}" + ); + assert_eq!( + jump_hop.image_src, + "/creation-type-references/jump-hop.webp" + ); + } + #[test] fn test_creation_entry_config_response_keeps_baby_object_match_visible() { let config = test_creation_entry_config_response(); diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 0aa81311..932f5099 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -553,12 +553,11 @@ pub async fn generate_custom_world_scene_image( "scene_image", asset_id.as_str(), async { - let settings = require_openai_image_settings(&state)? - .with_external_api_audit_context( - &request_context, - Some(owner_user_id.to_string()), - normalized.profile_id.clone(), - ); + let settings = require_openai_image_settings(&state)?.with_external_api_audit_context( + &request_context, + Some(owner_user_id.to_string()), + normalized.profile_id.clone(), + ); let http_client = build_openai_image_http_client(&settings)?; let reference_image = if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 910c18f2..6a80629b 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -13,15 +13,15 @@ use serde_json::{Value, json}; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, - JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse, - JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, - JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse, - JumpHopWorkspaceCreateRequest, + JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse, + JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, + JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use std::{ - collections::BTreeMap, + collections::{BTreeMap, VecDeque}, time::{SystemTime, UNIX_EPOCH}, }; @@ -46,8 +46,7 @@ use crate::{ work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; -const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = - ["start", "normal", "target", "finish", "bonus", "accent"]; +const JUMP_HOP_TILE_ITEM_COUNT: usize = 25; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; @@ -55,8 +54,8 @@ const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; -const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 2; -const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; +const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 5; +const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5; #[derive(Clone, Debug, PartialEq, Eq)] struct JumpHopTileAtlasSlice { @@ -239,6 +238,35 @@ pub async fn get_jump_hop_runtime_work( )) } +pub async fn get_jump_hop_leaderboard( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &profile_id, "profileId")?; + let leaderboard = state + .spacetime_client() + .get_jump_hop_leaderboard(profile_id, principal.subject().to_string()) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_RUNTIME_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopLeaderboardResponse { + profile_id: leaderboard.profile_id, + items: leaderboard.items, + viewer_best: leaderboard.viewer_best, + }, + )) +} + pub async fn start_jump_hop_run( State(state): State, Extension(request_context): Extension, @@ -247,6 +275,7 @@ pub async fn start_jump_hop_run( ) -> Result, Response> { let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; + let is_draft_runtime = payload.runtime_mode.as_deref() == Some("draft"); let owner_user_id = principal.subject().to_string(); let principal_kind = principal.kind().as_str(); let run = state @@ -261,23 +290,25 @@ pub async fn start_jump_hop_run( ) })?; - record_work_play_start_after_success( - &state, - &request_context, - build_jump_hop_work_play_tracking_draft( - &principal, - run.profile_id.clone(), - JUMP_HOP_RUNTIME_RUNS_ROUTE, + if !is_draft_runtime { + record_work_play_start_after_success( + &state, + &request_context, + build_jump_hop_work_play_tracking_draft( + &principal, + run.profile_id.clone(), + JUMP_HOP_RUNTIME_RUNS_ROUTE, + ) + .owner_user_id(run.owner_user_id.clone()) + .run_id(run.run_id.clone()) + .profile_id(run.profile_id.clone()) + .extra(json!({ + "runStatus": run.status, + "principalKind": principal_kind, + })), ) - .owner_user_id(run.owner_user_id.clone()) - .run_id(run.run_id.clone()) - .profile_id(run.profile_id.clone()) - .extra(json!({ - "runStatus": run.status, - "principalKind": principal_kind, - })), - ) - .await; + .await; + } Ok(json_success_body( Some(&request_context), @@ -391,15 +422,17 @@ async fn maybe_generate_jump_hop_assets( owner_user_id: &str, payload: &mut JumpHopActionRequest, ) -> Result<(), Response> { - if !matches!(payload.action_type, JumpHopActionType::CompileDraft) { + if !matches!( + payload.action_type, + JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles + ) { return Ok(()); } - if payload.character_asset.is_some() - && payload.tile_atlas_asset.is_some() + if payload.tile_atlas_asset.is_some() && payload .tile_assets .as_ref() - .is_some_and(|assets| !assets.is_empty()) + .is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT) { return Ok(()); } @@ -414,12 +447,11 @@ async fn maybe_generate_jump_hop_assets( let settings = require_openai_image_settings(state) .map(|settings| { - settings - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.clone()), - ) + settings.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.clone()), + ) }) .map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) @@ -428,58 +460,19 @@ async fn maybe_generate_jump_hop_assets( jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; - let character_prompt = payload - .character_prompt + let theme_text = payload + .theme_text .as_deref() - .unwrap_or("俯视角可爱主角,透明背景"); - let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集"); + .or(payload.work_title.as_deref()) + .unwrap_or("跳一跳"); + let tile_prompt = payload.tile_prompt.as_deref().unwrap_or(theme_text); - let character_generated = create_openai_image_generation( - &http_client, - &settings, - character_prompt, - Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"), - "1024*1024", - 1, - &[], - "跳一跳角色资产生成失败", - ) - .await - .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; - let character_image = character_generated - .images - .into_iter() - .next() - .ok_or_else(|| { - jump_hop_error_response( - request_context, - JUMP_HOP_CREATION_PROVIDER, - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "跳一跳角色资产生成成功但未返回图片。", - })), - ) - })?; - let character_asset = persist_jump_hop_generated_image_asset( - state, - owner_user_id, - profile_id.as_str(), - "character", - character_prompt, - character_image, - LegacyAssetPrefix::JumpHopAssets, - 768, - 768, - request_context, - ) - .await?; - - let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt); + let sheet_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); let tile_generated = create_openai_image_generation( &http_client, &settings, sheet_prompt.as_str(), - Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"), + Some(build_jump_hop_tile_atlas_negative_prompt()), "1024*1024", 1, &[], @@ -527,7 +520,12 @@ async fn maybe_generate_jump_hop_assets( .await?, ); } - payload.character_asset = Some(character_asset); + if payload.character_asset.is_none() { + payload.character_asset = Some(build_jump_hop_default_character_asset( + profile_id.as_str(), + theme_text, + )); + } payload.tile_atlas_asset = Some(tile_atlas_asset); payload.tile_assets = Some(tile_assets); payload.cover_composite = payload.cover_composite.clone().or_else(|| { @@ -538,28 +536,29 @@ async fn maybe_generate_jump_hop_assets( Ok(()) } -fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String { +fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String { + let theme_text = theme_text.trim(); + let theme_text = if theme_text.is_empty() { + "跳一跳" + } else { + theme_text + }; let subject_text = tile_prompt.trim(); let subject_text = if subject_text.is_empty() { - "等距立体地块图集" + theme_text } else { subject_text }; - let cell_plan = [ - "第1行第1列:start 起点地块", - "第1行第2列:normal 普通地块", - "第1行第3列:target 目标地块", - "第2行第1列:finish 终点地块", - "第2行第2列:bonus 奖励地块", - "第2行第3列:accent 视觉强调地块", - ] - .join(";"); format!( - "生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。" + "生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳一跳可落脚平台素材,按五行五列均匀摆放在纯绿色绿幕画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为俯视角平台跳跃游戏,画面内容是{subject_text}。\n每一块平台都必须直接使用主题元素做主体造型,主题要一眼可见;例如主题为水果时,应是苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台,不得变成石板、金属按钮、徽章或装备。\n只画平台裸素材,不画外层面板、棋盘底座、菜单、按钮、标题、文字、角标、装饰边框、工具栏、装备、武器、徽章、道具或角色。\n整体风格为清爽自然的休闲手游平台素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每格一个完整平台,是符合主题且有设计感的立体感平台,有顶面和清晰轮廓;不要默认生成灰色石板或金属地砖,除非主题本身就是石头或金属。\n每格主体必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯绿色绿幕安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个平台只保留主体内部明暗和外轮廓,不绘制落地投影、接触阴影、方形阴影、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个平台同一材质体系、同一光向,但形状和细节有变化;每个平台之间只能是纯绿色空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是接近 #00FF00 的纯绿色绿幕,背景平整无纹理、无渐变、无阴影、无黑底;主体自身不得使用接近 #00FF00 的纯绿。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板或道具界面。\nEnglish guardrail: isolated top-down fruit-shaped jump pad assets only, green screen background, no text, no poster, no architecture, no building, no UI screen, no inventory icons." ) } +fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { + "文字、Logo、水印、按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、灰色石板、金属地砖、建筑、楼房、海报、装备、武器、徽章、道具图标、UI图标卡、标题、说明文字、装饰边框、落地投影、接触阴影、方形阴影、方形底板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界" +} + fn slice_jump_hop_tile_atlas( image: &crate::openai_image_generation::DownloadedOpenAiImage, ) -> Result, AppError> { @@ -583,8 +582,8 @@ fn slice_jump_hop_tile_atlas( ); } - let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len()); - for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() { + let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_COUNT); + for index in 0..JUMP_HOP_TILE_ITEM_COUNT { let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS; @@ -598,6 +597,9 @@ fn slice_jump_hop_tile_atlas( y1.saturating_sub(y0).max(1), ); let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); + let cleaned = keep_jump_hop_largest_alpha_component(cleaned); + let cleaned = crop_generated_asset_sheet_view_edge_matte(cleaned); + let cleaned = pad_jump_hop_tile_slice_image(cleaned); let mut cursor = std::io::Cursor::new(Vec::new()); cleaned .write_to(&mut cursor, image::ImageFormat::Png) @@ -617,26 +619,116 @@ fn slice_jump_hop_tile_atlas( Ok(slices) } +fn pad_jump_hop_tile_slice_image(image: image::DynamicImage) -> image::DynamicImage { + let source = image.to_rgba8(); + let (width, height) = source.dimensions(); + if width == 0 || height == 0 { + return image::DynamicImage::ImageRgba8(source); + } + + // 中文注释:生图偶尔会让主体贴近单元格边缘;切片入库前补透明安全边, + // 避免运行态缩放或滤镜让主体看起来被裁掉。 + let pad_x = (width / 12).clamp(8, 24); + let pad_y = (height / 12).clamp(8, 24); + let mut padded = image::RgbaImage::from_pixel( + width.saturating_add(pad_x.saturating_mul(2)), + height.saturating_add(pad_y.saturating_mul(2)), + image::Rgba([0, 0, 0, 0]), + ); + image::imageops::overlay(&mut padded, &source, pad_x.into(), pad_y.into()); + image::DynamicImage::ImageRgba8(padded) +} + +fn keep_jump_hop_largest_alpha_component(image: image::DynamicImage) -> image::DynamicImage { + let mut source = image.to_rgba8(); + let (width, height) = source.dimensions(); + if width == 0 || height == 0 { + return image::DynamicImage::ImageRgba8(source); + } + + // 中文注释:模型偶尔会让相邻格的叶片、果梗或阴影越界进当前格; + // 每格只保留最大的 alpha 连通主体,能去掉这些小碎片再入库。 + let width_usize = width as usize; + let height_usize = height as usize; + let pixel_count = width_usize.saturating_mul(height_usize); + let mut visited = vec![false; pixel_count]; + let mut best_component = Vec::::new(); + + for start in 0..pixel_count { + if visited[start] || source.as_raw()[start * 4 + 3] <= 16 { + visited[start] = true; + continue; + } + + let mut queue = VecDeque::from([start]); + let mut component = Vec::::new(); + visited[start] = true; + + while let Some(index) = queue.pop_front() { + component.push(index); + let x = index % width_usize; + let y = index / width_usize; + + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 + { + continue; + } + let next = next_y as usize * width_usize + next_x as usize; + if visited[next] { + continue; + } + visited[next] = true; + if source.as_raw()[next * 4 + 3] > 16 { + queue.push_back(next); + } + } + } + } + + if component.len() > best_component.len() { + best_component = component; + } + } + + if best_component.is_empty() { + return image::DynamicImage::ImageRgba8(source); + } + + let mut keep = vec![false; pixel_count]; + for index in best_component { + keep[index] = true; + } + for index in 0..pixel_count { + if keep[index] { + continue; + } + let pixel = + source.get_pixel_mut((index % width_usize) as u32, (index / width_usize) as u32); + pixel.0[3] = 0; + } + + image::DynamicImage::ImageRgba8(source) +} + fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { match index { 0 => JumpHopTileType::Start, - 1 => JumpHopTileType::Normal, - 2 => JumpHopTileType::Target, - 3 => JumpHopTileType::Finish, - 4 => JumpHopTileType::Bonus, - _ => JumpHopTileType::Accent, + value if value % 11 == 0 => JumpHopTileType::Bonus, + value if value % 7 == 0 => JumpHopTileType::Accent, + value if value % 3 == 0 => JumpHopTileType::Target, + _ => JumpHopTileType::Normal, } } -fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str { - match tile_type { - JumpHopTileType::Start => "tile-start", - JumpHopTileType::Normal => "tile-normal", - JumpHopTileType::Target => "tile-target", - JumpHopTileType::Finish => "tile-finish", - JumpHopTileType::Bonus => "tile-bonus", - JumpHopTileType::Accent => "tile-accent", - } +fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String { + format!("tile-{:02}", tile_index + 1) } #[allow(clippy::too_many_arguments)] @@ -648,7 +740,7 @@ async fn persist_jump_hop_tile_asset( tile_slice: JumpHopTileAtlasSlice, request_context: &RequestContext, ) -> Result { - let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type); + let slot = jump_hop_tile_asset_slot_name(tile_index); let image = crate::openai_image_generation::DownloadedOpenAiImage { bytes: tile_slice.bytes, mime_type: "image/png".to_string(), @@ -658,7 +750,7 @@ async fn persist_jump_hop_tile_asset( state, owner_user_id, profile_id, - slot, + slot.as_str(), &format!( "跳一跳地块切片 {}:{}", tile_index + 1, @@ -674,10 +766,13 @@ async fn persist_jump_hop_tile_asset( Ok(JumpHopTileAsset { tile_type: tile_slice.tile_type, + tile_id: Some(slot), image_src: persisted.image_src, image_object_key: persisted.image_object_key, asset_object_id: persisted.asset_object_id, source_atlas_cell: tile_slice.source_atlas_cell, + atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1), + atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1), visual_width: 256, visual_height: 192, top_surface_radius: 42.0, @@ -685,6 +780,22 @@ async fn persist_jump_hop_tile_asset( }) } +fn build_jump_hop_default_character_asset( + profile_id: &str, + theme_text: &str, +) -> JumpHopCharacterAsset { + JumpHopCharacterAsset { + asset_id: format!("{profile_id}-builtin-character"), + image_src: "builtin://jump-hop/default-character".to_string(), + image_object_key: String::new(), + asset_object_id: format!("{profile_id}-builtin-character"), + generation_provider: "builtin-three".to_string(), + prompt: format!("内置默认 3D 角色:{}", theme_text.trim()), + width: 0, + height: 0, + } +} + async fn persist_jump_hop_generated_image_asset( state: &AppState, owner_user_id: &str, @@ -868,17 +979,26 @@ fn build_jump_hop_work_play_tracking_draft( } fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse { + let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title); JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, - work_title: payload.work_title.trim().to_string(), - work_description: payload.work_description.trim().to_string(), + theme_text: theme_text.clone(), + work_title: clean_or_default(&payload.work_title, &theme_text), + work_description: clean_or_default( + &payload.work_description, + &format!("{theme_text}主题的俯视角跳跃作品"), + ), theme_tags: normalize_tags(payload.theme_tags.clone()), difficulty: payload.difficulty.clone(), style_preset: payload.style_preset.clone(), - character_prompt: payload.character_prompt.trim().to_string(), - tile_prompt: payload.tile_prompt.trim().to_string(), + default_character: Some(default_jump_hop_character()), + character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"), + tile_prompt: clean_or_default( + &payload.tile_prompt, + &format!("{theme_text}主题的俯视角清爽游戏化立体感平台素材"), + ), end_mood_prompt: payload .end_mood_prompt .as_ref() @@ -897,13 +1017,7 @@ fn validate_workspace_request( request_context: &RequestContext, payload: &JumpHopWorkspaceCreateRequest, ) -> Result<(), Response> { - ensure_non_empty(request_context, &payload.work_title, "workTitle")?; - ensure_non_empty( - request_context, - &payload.character_prompt, - "characterPrompt", - )?; - ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?; + ensure_non_empty(request_context, &payload.theme_text, "themeText")?; if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID { return Err(jump_hop_error_response( request_context, @@ -917,6 +1031,32 @@ fn validate_workspace_request( Ok(()) } +fn normalize_theme_text(theme_text: &str, fallback: &str) -> String { + clean_or_default(theme_text, fallback) + .chars() + .take(60) + .collect::() +} + +fn clean_or_default(value: &str, fallback: &str) -> String { + let value = value.trim(); + if value.is_empty() { + fallback.trim().to_string() + } else { + value.to_string() + } +} + +fn default_jump_hop_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter { + shared_contracts::jump_hop::JumpHopDefaultCharacter { + character_id: "jump-hop-default-runner".to_string(), + display_name: "默认角色".to_string(), + model_kind: "builtin-three".to_string(), + body_color: "#f59e0b".to_string(), + accent_color: "#2563eb".to_string(), + } +} + fn ensure_non_empty( request_context: &RequestContext, value: &str, @@ -1020,32 +1160,82 @@ mod tests { use super::*; #[test] - fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() { - let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块"); + fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() { + let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台"); - assert!(prompt.contains("2行*3列")); - assert!(prompt.contains("第1行第1列:start 起点地块")); - assert!(prompt.contains("第2行第3列:accent 视觉强调地块")); + assert!(prompt.contains("五行五列")); + assert!(prompt.contains("共25个")); + assert!(prompt.contains("可落脚平台素材")); + assert!(prompt.contains("不要画成游戏界面")); + assert!(prompt.contains("主题要一眼可见")); + assert!(prompt.contains("每格一个完整平台")); + assert!(prompt.contains("清爽自然的休闲手游平台素材")); + assert!(prompt.contains("符合主题且有设计感的立体感平台")); + assert!(prompt.contains("四周至少保留18%纯绿色绿幕安全留白")); + assert!(prompt.contains("不绘制落地投影")); + assert!(prompt.contains("不画分隔线、网格线、容器框或棋盘格")); + assert!(prompt.contains("English guardrail")); + assert!(!prompt.contains("按5行*5列")); + assert!(!prompt.contains("2D地板图标")); + assert!(!prompt.contains("清爽自然的游戏图标")); + assert!(!prompt.contains("边缘厚度暗示")); + assert!(!prompt.contains("统一投影")); assert!(!prompt.contains("每个物品生成")); assert!(!prompt.contains("不同视图")); } #[test] - fn jump_hop_tile_atlas_slices_one_png_per_tile_type() { - let width = 300; - let height = 200; - let colors = [ - [220, 24, 24, 255], - [240, 150, 32, 255], - [248, 220, 72, 255], - [52, 168, 84, 255], - [38, 132, 255, 255], - [156, 92, 220, 255], - ]; + fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() { + let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); + + assert!(negative_prompt.contains("油亮高光")); + assert!(negative_prompt.contains("厚重CG渲染")); + assert!(negative_prompt.contains("游戏界面")); + assert!(negative_prompt.contains("图标集页面")); + assert!(negative_prompt.contains("建筑")); + assert!(negative_prompt.contains("方形阴影")); + assert!(negative_prompt.contains("方形底板")); + } + + #[test] + fn jump_hop_tile_slice_keeps_largest_alpha_component() { + let mut image = image::RgbaImage::from_pixel(80, 80, image::Rgba([0, 0, 0, 0])); + for y in 12..52 { + for x in 12..52 { + image.put_pixel(x, y, image::Rgba([220, 70, 50, 255])); + } + } + for y in 68..74 { + for x in 36..42 { + image.put_pixel(x, y, image::Rgba([40, 190, 80, 255])); + } + } + + let cleaned = keep_jump_hop_largest_alpha_component(image::DynamicImage::ImageRgba8(image)) + .to_rgba8(); + + assert_eq!(cleaned.get_pixel(20, 20).0[3], 255); + assert_eq!( + cleaned.get_pixel(38, 70).0[3], + 0, + "相邻格侵入的小碎片不应扩大当前地块切片边界" + ); + } + + #[test] + fn jump_hop_tile_atlas_slices_twenty_five_png_tiles() { + let width = 500; + let height = 500; let mut atlas = image::RgbaImage::new(width, height); - for row in 0..2 { - for col in 0..3 { - let color = image::Rgba(colors[row * 3 + col]); + for row in 0..5 { + for col in 0..5 { + let index = row * 5 + col; + let color = image::Rgba([ + 40 + index as u8 * 3, + 24 + index as u8 * 5, + 120 + index as u8 * 2, + 255, + ]); for y in row as u32 * 100..(row as u32 + 1) * 100 { for x in col as u32 * 100..(col as u32 + 1) * 100 { atlas.put_pixel(x, y, color); @@ -1065,20 +1255,48 @@ mod tests { let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); - assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len()); + assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT); for (index, slice) in slices.iter().enumerate() { assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index)); assert_eq!( slice.source_atlas_cell, - format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1) + format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1) ); let decoded = image::load_from_memory(slice.bytes.as_slice()) .expect("tile slice should decode") .to_rgba8(); + assert_eq!( + decoded.dimensions(), + (116, 116), + "跳一跳地块切片应在 100x100 单元格外补透明安全边" + ); + let color = [ + 40 + index as u8 * 3, + 24 + index as u8 * 5, + 120 + index as u8 * 2, + 255, + ]; assert!( - decoded.pixels().any(|pixel| pixel.0 == colors[index]), + decoded.pixels().any(|pixel| pixel.0 == color), "第 {index} 个地块切片应保留对应格子的主体颜色" ); } } + + #[test] + fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() { + let slots = (0..JUMP_HOP_TILE_ITEM_COUNT) + .map(jump_hop_tile_asset_slot_name) + .collect::>(); + let unique_slots = slots + .iter() + .cloned() + .collect::>(); + + assert_eq!( + unique_slots.len(), + JUMP_HOP_TILE_ITEM_COUNT, + "25 个地块切片必须写入 25 个独立 slot/path,不能按重复的 tile_type 互相覆盖" + ); + } } diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 726f0c7e..f21ecbbf 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -755,12 +755,11 @@ async fn generate_match3d_material_sheet_from_level_scene( config: &Match3DConfigJson, background_asset: Option<&Match3DGeneratedBackgroundAsset>, ) -> Result { - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let prompt = build_match3d_item_spritesheet_prompt(); let reference = load_match3d_level_scene_reference_image(state, background_asset).await?; diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index 99d4ef06..bcdea311 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -304,12 +304,11 @@ pub(super) async fn generate_match3d_cover_image_asset( reference_image_srcs: Vec, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let cover_prompt = build_match3d_cover_generation_prompt(config, prompt); let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit( @@ -459,12 +458,11 @@ pub(super) async fn generate_match3d_level_asset_bundle( prompt: &str, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let level_scene_prompt = build_match3d_level_scene_generation_prompt(config); @@ -607,12 +605,11 @@ pub(super) async fn generate_match3d_container_image( prompt: &str, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let reference_image = load_match3d_container_reference_image()?; let container_prompt = build_match3d_container_generation_prompt(config, prompt); diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index 48864e8d..f2604406 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -7,8 +7,9 @@ use crate::{ auth::{require_bearer_auth, require_runtime_principal_auth}, jump_hop::{ create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail, - get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, - list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, + get_jump_hop_leaderboard, get_jump_hop_runtime_work, get_jump_hop_session, + jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, + restart_jump_hop_run, start_jump_hop_run, }, state::AppState, }; @@ -54,6 +55,13 @@ pub fn router(state: AppState) -> Router { "/api/runtime/jump-hop/works/{profile_id}", get(get_jump_hop_runtime_work), ) + .route( + "/api/runtime/jump-hop/works/{profile_id}/leaderboard", + get(get_jump_hop_leaderboard).route_layer(middleware::from_fn_with_state( + state.clone(), + require_runtime_principal_auth, + )), + ) .route( "/api/runtime/jump-hop/runs", post(start_jump_hop_run).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index a17766f7..c03ad4bf 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -310,12 +310,11 @@ pub(crate) async fn generate_puzzle_level_asset_bundle( level_name: &str, puzzle_image: &PuzzleDownloadedImage, ) -> Result { - let settings = require_puzzle_vector_engine_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(session_id.to_string()), - ); + let settings = require_puzzle_vector_engine_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(session_id.to_string()), + ); let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?; let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image); let scene_generated = create_puzzle_vector_engine_image_generation( diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 4c6f9b2f..4338966b 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -117,11 +117,9 @@ impl PuzzleVectorEngineSettings { ) -> Self { self.external_api_audit_user_id = user_id; self.external_api_audit_profile_id = profile_id; - self.external_api_audit_request_id = - Some(request_context.request_id().to_string()); + self.external_api_audit_request_id = Some(request_context.request_id().to_string()); self } - } pub(crate) struct ParsedPuzzleImageDataUrl { diff --git a/server-rs/crates/api-server/src/square_hole/visual_assets.rs b/server-rs/crates/api-server/src/square_hole/visual_assets.rs index 75ad863e..3379f2a4 100644 --- a/server-rs/crates/api-server/src/square_hole/visual_assets.rs +++ b/server-rs/crates/api-server/src/square_hole/visual_assets.rs @@ -398,12 +398,11 @@ async fn generate_square_hole_image_data_url( size: &str, failure_context: &str, ) -> Result { - let settings = require_openai_image_settings(state)? - .with_external_api_audit_context( - request_context, - Some(owner_user_id.to_string()), - Some(profile_id.to_string()), - ); + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + request_context, + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs index e7f835bf..71a990d5 100644 --- a/server-rs/crates/module-jump-hop/src/application.rs +++ b/server-rs/crates/module-jump-hop/src/application.rs @@ -5,61 +5,18 @@ use crate::{ JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType, }; +const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0; +const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.008; + pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath { let config = difficulty_config(difficulty); - let mut rng = DeterministicRng::new(seed, difficulty.as_str()); - let platform_count = rng.range_u32(config.min_platforms, config.max_platforms) as usize; - let mut platforms = Vec::with_capacity(platform_count); - let mut x = 0.0f32; - let mut y = 0.0f32; - - for index in 0..platform_count { - let tile_type = if index == 0 { - JumpHopTileType::Start - } else if index + 1 == platform_count { - JumpHopTileType::Finish - } else if index % 7 == 0 { - JumpHopTileType::Bonus - } else if index % 5 == 0 { - JumpHopTileType::Target - } else if index % 4 == 0 { - JumpHopTileType::Accent - } else { - JumpHopTileType::Normal - }; - let width = rng.range_f32(config.min_width, config.max_width); - let height = width * rng.range_f32(0.86, 1.04); - let landing_radius = width * config.landing_radius_factor; - let perfect_radius = landing_radius * config.perfect_radius_factor; - - platforms.push(JumpHopPlatform { - platform_id: format!("jump-hop-platform-{index:03}"), - tile_type, - x, - y, - width, - height, - landing_radius, - perfect_radius, - score_value: if tile_type == JumpHopTileType::Bonus { - 180 - } else { - 100 - }, - }); - - if index + 1 < platform_count { - let distance = rng.range_f32(config.min_gap, config.max_gap); - let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 }; - x += distance * 0.62 * direction; - y += distance; - } - } + let platform_count = 8usize; + let platforms = build_platforms_until(seed, difficulty, platform_count); JumpHopPath { seed: seed.trim().to_string(), difficulty, - finish_index: platform_count.saturating_sub(1) as u32, + finish_index: u32::MAX, platforms, camera_preset: "portrait-isometric-9x16".to_string(), scoring: JumpHopScoring { @@ -85,6 +42,7 @@ pub fn start_run( if path.platforms.is_empty() { return Err(JumpHopError::EmptyPath); } + let path = normalize_jump_hop_path_platform_size(path); Ok(JumpHopRunSnapshot { run_id, @@ -103,7 +61,9 @@ pub fn start_run( pub fn apply_jump( run: &JumpHopRunSnapshot, - charge_ms: u32, + drag_distance: f32, + drag_vector_x: Option, + drag_vector_y: Option, jumped_at_ms: u64, ) -> Result { if run.status != JumpHopRunStatus::Playing { @@ -111,46 +71,42 @@ pub fn apply_jump( } let current_index = run.current_platform_index as usize; let next_index = current_index + 1; + let path = extend_jump_hop_path(run.path.clone(), next_index + 3); let current = run .path .platforms .get(current_index) .ok_or(JumpHopError::EmptyPath)?; - let target = run - .path + let target = path .platforms .get(next_index) .ok_or(JumpHopError::NoNextPlatform)?; - let capped_charge = charge_ms.min(run.path.scoring.max_charge_ms); - let jump_distance = capped_charge as f32 * run.path.scoring.charge_to_distance_ratio; + let capped_drag_distance = drag_distance.clamp(0.0, run.path.scoring.max_charge_ms as f32); + let jump_distance = capped_drag_distance * run.path.scoring.charge_to_distance_ratio; let vector_x = target.x - current.x; let vector_y = target.y - current.y; let target_distance = vector_x.hypot(vector_y).max(0.0001); - let unit_x = vector_x / target_distance; - let unit_y = vector_y / target_distance; + let (unit_x, unit_y) = normalize_jump_direction( + drag_vector_x, + drag_vector_y, + vector_x / target_distance, + vector_y / target_distance, + ); let landed_x = current.x + unit_x * jump_distance; let landed_y = current.y + unit_y * jump_distance; let landing_error = (landed_x - target.x).hypot(landed_y - target.y); + let target_landing_radius = target.landing_radius; let mut next = run.clone(); - let result = if landing_error <= target.perfect_radius { - if next_index as u32 == run.path.finish_index { - JumpHopJumpResultKind::Finish - } else { - JumpHopJumpResultKind::Perfect - } - } else if landing_error <= target.landing_radius { - if next_index as u32 == run.path.finish_index { - JumpHopJumpResultKind::Finish - } else { - JumpHopJumpResultKind::Hit - } + next.path = path; + let result = if landing_error <= target_landing_radius { + JumpHopJumpResultKind::Hit } else { JumpHopJumpResultKind::Miss }; next.last_jump = Some(JumpHopLastJump { - charge_ms: capped_charge, + charge_ms: capped_drag_distance.round() as u32, jump_distance, target_platform_index: next_index as u32, landed_x, @@ -166,23 +122,8 @@ pub fn apply_jump( } next.current_platform_index = next_index as u32; - next.combo = next.combo.saturating_add(1); - next.score = next.score.saturating_add(target.score_value); - if matches!( - result, - JumpHopJumpResultKind::Perfect | JumpHopJumpResultKind::Finish - ) { - next.score = next - .score - .saturating_add(run.path.scoring.perfect_bonus) - .saturating_add(next.combo.saturating_mul(run.path.scoring.hit_bonus)); - } else { - next.score = next.score.saturating_add(run.path.scoring.hit_bonus); - } - if result == JumpHopJumpResultKind::Finish { - next.status = JumpHopRunStatus::Cleared; - next.finished_at_ms = Some(jumped_at_ms); - } + next.combo = 0; + next.score = next.current_platform_index; Ok(next) } @@ -201,9 +142,31 @@ pub fn restart_run( ) } +fn normalize_jump_hop_path_platform_size(mut path: JumpHopPath) -> JumpHopPath { + let should_scale_legacy_path = path + .platforms + .iter() + .any(|platform| platform.width < 1.2 && platform.landing_radius < 0.75); + if !should_scale_legacy_path { + if (path.scoring.charge_to_distance_ratio - JUMP_HOP_CHARGE_TO_DISTANCE_RATIO).abs() + > f32::EPSILON + { + path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO; + } + return path; + } + + for platform in &mut path.platforms { + platform.width *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER; + platform.height *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER; + platform.landing_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER; + platform.perfect_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER; + } + path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO; + path +} + struct DifficultyConfig { - min_platforms: u32, - max_platforms: u32, min_gap: f32, max_gap: f32, min_width: f32, @@ -214,54 +177,143 @@ struct DifficultyConfig { max_charge_ms: u32, } +fn build_platforms_until( + seed: &str, + difficulty: JumpHopDifficulty, + required_count: usize, +) -> Vec { + let config = difficulty_config(difficulty); + let mut platforms = Vec::with_capacity(required_count); + let mut x = 0.0f32; + let mut y = 0.0f32; + + for index in 0..required_count { + platforms.push(build_platform(seed, difficulty, index, x, y, &config)); + if index + 1 < required_count { + let mut rng = DeterministicRng::new(seed, &format!("{}:{index}", difficulty.as_str())); + let distance = rng.range_f32(config.min_gap, config.max_gap); + let lane = rng.range_f32(0.42, 0.86); + let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 }; + x += distance * lane * direction; + y += distance; + } + } + + platforms +} + +fn build_platform( + seed: &str, + difficulty: JumpHopDifficulty, + index: usize, + x: f32, + y: f32, + config: &DifficultyConfig, +) -> JumpHopPlatform { + let mut rng = DeterministicRng::new(seed, &format!("platform:{}:{index}", difficulty.as_str())); + let tile_type = if index == 0 { + JumpHopTileType::Start + } else if index % 11 == 0 { + JumpHopTileType::Bonus + } else if index % 7 == 0 { + JumpHopTileType::Accent + } else if index % 3 == 0 { + JumpHopTileType::Target + } else { + JumpHopTileType::Normal + }; + let width = rng.range_f32(config.min_width, config.max_width); + let height = width * rng.range_f32(0.88, 1.06); + let landing_radius = width * config.landing_radius_factor; + + JumpHopPlatform { + platform_id: format!("jump-hop-platform-{index:05}"), + tile_type, + x, + y, + width: width * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER, + height: height * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER, + landing_radius: landing_radius * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER, + perfect_radius: landing_radius + * config.perfect_radius_factor + * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER, + score_value: 1, + } +} + +fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHopPath { + if path.platforms.len() >= required_count { + return path; + } + path.platforms = build_platforms_until(&path.seed, path.difficulty, required_count); + path.finish_index = u32::MAX; + path +} + +fn normalize_jump_direction( + drag_vector_x: Option, + drag_vector_y: Option, + fallback_x: f32, + fallback_y: f32, +) -> (f32, f32) { + let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else { + return (fallback_x, fallback_y); + }; + let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else { + return (fallback_x, fallback_y); + }; + // 前端提交的是屏幕拖拽向量:x 轴同向,y 轴向下为正。 + // 真实起跳需要“反向弹出”,同时把屏幕 y 翻回世界坐标的向上为正。 + let jump_x = -drag_x; + let jump_y = drag_y; + let length = jump_x.hypot(jump_y); + if length < 0.0001 { + (fallback_x, fallback_y) + } else { + (jump_x / length, jump_y / length) + } +} + fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { match difficulty { JumpHopDifficulty::Easy => DifficultyConfig { - min_platforms: 12, - max_platforms: 14, min_gap: 1.0, max_gap: 1.45, min_width: 0.9, max_width: 1.08, landing_radius_factor: 0.62, perfect_radius_factor: 0.32, - charge_to_distance_ratio: 0.004, + charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 700, }, JumpHopDifficulty::Standard => DifficultyConfig { - min_platforms: 16, - max_platforms: 18, min_gap: 1.22, max_gap: 1.78, min_width: 0.82, max_width: 1.0, landing_radius_factor: 0.54, perfect_radius_factor: 0.26, - charge_to_distance_ratio: 0.004, + charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 780, }, JumpHopDifficulty::Advanced => DifficultyConfig { - min_platforms: 20, - max_platforms: 24, min_gap: 1.45, max_gap: 2.05, min_width: 0.72, max_width: 0.94, landing_radius_factor: 0.48, perfect_radius_factor: 0.22, - charge_to_distance_ratio: 0.004, + charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 860, }, JumpHopDifficulty::Challenge => DifficultyConfig { - min_platforms: 26, - max_platforms: 32, min_gap: 1.7, max_gap: 2.35, min_width: 0.66, max_width: 0.88, landing_radius_factor: 0.42, perfect_radius_factor: 0.18, - charge_to_distance_ratio: 0.004, + charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 950, }, } @@ -289,13 +341,6 @@ impl DeterministicRng { (self.state >> 32) as u32 } - fn range_u32(&mut self, min: u32, max: u32) -> u32 { - if max <= min { - return min; - } - min + self.next_u32() % (max - min + 1) - } - fn range_f32(&mut self, min: f32, max: f32) -> f32 { if max <= min { return min; @@ -319,14 +364,67 @@ mod tests { let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge); assert_eq!(first, second); - assert!((16..=18).contains(&first.platforms.len())); - assert!((26..=32).contains(&challenge.platforms.len())); + assert_eq!(first.platforms.len(), 8); + assert_eq!(challenge.platforms.len(), 8); assert_eq!(first.platforms.first().unwrap().tile_type.as_str(), "start"); - assert_eq!(first.platforms.last().unwrap().tile_type.as_str(), "finish"); + assert_eq!(first.finish_index, u32::MAX); } #[test] - fn jump_resolution_distinguishes_perfect_hit_and_miss() { + fn difficulty_charge_to_distance_ratio_is_doubled() { + let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy); + let standard = generate_jump_hop_path("seed-ratio-standard", JumpHopDifficulty::Standard); + let advanced = generate_jump_hop_path("seed-ratio-advanced", JumpHopDifficulty::Advanced); + let challenge = generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge); + + assert_eq!(easy.scoring.charge_to_distance_ratio, 0.008); + assert_eq!(standard.scoring.charge_to_distance_ratio, 0.008); + assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.008); + assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.008); + } + + #[test] + fn generated_platforms_use_double_size_and_landing_radius() { + let path = generate_jump_hop_path("seed-size", JumpHopDifficulty::Standard); + let first_platform = path.platforms.first().expect("platform should exist"); + + assert!(first_platform.width >= 1.64); + assert!(first_platform.width <= 2.0); + assert!(first_platform.height >= 1.44); + assert!(first_platform.height <= 2.12); + assert!(first_platform.landing_radius >= 0.88); + assert!(first_platform.landing_radius <= 1.08); + } + + #[test] + fn start_run_normalizes_legacy_single_size_platforms() { + let mut path = generate_jump_hop_path("seed-legacy", JumpHopDifficulty::Standard); + for platform in &mut path.platforms { + platform.width /= 2.0; + platform.height /= 2.0; + platform.landing_radius /= 2.0; + platform.perfect_radius /= 2.0; + } + let legacy_width = path.platforms[0].width; + let legacy_landing_radius = path.platforms[0].landing_radius; + + let run = start_run( + "run-legacy".to_string(), + "user-legacy".to_string(), + "profile-legacy".to_string(), + path, + 100, + ) + .expect("run should start"); + + assert!((run.path.platforms[0].width - legacy_width * 2.0).abs() < 0.0001); + assert!( + (run.path.platforms[0].landing_radius - legacy_landing_radius * 2.0).abs() < 0.0001 + ); + } + + #[test] + fn jump_resolution_distinguishes_hit_and_miss() { let path = generate_jump_hop_path("seed-b", JumpHopDifficulty::Easy); let run = start_run( "run-1".to_string(), @@ -338,25 +436,25 @@ mod tests { .expect("run should start"); let target = &run.path.platforms[1]; let distance = target.x.hypot(target.y); - let perfect_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32; - - let perfect = apply_jump(&run, perfect_charge, 200).expect("jump should resolve"); - assert_eq!( - perfect.last_jump.as_ref().unwrap().result, - JumpHopJumpResultKind::Perfect - ); - assert_eq!(perfect.status, JumpHopRunStatus::Playing); - assert_eq!(perfect.current_platform_index, 1); + let target_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32; let hit = - apply_jump(&run, perfect_charge.saturating_add(80), 200).expect("jump should resolve"); + apply_jump(&run, target_charge as f32, None, None, 200).expect("jump should resolve"); assert_eq!( hit.last_jump.as_ref().unwrap().result, JumpHopJumpResultKind::Hit ); + assert_eq!(hit.status, JumpHopRunStatus::Playing); + assert_eq!(hit.current_platform_index, 1); - let miss = - apply_jump(&run, perfect_charge.saturating_add(900), 200).expect("jump should resolve"); + let miss = apply_jump( + &run, + target_charge.saturating_add(900) as f32, + None, + None, + 200, + ) + .expect("jump should resolve"); assert_eq!(miss.status, JumpHopRunStatus::Failed); assert_eq!( miss.last_jump.as_ref().unwrap().result, @@ -364,6 +462,39 @@ mod tests { ); } + #[test] + fn jump_resolution_uses_screen_drag_y_axis_for_forward_jump_direction() { + let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy); + let run = start_run( + "run-screen-axis".to_string(), + "user-screen-axis".to_string(), + "profile-screen-axis".to_string(), + path, + 100, + ) + .expect("run should start"); + let current = &run.path.platforms[0]; + let target = &run.path.platforms[1]; + let target_distance = (target.x - current.x).hypot(target.y - current.y); + let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32; + + let result = apply_jump( + &run, + charge as f32, + Some(-(target.x - current.x)), + Some(target.y - current.y), + 200, + ) + .expect("jump should resolve"); + + assert_eq!(result.status, JumpHopRunStatus::Playing); + assert_eq!( + result.last_jump.as_ref().unwrap().result, + JumpHopJumpResultKind::Hit + ); + assert_eq!(result.current_platform_index, 1); + } + #[test] fn restart_returns_to_first_platform_and_playing_state() { let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy); @@ -392,4 +523,32 @@ mod tests { assert_eq!(restarted.started_at_ms, 300); assert!(restarted.finished_at_ms.is_none()); } + + #[test] + fn successful_jump_extends_infinite_path_buffer_and_counts_jumps() { + let path = generate_jump_hop_path("seed-d", JumpHopDifficulty::Easy); + let mut run = start_run( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + path, + 100, + ) + .expect("run should start"); + + for step in 0..9 { + let current = &run.path.platforms[run.current_platform_index as usize]; + let target = &run.path.platforms[run.current_platform_index as usize + 1]; + let distance = (target.x - current.x).hypot(target.y - current.y); + let charge = (distance / run.path.scoring.charge_to_distance_ratio).round() as u32; + run = apply_jump(&run, charge as f32, None, None, 200 + step) + .expect("jump should resolve"); + } + + assert_eq!(run.status, JumpHopRunStatus::Playing); + assert_eq!(run.current_platform_index, 9); + assert_eq!(run.score, 9); + assert!(run.path.platforms.len() >= 12); + assert!(run.finished_at_ms.is_none()); + } } diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 2d997da6..060f0bac 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -120,9 +120,9 @@ pub fn default_creation_entry_type_snapshots( build_default_creation_entry_type_snapshot( "jump-hop", "跳一跳", - "俯视角跳跃闯关", + "主题驱动平台跳跃", "可创建", - "/creation-type-references/puzzle.webp", + "/creation-type-references/jump-hop.webp", true, true, 45, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 054ab40e..9d13e83d 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -293,6 +293,29 @@ mod tests { assert_eq!(wooden_fish.image_src, "/wooden-fish/default-hit-object.png"); } + #[test] + fn default_creation_entry_types_include_jump_hop_theme_only_entry() { + let configs = default_creation_entry_type_snapshots(1); + let jump_hop = configs + .iter() + .find(|item| item.id == "jump-hop") + .expect("jump-hop creation entry should be seeded"); + + assert_eq!(jump_hop.title, "跳一跳"); + assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃"); + assert!(jump_hop.visible); + assert!(jump_hop.open); + assert_eq!(jump_hop.badge, "可创建"); + assert_eq!(jump_hop.sort_order, 45); + assert_eq!( + jump_hop.image_src, + "/creation-type-references/jump-hop.webp" + ); + assert_eq!(jump_hop.category_id, "recommended"); + assert_eq!(jump_hop.category_label, "热门推荐"); + assert_eq!(jump_hop.category_sort_order, 20); + } + #[test] fn normalized_clamps_music_volume_into_valid_range() { let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light); diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index cd2c0a51..0684a314 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -44,7 +44,6 @@ pub enum JumpHopTileType { #[serde(rename_all = "kebab-case")] pub enum JumpHopActionType { CompileDraft, - RegenerateCharacter, RegenerateTiles, UpdateWorkMeta, UpdateDifficulty, @@ -71,12 +70,20 @@ pub enum JumpHopJumpResult { #[serde(rename_all = "camelCase")] pub struct JumpHopWorkspaceCreateRequest { pub template_id: String, + pub theme_text: String, + #[serde(default)] pub work_title: String, + #[serde(default)] pub work_description: String, + #[serde(default)] pub theme_tags: Vec, + #[serde(default = "default_jump_hop_difficulty")] pub difficulty: JumpHopDifficulty, + #[serde(default = "default_jump_hop_style_preset")] pub style_preset: JumpHopStylePreset, + #[serde(default)] pub character_prompt: String, + #[serde(default)] pub tile_prompt: String, #[serde(default)] pub end_mood_prompt: Option, @@ -89,6 +96,8 @@ pub struct JumpHopActionRequest { #[serde(default)] pub profile_id: Option, #[serde(default)] + pub theme_text: Option, + #[serde(default)] pub work_title: Option, #[serde(default)] pub work_description: Option, @@ -127,14 +136,30 @@ pub struct JumpHopCharacterAsset { pub height: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopDefaultCharacter { + pub character_id: String, + pub display_name: String, + pub model_kind: String, + pub body_color: String, + pub accent_color: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct JumpHopTileAsset { pub tile_type: JumpHopTileType, + #[serde(default)] + pub tile_id: Option, pub image_src: String, pub image_object_key: String, pub asset_object_id: String, pub source_atlas_cell: String, + #[serde(default)] + pub atlas_row: Option, + #[serde(default)] + pub atlas_col: Option, pub visual_width: u32, pub visual_height: u32, pub top_surface_radius: f32, @@ -193,11 +218,14 @@ pub struct JumpHopDraftResponse { pub template_name: String, #[serde(default)] pub profile_id: Option, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, pub difficulty: JumpHopDifficulty, pub style_preset: JumpHopStylePreset, + #[serde(default)] + pub default_character: Option, pub character_prompt: String, pub tile_prompt: String, #[serde(default)] @@ -251,6 +279,7 @@ pub struct JumpHopWorkSummaryResponse { pub owner_user_id: String, #[serde(default)] pub source_session_id: Option, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -274,6 +303,8 @@ pub struct JumpHopWorkProfileResponse { pub summary: JumpHopWorkSummaryResponse, pub draft: JumpHopDraftResponse, pub path: JumpHopPath, + #[serde(default)] + pub default_character: Option, pub character_asset: JumpHopCharacterAsset, pub tile_atlas_asset: JumpHopCharacterAsset, pub tile_assets: Vec, @@ -305,6 +336,7 @@ pub struct JumpHopGalleryCardResponse { pub profile_id: String, pub owner_user_id: String, pub author_display_name: String, + pub theme_text: String, pub work_title: String, pub work_description: String, #[serde(default)] @@ -343,6 +375,8 @@ pub struct JumpHopRuntimeRunSnapshotResponse { pub owner_user_id: String, pub status: JumpHopRunStatus, pub current_platform_index: u32, + pub successful_jump_count: u32, + pub duration_ms: u64, pub score: u32, pub combo: u32, pub path: JumpHopPath, @@ -363,15 +397,29 @@ pub struct JumpHopRunResponse { #[serde(rename_all = "camelCase")] pub struct JumpHopStartRunRequest { pub profile_id: String, + #[serde(default)] + pub runtime_mode: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct JumpHopJumpRequest { - pub charge_ms: u32, + pub drag_distance: f32, + #[serde(default)] + pub drag_vector_x: Option, + #[serde(default)] + pub drag_vector_y: Option, pub client_event_id: String, } +fn default_jump_hop_difficulty() -> JumpHopDifficulty { + JumpHopDifficulty::Standard +} + +fn default_jump_hop_style_preset() -> JumpHopStylePreset { + JumpHopStylePreset::MinimalBlocks +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct JumpHopRestartRunRequest { @@ -384,6 +432,25 @@ pub struct JumpHopJumpResponse { pub run: JumpHopRuntimeRunSnapshotResponse, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopLeaderboardEntry { + pub rank: u32, + pub player_id: String, + pub successful_jump_count: u32, + pub duration_ms: u64, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopLeaderboardResponse { + pub profile_id: String, + pub items: Vec, + #[serde(default)] + pub viewer_best: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -393,6 +460,7 @@ mod tests { fn jump_hop_workspace_request_uses_camel_case() { let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest { template_id: "jump-hop".to_string(), + theme_text: "跳一跳".to_string(), work_title: "跳一跳".to_string(), work_description: "俯视角跳跃闯关".to_string(), theme_tags: vec!["休闲".to_string()], diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 28a5ed25..0ba46095 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -1,15 +1,15 @@ use super::*; use crate::mapper::{ map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row, - map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result, - map_jump_hop_works_procedure_result, + map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result, + map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result, }; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse, - JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, - JumpHopTileType, JumpHopWorkProfileResponse, + JumpHopJumpRequest, JumpHopLeaderboardResponse, JumpHopRestartRunRequest, + JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, + JumpHopStylePreset, JumpHopWorkProfileResponse, }; use shared_kernel::build_prefixed_uuid_id; @@ -229,7 +229,7 @@ impl SpacetimeClient { let work = self .get_jump_hop_work_profile(profile_id, String::new()) .await?; - validate_jump_hop_runtime_ready(&work)?; + validate_jump_hop_runtime_ready(&work, "published")?; Ok(work) } @@ -242,13 +242,15 @@ impl SpacetimeClient { let work = self .get_jump_hop_work_profile(profile_id.clone(), String::new()) .await?; - validate_jump_hop_runtime_ready(&work)?; + let runtime_mode = normalize_jump_hop_runtime_mode(payload.runtime_mode.as_deref()); + validate_jump_hop_runtime_ready(&work, runtime_mode)?; let run_id = build_prefixed_uuid_id("jump-hop-run-"); let procedure_input = JumpHopRunStartInput { client_event_id: format!("{run_id}:start"), run_id, owner_user_id, profile_id, + runtime_mode: runtime_mode.to_string(), started_at_ms: current_unix_micros().div_euclid(1000), }; self.start_jump_hop_run_with_input(procedure_input).await @@ -303,7 +305,9 @@ impl SpacetimeClient { let procedure_input = JumpHopRunJumpInput { run_id, owner_user_id, - charge_ms: payload.charge_ms, + drag_distance: payload.drag_distance, + drag_vector_x: payload.drag_vector_x, + drag_vector_y: payload.drag_vector_y, client_event_id: payload.client_event_id, jumped_at_ms: current_unix_micros().div_euclid(1000), }; @@ -396,13 +400,39 @@ impl SpacetimeClient { self.get_jump_hop_work_profile(card.profile_id, String::new()) .await } + + pub async fn get_jump_hop_leaderboard( + &self, + profile_id: String, + viewer_player_id: String, + ) -> Result { + let procedure_input = JumpHopLeaderboardGetInput { + profile_id, + viewer_player_id, + limit: 50, + }; + + self.call_after_connect("get_jump_hop_leaderboard", move |connection, sender| { + connection.procedures().get_jump_hop_leaderboard_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_leaderboard_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } } fn validate_jump_hop_runtime_ready( work: &JumpHopWorkProfileResponse, + runtime_mode: &str, ) -> Result<(), SpacetimeClientError> { let status = work.summary.publication_status.trim().to_ascii_lowercase(); - if status != "published" { + if runtime_mode == "published" && status != "published" { return Err(SpacetimeClientError::validation_failed( "jump-hop runtime 只能启动已发布作品", )); @@ -412,11 +442,11 @@ fn validate_jump_hop_runtime_ready( "jump-hop runtime 需要 ready 状态作品", )); } - validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?; - validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; - if work.tile_assets.is_empty() { + validate_jump_hop_default_character_ready(work)?; + validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; + if work.tile_assets.len() < 25 { return Err(SpacetimeClientError::validation_failed( - "jump-hop runtime 缺少地块资产", + "jump-hop runtime 需要 25 个地块资产", )); } for (index, asset) in work.tile_assets.iter().enumerate() { @@ -437,7 +467,34 @@ fn validate_jump_hop_runtime_ready( Ok(()) } -fn validate_jump_hop_character_asset_ready( +fn normalize_jump_hop_runtime_mode(value: Option<&str>) -> &'static str { + if value + .map(|value| value.trim().eq_ignore_ascii_case("draft")) + .unwrap_or(false) + { + "draft" + } else { + "published" + } +} + +fn validate_jump_hop_default_character_ready( + work: &JumpHopWorkProfileResponse, +) -> Result<(), SpacetimeClientError> { + let Some(default_character) = work.default_character.as_ref() else { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 缺少内置默认角色配置", + )); + }; + if default_character.model_kind.trim() != "builtin-three" { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 默认角色必须使用 builtin-three", + )); + } + Ok(()) +} + +fn validate_jump_hop_tile_atlas_asset_ready( asset: &JumpHopCharacterAsset, field: &str, ) -> Result<(), SpacetimeClientError> { @@ -475,7 +532,6 @@ enum JumpHopActionProcedure { #[derive(Clone, Copy)] enum JumpHopDraftMergeScope { CompileDraft, - RegenerateCharacter, RegenerateTiles, UpdateWorkMeta, UpdateDifficulty, @@ -484,7 +540,6 @@ enum JumpHopDraftMergeScope { #[derive(Clone, Copy)] enum JumpHopAssetRefresh { Preserve, - Character, Tiles, } @@ -496,12 +551,18 @@ fn build_jump_hop_action_plan( ) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> { let scope = match payload.action_type { JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft, - JumpHopActionType::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter, JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles, JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta, JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty, }; - let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?; + let mut base_draft = current.draft.clone(); + if matches!(payload.action_type, JumpHopActionType::RegenerateTiles) { + if let Some(draft) = base_draft.as_mut() { + draft.tile_atlas_asset = None; + draft.tile_assets.clear(); + } + } + let mut draft = merge_action_into_draft(base_draft, payload, scope)?; let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?; draft.profile_id = Some(profile_id.clone()); @@ -514,16 +575,6 @@ fn build_jump_hop_action_plan( JumpHopAssetRefresh::Preserve, now_micros, )?), - JumpHopActionType::RegenerateCharacter => { - JumpHopActionProcedure::Compile(build_compile_input( - current, - owner_user_id, - &profile_id, - &mut draft, - JumpHopAssetRefresh::Character, - now_micros, - )?) - } JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input( current, owner_user_id, @@ -563,6 +614,13 @@ fn merge_action_into_draft( { draft.work_title = value.trim().to_string(); } + if let Some(value) = payload + .theme_text + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.theme_text = value.trim().chars().take(60).collect(); + } if let Some(value) = payload.work_description.as_ref() { draft.work_description = value.trim().to_string(); } @@ -590,10 +648,7 @@ fn merge_action_into_draft( .filter(|value| !value.is_empty()); } } - if matches!( - scope, - JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter - ) { + if matches!(scope, JumpHopDraftMergeScope::CompileDraft) { if let Some(value) = payload .character_prompt .as_ref() @@ -622,10 +677,7 @@ fn merge_action_into_draft( { draft.profile_id = Some(profile_id.to_string()); } - if matches!( - scope, - JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter - ) { + if matches!(scope, JumpHopDraftMergeScope::CompileDraft) { if let Some(asset) = payload.character_asset.clone() { draft.character_asset = Some(asset); } @@ -665,28 +717,19 @@ fn build_compile_input( refresh: JumpHopAssetRefresh, now_micros: i64, ) -> Result { - let force_character = matches!(refresh, JumpHopAssetRefresh::Character); - let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles); - if force_character { - draft.character_asset = None; - } - if force_tiles { - draft.tile_atlas_asset = None; - draft.tile_assets.clear(); - } - let character_asset = draft.character_asset.clone().ok_or_else(|| { - SpacetimeClientError::validation_failed( - "jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object", - ) - })?; + let character_asset = draft.character_asset.clone().unwrap_or_else(|| { + build_jump_hop_default_character_asset(profile_id, draft.theme_text.as_str()) + }); + draft.character_asset = Some(character_asset.clone()); + draft.default_character = Some(default_jump_hop_default_character()); let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed( "jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object", ) })?; - let tile_assets = if draft.tile_assets.is_empty() { + let tile_assets = if draft.tile_assets.len() < 25 { return Err(SpacetimeClientError::validation_failed( - "jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object", + "jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object", )); } else { draft.tile_assets.clone() @@ -705,7 +748,7 @@ fn build_compile_input( work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags_json: Some(json_string(&draft.theme_tags)?), - theme_text: Some(draft.work_title.clone()), + theme_text: Some(draft.theme_text.clone()), difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()), style_preset: Some(style_to_str(&draft.style_preset).to_string()), character_prompt: Some(draft.character_prompt.clone()), @@ -785,13 +828,15 @@ fn default_draft() -> JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, + theme_text: JUMP_HOP_TEMPLATE_NAME.to_string(), work_title: JUMP_HOP_TEMPLATE_NAME.to_string(), work_description: "俯视角跳跃闯关".to_string(), theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()], difficulty: JumpHopDifficulty::Standard, style_preset: JumpHopStylePreset::MinimalBlocks, - character_prompt: "俯视角可爱主角,透明背景".to_string(), - tile_prompt: "等距立体地块图集".to_string(), + default_character: Some(default_jump_hop_default_character()), + character_prompt: "内置默认 3D 角色".to_string(), + tile_prompt: "跳一跳主题的俯视角清爽游戏化立体感平台素材".to_string(), end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, @@ -804,7 +849,7 @@ fn default_draft() -> JumpHopDraftResponse { fn build_config_json(draft: &JumpHopDraftResponse) -> Result { serde_json::to_string(&serde_json::json!({ - "themeText": draft.work_title, + "themeText": draft.theme_text, "difficulty": difficulty_to_str(&draft.difficulty), "stylePreset": style_to_str(&draft.style_preset), "characterPrompt": draft.character_prompt, @@ -814,94 +859,6 @@ fn build_config_json(draft: &JumpHopDraftResponse) -> Result, - profile_id: &str, - prompt: &str, - force_new: bool, - now_micros: i64, -) -> JumpHopCharacterAsset { - if !force_new { - if let Some(asset) = existing { - return asset; - } - } - let revision = force_new.then_some(now_micros); - let suffix = asset_revision_suffix(revision); - JumpHopCharacterAsset { - asset_id: format!("{profile_id}-character{suffix}"), - image_src: format!("/generated-jump-hop-assets/{profile_id}/character{suffix}.png"), - image_object_key: format!("generated-jump-hop-assets/{profile_id}/character{suffix}.png"), - asset_object_id: format!("{profile_id}-character{suffix}-object"), - generation_provider: "deterministic-placeholder".to_string(), - prompt: prompt.to_string(), - width: 768, - height: 768, - } -} - -fn ensure_tile_atlas_asset( - existing: Option, - profile_id: &str, - prompt: &str, - force_new: bool, - now_micros: i64, -) -> JumpHopCharacterAsset { - if !force_new { - if let Some(asset) = existing { - return asset; - } - } - let revision = force_new.then_some(now_micros); - let suffix = asset_revision_suffix(revision); - JumpHopCharacterAsset { - asset_id: format!("{profile_id}-tile-atlas{suffix}"), - image_src: format!("/generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"), - image_object_key: format!("generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"), - asset_object_id: format!("{profile_id}-tile-atlas{suffix}-object"), - generation_provider: "deterministic-placeholder".to_string(), - prompt: prompt.to_string(), - width: 1024, - height: 1024, - } -} - -fn ensure_tile_assets( - existing: Vec, - profile_id: &str, - force_new: bool, - now_micros: i64, -) -> Vec { - if !force_new && !existing.is_empty() { - return existing; - } - let suffix = asset_revision_suffix(force_new.then_some(now_micros)); - [ - JumpHopTileType::Start, - JumpHopTileType::Normal, - JumpHopTileType::Target, - JumpHopTileType::Finish, - JumpHopTileType::Bonus, - JumpHopTileType::Accent, - ] - .into_iter() - .enumerate() - .map(|(index, tile_type)| JumpHopTileAsset { - tile_type, - image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"), - image_object_key: format!( - "generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png" - ), - asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"), - source_atlas_cell: format!("cell-{index}{suffix}"), - visual_width: 256, - visual_height: 192, - top_surface_radius: 42.0, - landing_radius: 34.0, - }) - .collect() -} - fn resolve_cover_composite( draft: &JumpHopDraftResponse, profile_id: &str, @@ -926,6 +883,22 @@ fn resolve_cover_composite( )) } +fn build_jump_hop_default_character_asset( + profile_id: &str, + theme_text: &str, +) -> JumpHopCharacterAsset { + JumpHopCharacterAsset { + asset_id: format!("{profile_id}-builtin-character"), + image_src: "builtin://jump-hop/default-character".to_string(), + image_object_key: String::new(), + asset_object_id: format!("{profile_id}-builtin-character"), + generation_provider: "builtin-three".to_string(), + prompt: format!("内置默认 3D 角色:{}", theme_text.trim()), + width: 0, + height: 0, + } +} + fn asset_revision_suffix(revision: Option) -> String { revision .filter(|value| *value > 0) @@ -957,6 +930,16 @@ fn style_to_str(value: &JumpHopStylePreset) -> &'static str { } } +fn default_jump_hop_default_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter { + shared_contracts::jump_hop::JumpHopDefaultCharacter { + character_id: "jump-hop-default-runner".to_string(), + display_name: "默认角色".to_string(), + model_kind: "builtin-three".to_string(), + body_color: "#f59e0b".to_string(), + accent_color: "#2563eb".to_string(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -968,8 +951,9 @@ mod tests { const NOW_MICROS: i64 = 1_763_456_789_000_000; #[test] - fn jump_hop_action_compile_draft_builds_compile_input_with_assets() { - let session = session_with_draft(draft_without_assets()); + fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character() + { + let session = session_with_draft(draft_without_character_asset()); let payload = action(JumpHopActionType::CompileDraft); let (plan, draft) = @@ -987,7 +971,7 @@ mod tests { .character_asset_json .as_deref() .unwrap_or("") - .contains("-character") + .contains("builtin-three") ); assert!( input @@ -1001,59 +985,19 @@ mod tests { .tile_assets_json .as_deref() .unwrap_or("") - .contains("tile-0-object") + .contains("old-tile-25-object") ); + assert_eq!(draft.tile_assets.len(), 25); assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready); } - #[test] - fn jump_hop_action_regenerate_character_replaces_only_character_asset_input() { - let session = session_with_draft(draft_with_assets()); - let mut payload = action(JumpHopActionType::RegenerateCharacter); - payload.character_prompt = Some("新的主角提示词".to_string()); - - let (plan, _draft) = - build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) - .expect("regenerate-character should build plan"); - - let JumpHopActionProcedure::Compile(input) = plan else { - panic!("regenerate-character should call compile_jump_hop_draft"); - }; - assert!( - !input - .character_asset_json - .as_deref() - .unwrap_or("") - .contains("old-character") - ); - assert!( - input - .character_asset_json - .as_deref() - .unwrap_or("") - .contains(&NOW_MICROS.to_string()) - ); - assert!( - input - .tile_atlas_asset_json - .as_deref() - .unwrap_or("") - .contains("old-tile-atlas") - ); - assert!( - input - .tile_assets_json - .as_deref() - .unwrap_or("") - .contains("old-normal-tile") - ); - } - #[test] fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() { let session = session_with_draft(draft_with_assets()); let mut payload = action(JumpHopActionType::RegenerateTiles); payload.tile_prompt = Some("新的地块提示词".to_string()); + payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS)); + payload.tile_assets = Some(tile_assets("new", 25)); let (plan, _draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) @@ -1067,7 +1011,7 @@ mod tests { .character_asset_json .as_deref() .unwrap_or("") - .contains("old-character") + .contains("builtin-three") ); assert!( !input @@ -1081,24 +1025,43 @@ mod tests { .tile_assets_json .as_deref() .unwrap_or("") - .contains("old-normal-tile") + .contains("old-tile-01-object") ); assert!( input .tile_atlas_asset_json .as_deref() .unwrap_or("") - .contains(&NOW_MICROS.to_string()) + .contains("new-tile-atlas") ); assert!( input .tile_assets_json .as_deref() .unwrap_or("") - .contains(&NOW_MICROS.to_string()) + .contains("new-tile-25-object") ); } + #[test] + fn jump_hop_action_compile_draft_persists_theme_text_separately_from_title() { + let session = session_with_draft(draft_without_character_asset()); + let mut payload = action(JumpHopActionType::CompileDraft); + payload.theme_text = Some(" 森林蘑菇跳台 ".to_string()); + payload.work_title = Some("自动标题".to_string()); + + let (plan, draft) = + build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("compile-draft should build plan"); + + let JumpHopActionProcedure::Compile(input) = plan else { + panic!("compile-draft should call compile_jump_hop_draft"); + }; + assert_eq!(draft.theme_text, "森林蘑菇跳台"); + assert_eq!(input.theme_text.as_deref(), Some("森林蘑菇跳台")); + assert_eq!(input.work_title, "自动标题"); + } + #[test] fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() { let session = session_with_draft(draft_with_assets()); @@ -1143,20 +1106,22 @@ mod tests { .character_asset .as_ref() .map(|asset| asset.asset_id.as_str()), - Some("old-character") + Some("jump-hop-profile-test-builtin-character") ); assert_eq!( draft .tile_assets .first() .map(|asset| asset.asset_object_id.as_str()), - Some("old-normal-tile-object") + Some("old-tile-01-object") ); } fn action(action_type: JumpHopActionType) -> JumpHopActionRequest { JumpHopActionRequest { action_type, + profile_id: None, + theme_text: None, work_title: None, work_description: None, theme_tags: None, @@ -1165,6 +1130,10 @@ mod tests { character_prompt: None, tile_prompt: None, end_mood_prompt: None, + character_asset: None, + tile_atlas_asset: None, + tile_assets: None, + cover_composite: None, } } @@ -1179,9 +1148,11 @@ mod tests { } } - fn draft_without_assets() -> JumpHopDraftResponse { + fn draft_without_character_asset() -> JumpHopDraftResponse { JumpHopDraftResponse { profile_id: None, + tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)), + tile_assets: tile_assets("old", 25), ..base_draft() } } @@ -1189,37 +1160,9 @@ mod tests { fn draft_with_assets() -> JumpHopDraftResponse { JumpHopDraftResponse { profile_id: Some(PROFILE_ID.to_string()), - character_asset: Some(JumpHopCharacterAsset { - asset_id: "old-character".to_string(), - image_src: "/generated-jump-hop-assets/old-character.png".to_string(), - image_object_key: "generated-jump-hop-assets/old-character.png".to_string(), - asset_object_id: "old-character-object".to_string(), - generation_provider: "old-provider".to_string(), - prompt: "旧角色提示词".to_string(), - width: 768, - height: 768, - }), - tile_atlas_asset: Some(JumpHopCharacterAsset { - asset_id: "old-tile-atlas".to_string(), - image_src: "/generated-jump-hop-assets/old-tile-atlas.png".to_string(), - image_object_key: "generated-jump-hop-assets/old-tile-atlas.png".to_string(), - asset_object_id: "old-tile-atlas-object".to_string(), - generation_provider: "old-provider".to_string(), - prompt: "旧地块提示词".to_string(), - width: 1024, - height: 1024, - }), - tile_assets: vec![JumpHopTileAsset { - tile_type: JumpHopTileType::Normal, - image_src: "/generated-jump-hop-assets/old-normal-tile.png".to_string(), - image_object_key: "generated-jump-hop-assets/old-normal-tile.png".to_string(), - asset_object_id: "old-normal-tile-object".to_string(), - source_atlas_cell: "old-cell".to_string(), - visual_width: 256, - visual_height: 192, - top_surface_radius: 42.0, - landing_radius: 34.0, - }], + character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")), + tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)), + tile_assets: tile_assets("old", 25), path: Some(sample_jump_hop_path()), cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()), generation_status: JumpHopGenerationStatus::Ready, @@ -1227,16 +1170,58 @@ mod tests { } } + fn tile_atlas_asset(asset_id: &str, revision: i64) -> JumpHopCharacterAsset { + let suffix = asset_revision_suffix((revision > 0).then_some(revision)); + JumpHopCharacterAsset { + asset_id: asset_id.to_string(), + image_src: format!("/generated-jump-hop-assets/{asset_id}{suffix}.png"), + image_object_key: format!("generated-jump-hop-assets/{asset_id}{suffix}.png"), + asset_object_id: format!("{asset_id}-object"), + generation_provider: "vector-engine-image2".to_string(), + prompt: "旧地块提示词".to_string(), + width: 1024, + height: 1024, + } + } + + fn tile_assets(prefix: &str, count: usize) -> Vec { + (0..count) + .map(|index| JumpHopTileAsset { + tile_type: if index == 0 { + JumpHopTileType::Start + } else { + JumpHopTileType::Normal + }, + tile_id: Some(format!("tile-{:02}", index + 1)), + image_src: format!("/generated-jump-hop-assets/{prefix}-tile-{}.png", index + 1), + image_object_key: format!( + "generated-jump-hop-assets/{prefix}-tile-{}.png", + index + 1 + ), + asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1), + source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1), + atlas_row: Some(index as u32 / 5 + 1), + atlas_col: Some(index as u32 % 5 + 1), + visual_width: 256, + visual_height: 192, + top_surface_radius: 42.0, + landing_radius: 34.0, + }) + .collect() + } + fn base_draft() -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, + theme_text: "旧主题".to_string(), work_title: "旧标题".to_string(), work_description: "旧描述".to_string(), theme_tags: vec!["旧标签".to_string()], difficulty: JumpHopDifficulty::Standard, style_preset: JumpHopStylePreset::MinimalBlocks, + default_character: Some(default_jump_hop_default_character()), character_prompt: "旧角色提示词".to_string(), tile_prompt: "旧地块提示词".to_string(), end_mood_prompt: None, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index fa080b9d..271f1be4 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -171,8 +171,8 @@ pub(crate) use self::inventory::{ }; pub(crate) use self::jump_hop::{ map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row, - map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result, - map_jump_hop_works_procedure_result, + map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result, + map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result, }; pub(crate) use self::match3d::{ map_match3d_agent_session_procedure_result, map_match3d_click_item_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs b/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs index ae4c3253..19f51490 100644 --- a/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs @@ -163,6 +163,7 @@ mod tests { let row = BarkBattleGalleryViewRow { work_id: "BB-33333333".to_string(), owner_user_id: "user-3".to_string(), + author_display_name: "声浪玩家".to_string(), source_draft_id: Some("bark-battle-draft-3".to_string()), config_version: 1, ruleset_version: "bark-battle-ruleset-v1".to_string(), diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs index a2384840..e59a722d 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -1,10 +1,11 @@ use super::*; pub use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, - JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, + JumpHopDefaultCharacter, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, - JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, + JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, + JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopPath, JumpHopPlatform, + JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, @@ -61,6 +62,25 @@ pub(crate) fn map_jump_hop_run_procedure_result( Ok(map_jump_hop_run_snapshot(run)) } +pub(crate) fn map_jump_hop_leaderboard_procedure_result( + result: JumpHopLeaderboardProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(JumpHopLeaderboardResponse { + profile_id: result.profile_id, + items: result + .items + .into_iter() + .map(map_jump_hop_leaderboard_entry_snapshot) + .collect(), + viewer_best: result + .viewer_best + .map(map_jump_hop_leaderboard_entry_snapshot), + }) +} + pub(crate) fn map_jump_hop_gallery_card_view_row( row: JumpHopGalleryCardViewRow, ) -> JumpHopGalleryCardResponse { @@ -70,6 +90,7 @@ pub(crate) fn map_jump_hop_gallery_card_view_row( profile_id: row.profile_id, owner_user_id: row.owner_user_id, author_display_name: row.author_display_name, + theme_text: row.work_title.clone(), work_title: row.work_title, work_description: row.work_description, cover_image_src: empty_string_to_none(row.cover_image_src), @@ -108,11 +129,13 @@ fn map_jump_hop_work_snapshot( template_id: "jump-hop".to_string(), template_name: "跳一跳".to_string(), profile_id: Some(snapshot.profile_id.clone()), + theme_text: snapshot.work_title.clone(), work_title: snapshot.work_title.clone(), work_description: snapshot.work_description.clone(), theme_tags: snapshot.theme_tags.clone(), difficulty: parse_difficulty(&snapshot.difficulty), style_preset: parse_style_preset(&snapshot.style_preset), + default_character: Some(default_jump_hop_character()), character_prompt: snapshot.character_prompt.clone(), tile_prompt: snapshot.tile_prompt.clone(), end_mood_prompt: snapshot.end_mood_prompt.clone(), @@ -143,6 +166,7 @@ fn map_jump_hop_work_snapshot( profile_id: snapshot.profile_id, owner_user_id: snapshot.owner_user_id, source_session_id: empty_string_to_none(snapshot.source_session_id), + theme_text: snapshot.work_title.clone(), work_title: snapshot.work_title, work_description: snapshot.work_description, theme_tags: snapshot.theme_tags, @@ -159,6 +183,7 @@ fn map_jump_hop_work_snapshot( }, draft, path: map_jump_hop_path(snapshot.path), + default_character: Some(default_jump_hop_character()), character_asset, tile_atlas_asset, tile_assets: snapshot @@ -170,15 +195,18 @@ fn map_jump_hop_work_snapshot( } fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse { + let theme_text = snapshot.work_title.clone(); JumpHopDraftResponse { template_id: snapshot.template_id, template_name: snapshot.template_name, profile_id: snapshot.profile_id, + theme_text, work_title: snapshot.work_title, work_description: snapshot.work_description, theme_tags: snapshot.theme_tags, difficulty: parse_difficulty(&snapshot.difficulty), style_preset: parse_style_preset(&snapshot.style_preset), + default_character: Some(default_jump_hop_character()), character_prompt: snapshot.character_prompt, tile_prompt: snapshot.tile_prompt, end_mood_prompt: snapshot.end_mood_prompt, @@ -211,10 +239,13 @@ fn map_character_asset(snapshot: JumpHopCharacterAssetSnapshot) -> JumpHopCharac fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset { JumpHopTileAsset { tile_type: parse_tile_type(&snapshot.tile_type), + tile_id: snapshot.tile_id, image_src: snapshot.image_src, image_object_key: snapshot.image_object_key, asset_object_id: snapshot.asset_object_id, source_atlas_cell: snapshot.source_atlas_cell, + atlas_row: snapshot.atlas_row, + atlas_col: snapshot.atlas_col, visual_width: snapshot.visual_width, visual_height: snapshot.visual_height, top_surface_radius: snapshot.top_surface_radius, @@ -263,6 +294,8 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS crate::module_bindings::JumpHopRunStatus::Playing => JumpHopRunStatus::Playing, }, current_platform_index: snapshot.current_platform_index, + successful_jump_count: snapshot.current_platform_index, + duration_ms: jump_hop_duration_ms(snapshot.started_at_ms, snapshot.finished_at_ms), score: snapshot.score, combo: snapshot.combo, path: map_jump_hop_path(snapshot.path), @@ -286,6 +319,34 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS } } +fn map_jump_hop_leaderboard_entry_snapshot( + snapshot: JumpHopLeaderboardEntrySnapshot, +) -> JumpHopLeaderboardEntry { + JumpHopLeaderboardEntry { + rank: snapshot.rank, + player_id: snapshot.player_id, + successful_jump_count: snapshot.successful_jump_count, + duration_ms: snapshot.duration_ms, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn default_jump_hop_character() -> JumpHopDefaultCharacter { + JumpHopDefaultCharacter { + character_id: "jump-hop-default-runner".to_string(), + display_name: "默认角色".to_string(), + model_kind: "builtin-three".to_string(), + body_color: "#f59e0b".to_string(), + accent_color: "#2563eb".to_string(), + } +} + +fn jump_hop_duration_ms(started_at_ms: u64, finished_at_ms: Option) -> u64 { + finished_at_ms + .unwrap_or(started_at_ms) + .saturating_sub(started_at_ms) +} + fn parse_difficulty(value: &str) -> JumpHopDifficulty { match value { "easy" => JumpHopDifficulty::Easy, diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs index 3e146d65..0d537266 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -280,7 +280,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows( }, creation_types: creation_types .into_iter() - .map(|item| module_runtime::CreationEntryTypeSnapshot { + .map(|item| normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot { id: item.id, title: item.title, subtitle: item.subtitle, @@ -299,7 +299,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows( ), category_sort_order: item.category_sort_order, updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), - }) + })) .collect(), updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), }, @@ -332,19 +332,21 @@ fn map_creation_entry_config_snapshot( creation_types: snapshot .creation_types .into_iter() - .map(|item| module_runtime::CreationEntryTypeSnapshot { - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - image_src: item.image_src, - visible: item.visible, - open: item.open, - sort_order: item.sort_order, - category_id: item.category_id, - category_label: item.category_label, - category_sort_order: item.category_sort_order, - updated_at_micros: item.updated_at_micros, + .map(|item| { + normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + category_id: item.category_id, + category_label: item.category_label, + category_sort_order: item.category_sort_order, + updated_at_micros: item.updated_at_micros, + }) }) .collect(), updated_at_micros: snapshot.updated_at_micros, @@ -358,6 +360,138 @@ fn creation_entry_text_or_default(value: Option, default_value: &str) -> .unwrap_or_else(|| default_value.to_string()) } +fn normalize_creation_entry_type_snapshot( + item: module_runtime::CreationEntryTypeSnapshot, +) -> module_runtime::CreationEntryTypeSnapshot { + // 中文注释:旧库里残留的跳一跳系统默认入口行仍会从订阅缓存命中,这里统一做读模型纠偏, + // 这样无论走订阅缓存还是 procedure 回退,创作页都只会看到新的跳一跳入口口径。 + if item.id == "jump-hop" + && item.title == "跳一跳" + && item.subtitle == "俯视角跳跃闯关" + && item.badge == "可创建" + && item.image_src == "/creation-type-references/puzzle.webp" + && item.visible + && item.open + && item.sort_order == 45 + { + return module_runtime::CreationEntryTypeSnapshot { + subtitle: "主题驱动平台跳跃".to_string(), + image_src: "/creation-type-references/jump-hop.webp".to_string(), + ..item + }; + } + + item +} + +#[cfg(test)] +mod tests { + use super::*; + use spacetimedb_sdk::Timestamp; + + fn build_creation_entry_header() -> CreationEntryConfig { + CreationEntryConfig { + config_id: "creation-entry-config".to_string(), + start_title: "新建作品".to_string(), + start_description: "选择模板后进入对应的创作表单。".to_string(), + start_idle_badge: "模板 Tab".to_string(), + start_busy_badge: "正在开启".to_string(), + modal_title: "选择创作类型".to_string(), + modal_description: "先选玩法类型,再进入对应创作工作台。".to_string(), + updated_at: Timestamp::from_micros_since_unix_epoch(1_000_000), + event_title: None, + event_description: None, + event_cover_image_src: None, + event_prize_pool_mud_points: 0, + event_starts_at_text: None, + event_ends_at_text: None, + } + } + + fn build_old_jump_hop_row() -> CreationEntryTypeConfig { + CreationEntryTypeConfig { + id: "jump-hop".to_string(), + title: "跳一跳".to_string(), + subtitle: "俯视角跳跃闯关".to_string(), + badge: "可创建".to_string(), + image_src: "/creation-type-references/puzzle.webp".to_string(), + visible: true, + open: true, + sort_order: 45, + updated_at: Timestamp::from_micros_since_unix_epoch(2_000_000), + category_id: Some("recommended".to_string()), + category_label: Some("热门推荐".to_string()), + category_sort_order: 20, + } + } + + #[test] + fn build_creation_entry_config_record_from_rows_normalizes_old_jump_hop_row() { + let record = build_creation_entry_config_record_from_rows( + build_creation_entry_header(), + vec![build_old_jump_hop_row()], + ); + + let jump_hop = record + .creation_types + .iter() + .find(|item| item.id == "jump-hop") + .expect("should contain jump-hop"); + + assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃"); + assert_eq!(jump_hop.image_src, "/creation-type-references/jump-hop.webp"); + } + + #[test] + fn map_creation_entry_config_snapshot_normalizes_old_jump_hop_snapshot() { + let record = map_creation_entry_config_snapshot(CreationEntryConfigSnapshot { + config_id: "creation-entry-config".to_string(), + start_card: CreationEntryStartCardSnapshot { + title: "新建作品".to_string(), + description: "选择模板后进入对应的创作表单。".to_string(), + idle_badge: "模板 Tab".to_string(), + busy_badge: "正在开启".to_string(), + }, + type_modal: CreationEntryTypeModalSnapshot { + title: "选择创作类型".to_string(), + description: "先选玩法类型,再进入对应创作工作台。".to_string(), + }, + event_banner: CreationEntryEventBannerSnapshot { + title: "主题创作赛".to_string(), + description: "用温暖的色彩,捏出秋天的故事。".to_string(), + cover_image_src: "/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png".to_string(), + prize_pool_mud_points: 58_000, + starts_at_text: "2024.10.20 10:00".to_string(), + ends_at_text: "2024.11.20 23:59".to_string(), + }, + creation_types: vec![CreationEntryTypeSnapshot { + id: "jump-hop".to_string(), + title: "跳一跳".to_string(), + subtitle: "俯视角跳跃闯关".to_string(), + badge: "可创建".to_string(), + image_src: "/creation-type-references/puzzle.webp".to_string(), + visible: true, + open: true, + sort_order: 45, + category_id: "recommended".to_string(), + category_label: "热门推荐".to_string(), + category_sort_order: 20, + updated_at_micros: 2_000_000, + }], + updated_at_micros: 1_000_000, + }); + + let jump_hop = record + .creation_types + .iter() + .find(|item| item.id == "jump-hop") + .expect("should contain jump-hop"); + + assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃"); + assert_eq!(jump_hop.image_src, "/creation-type-references/jump-hop.webp"); + } +} + pub(crate) fn map_runtime_setting_procedure_result( result: RuntimeSettingProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index bcd66689..e1d4c952 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -365,6 +365,7 @@ pub mod get_custom_world_gallery_detail_by_code_procedure; pub mod get_custom_world_gallery_detail_procedure; pub mod get_custom_world_library_detail_procedure; pub mod get_jump_hop_agent_session_procedure; +pub mod get_jump_hop_leaderboard_procedure; pub mod get_jump_hop_run_procedure; pub mod get_jump_hop_work_profile_procedure; pub mod get_match_3_d_agent_session_procedure; @@ -433,6 +434,11 @@ pub mod jump_hop_gallery_view_table; pub mod jump_hop_jump_procedure; pub mod jump_hop_jump_result_kind_type; pub mod jump_hop_last_jump_type; +pub mod jump_hop_leaderboard_entry_row_type; +pub mod jump_hop_leaderboard_entry_snapshot_type; +pub mod jump_hop_leaderboard_entry_table; +pub mod jump_hop_leaderboard_get_input_type; +pub mod jump_hop_leaderboard_procedure_result_type; pub mod jump_hop_path_type; pub mod jump_hop_platform_type; pub mod jump_hop_run_get_input_type; @@ -1404,6 +1410,7 @@ pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gall pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session; +pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard; pub use get_jump_hop_run_procedure::get_jump_hop_run; pub use get_jump_hop_work_profile_procedure::get_jump_hop_work_profile; pub use get_match_3_d_agent_session_procedure::get_match_3_d_agent_session; @@ -1472,6 +1479,11 @@ pub use jump_hop_gallery_view_table::*; pub use jump_hop_jump_procedure::jump_hop_jump; pub use jump_hop_jump_result_kind_type::JumpHopJumpResultKind; pub use jump_hop_last_jump_type::JumpHopLastJump; +pub use jump_hop_leaderboard_entry_row_type::JumpHopLeaderboardEntryRow; +pub use jump_hop_leaderboard_entry_snapshot_type::JumpHopLeaderboardEntrySnapshot; +pub use jump_hop_leaderboard_entry_table::*; +pub use jump_hop_leaderboard_get_input_type::JumpHopLeaderboardGetInput; +pub use jump_hop_leaderboard_procedure_result_type::JumpHopLeaderboardProcedureResult; pub use jump_hop_path_type::JumpHopPath; pub use jump_hop_platform_type::JumpHopPlatform; pub use jump_hop_run_get_input_type::JumpHopRunGetInput; @@ -2400,6 +2412,7 @@ pub struct DbUpdate { jump_hop_event: __sdk::TableUpdate, jump_hop_gallery_card_view: __sdk::TableUpdate, jump_hop_gallery_view: __sdk::TableUpdate, + jump_hop_leaderboard_entry: __sdk::TableUpdate, jump_hop_runtime_run: __sdk::TableUpdate, jump_hop_work_profile: __sdk::TableUpdate, match_3_d_agent_message: __sdk::TableUpdate, @@ -2614,6 +2627,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "jump_hop_gallery_view" => db_update.jump_hop_gallery_view.append( jump_hop_gallery_view_table::parse_table_update(table_update)?, ), + "jump_hop_leaderboard_entry" => db_update.jump_hop_leaderboard_entry.append( + jump_hop_leaderboard_entry_table::parse_table_update(table_update)?, + ), "jump_hop_runtime_run" => db_update.jump_hop_runtime_run.append( jump_hop_runtime_run_table::parse_table_update(table_update)?, ), @@ -3043,6 +3059,12 @@ impl __sdk::DbUpdate for DbUpdate { diff.jump_hop_event = cache .apply_diff_to_table::("jump_hop_event", &self.jump_hop_event) .with_updates_by_pk(|row| &row.event_id); + diff.jump_hop_leaderboard_entry = cache + .apply_diff_to_table::( + "jump_hop_leaderboard_entry", + &self.jump_hop_leaderboard_entry, + ) + .with_updates_by_pk(|row| &row.entry_id); diff.jump_hop_runtime_run = cache .apply_diff_to_table::( "jump_hop_runtime_run", @@ -3528,6 +3550,9 @@ impl __sdk::DbUpdate for DbUpdate { "jump_hop_gallery_view" => db_update .jump_hop_gallery_view .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "jump_hop_leaderboard_entry" => db_update + .jump_hop_leaderboard_entry + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "jump_hop_runtime_run" => db_update .jump_hop_runtime_run .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3871,6 +3896,9 @@ impl __sdk::DbUpdate for DbUpdate { "jump_hop_gallery_view" => db_update .jump_hop_gallery_view .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "jump_hop_leaderboard_entry" => db_update + .jump_hop_leaderboard_entry + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "jump_hop_runtime_run" => db_update .jump_hop_runtime_run .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4130,6 +4158,7 @@ pub struct AppliedDiff<'r> { jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>, jump_hop_gallery_card_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryCardViewRow>, jump_hop_gallery_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryViewRow>, + jump_hop_leaderboard_entry: __sdk::TableAppliedDiff<'r, JumpHopLeaderboardEntryRow>, jump_hop_runtime_run: __sdk::TableAppliedDiff<'r, JumpHopRuntimeRunRow>, jump_hop_work_profile: __sdk::TableAppliedDiff<'r, JumpHopWorkProfileRow>, match_3_d_agent_message: __sdk::TableAppliedDiff<'r, Match3DAgentMessageRow>, @@ -4422,6 +4451,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.jump_hop_gallery_view, event, ); + callbacks.invoke_table_row_callbacks::( + "jump_hop_leaderboard_entry", + &self.jump_hop_leaderboard_entry, + event, + ); callbacks.invoke_table_row_callbacks::( "jump_hop_runtime_run", &self.jump_hop_runtime_run, @@ -5444,6 +5478,7 @@ impl __sdk::SpacetimeModule for RemoteModule { jump_hop_event_table::register_table(client_cache); jump_hop_gallery_card_view_table::register_table(client_cache); jump_hop_gallery_view_table::register_table(client_cache); + jump_hop_leaderboard_entry_table::register_table(client_cache); jump_hop_runtime_run_table::register_table(client_cache); jump_hop_work_profile_table::register_table(client_cache); match_3_d_agent_message_table::register_table(client_cache); @@ -5556,6 +5591,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "jump_hop_event", "jump_hop_gallery_card_view", "jump_hop_gallery_view", + "jump_hop_leaderboard_entry", "jump_hop_runtime_run", "jump_hop_work_profile", "match_3_d_agent_message", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs new file mode 100644 index 00000000..519e5acd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::jump_hop_leaderboard_get_input_type::JumpHopLeaderboardGetInput; +use super::jump_hop_leaderboard_procedure_result_type::JumpHopLeaderboardProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetJumpHopLeaderboardArgs { + pub input: JumpHopLeaderboardGetInput, +} + +impl __sdk::InModule for GetJumpHopLeaderboardArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_jump_hop_leaderboard`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_jump_hop_leaderboard { + fn get_jump_hop_leaderboard(&self, input: JumpHopLeaderboardGetInput) { + self.get_jump_hop_leaderboard_then(input, |_, _| {}); + } + + fn get_jump_hop_leaderboard_then( + &self, + input: JumpHopLeaderboardGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_jump_hop_leaderboard for super::RemoteProcedures { + fn get_jump_hop_leaderboard_then( + &self, + input: JumpHopLeaderboardGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopLeaderboardProcedureResult>( + "get_jump_hop_leaderboard", + GetJumpHopLeaderboardArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_row_type.rs new file mode 100644 index 00000000..369cbcce --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_row_type.rs @@ -0,0 +1,72 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLeaderboardEntryRow { + pub entry_id: String, + pub profile_id: String, + pub player_id: String, + pub successful_jump_count: u32, + pub duration_ms: u64, + pub run_id: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for JumpHopLeaderboardEntryRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `JumpHopLeaderboardEntryRow`. +/// +/// Provides typed access to columns for query building. +pub struct JumpHopLeaderboardEntryRowCols { + pub entry_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub player_id: __sdk::__query_builder::Col, + pub successful_jump_count: __sdk::__query_builder::Col, + pub duration_ms: __sdk::__query_builder::Col, + pub run_id: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for JumpHopLeaderboardEntryRow { + type Cols = JumpHopLeaderboardEntryRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + JumpHopLeaderboardEntryRowCols { + entry_id: __sdk::__query_builder::Col::new(table_name, "entry_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + player_id: __sdk::__query_builder::Col::new(table_name, "player_id"), + successful_jump_count: __sdk::__query_builder::Col::new( + table_name, + "successful_jump_count", + ), + duration_ms: __sdk::__query_builder::Col::new(table_name, "duration_ms"), + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `JumpHopLeaderboardEntryRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct JumpHopLeaderboardEntryRowIxCols { + pub entry_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for JumpHopLeaderboardEntryRow { + type IxCols = JumpHopLeaderboardEntryRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + JumpHopLeaderboardEntryRowIxCols { + entry_id: __sdk::__query_builder::IxCol::new(table_name, "entry_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for JumpHopLeaderboardEntryRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_snapshot_type.rs new file mode 100644 index 00000000..f8269a17 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLeaderboardEntrySnapshot { + pub rank: u32, + pub player_id: String, + pub successful_jump_count: u32, + pub duration_ms: u64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for JumpHopLeaderboardEntrySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_table.rs new file mode 100644 index 00000000..1d6ea6ec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::jump_hop_leaderboard_entry_row_type::JumpHopLeaderboardEntryRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `jump_hop_leaderboard_entry`. +/// +/// Obtain a handle from the [`JumpHopLeaderboardEntryTableAccess::jump_hop_leaderboard_entry`] method on [`super::RemoteTables`], +/// like `ctx.db.jump_hop_leaderboard_entry()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_leaderboard_entry().on_insert(...)`. +pub struct JumpHopLeaderboardEntryTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `jump_hop_leaderboard_entry`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait JumpHopLeaderboardEntryTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`JumpHopLeaderboardEntryTableHandle`], which mediates access to the table `jump_hop_leaderboard_entry`. + fn jump_hop_leaderboard_entry(&self) -> JumpHopLeaderboardEntryTableHandle<'_>; +} + +impl JumpHopLeaderboardEntryTableAccess for super::RemoteTables { + fn jump_hop_leaderboard_entry(&self) -> JumpHopLeaderboardEntryTableHandle<'_> { + JumpHopLeaderboardEntryTableHandle { + imp: self + .imp + .get_table::("jump_hop_leaderboard_entry"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct JumpHopLeaderboardEntryInsertCallbackId(__sdk::CallbackId); +pub struct JumpHopLeaderboardEntryDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for JumpHopLeaderboardEntryTableHandle<'ctx> { + type Row = JumpHopLeaderboardEntryRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = JumpHopLeaderboardEntryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopLeaderboardEntryInsertCallbackId { + JumpHopLeaderboardEntryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: JumpHopLeaderboardEntryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = JumpHopLeaderboardEntryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopLeaderboardEntryDeleteCallbackId { + JumpHopLeaderboardEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: JumpHopLeaderboardEntryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct JumpHopLeaderboardEntryUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for JumpHopLeaderboardEntryTableHandle<'ctx> { + type UpdateCallbackId = JumpHopLeaderboardEntryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> JumpHopLeaderboardEntryUpdateCallbackId { + JumpHopLeaderboardEntryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: JumpHopLeaderboardEntryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `entry_id` unique index on the table `jump_hop_leaderboard_entry`, +/// which allows point queries on the field of the same name +/// via the [`JumpHopLeaderboardEntryEntryIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_leaderboard_entry().entry_id().find(...)`. +pub struct JumpHopLeaderboardEntryEntryIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> JumpHopLeaderboardEntryTableHandle<'ctx> { + /// Get a handle on the `entry_id` unique index on the table `jump_hop_leaderboard_entry`. + pub fn entry_id(&self) -> JumpHopLeaderboardEntryEntryIdUnique<'ctx> { + JumpHopLeaderboardEntryEntryIdUnique { + imp: self.imp.get_unique_constraint::("entry_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> JumpHopLeaderboardEntryEntryIdUnique<'ctx> { + /// Find the subscribed row whose `entry_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("jump_hop_leaderboard_entry"); + _table.add_unique_constraint::("entry_id", |row| &row.entry_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `JumpHopLeaderboardEntryRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait jump_hop_leaderboard_entryQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `JumpHopLeaderboardEntryRow`. + fn jump_hop_leaderboard_entry( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl jump_hop_leaderboard_entryQueryTableAccess for __sdk::QueryTableAccessor { + fn jump_hop_leaderboard_entry( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("jump_hop_leaderboard_entry") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_get_input_type.rs new file mode 100644 index 00000000..0c66a38b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_get_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLeaderboardGetInput { + pub profile_id: String, + pub viewer_player_id: String, + pub limit: u32, +} + +impl __sdk::InModule for JumpHopLeaderboardGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_procedure_result_type.rs new file mode 100644 index 00000000..b1ff0a33 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_procedure_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::jump_hop_leaderboard_entry_snapshot_type::JumpHopLeaderboardEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLeaderboardProcedureResult { + pub ok: bool, + pub profile_id: String, + pub items: Vec, + pub viewer_best: Option, + pub error_message: Option, +} + +impl __sdk::InModule for JumpHopLeaderboardProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs index e73b5530..090fbea8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs @@ -9,7 +9,9 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct JumpHopRunJumpInput { pub run_id: String, pub owner_user_id: String, - pub charge_ms: u32, + pub drag_distance: f32, + pub drag_vector_x: Option, + pub drag_vector_y: Option, pub client_event_id: String, pub jumped_at_ms: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs index 40578dae..d5c00ddf 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs @@ -10,6 +10,7 @@ pub struct JumpHopRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub runtime_mode: String, pub client_event_id: String, pub started_at_ms: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs index 6874988f..9ca1fe02 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs @@ -8,10 +8,13 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct JumpHopTileAssetSnapshot { pub tile_type: String, + pub tile_id: Option, pub image_src: String, pub image_object_key: String, pub asset_object_id: String, pub source_atlas_cell: String, + pub atlas_row: Option, + pub atlas_col: Option, pub visual_width: u32, pub visual_height: u32, pub top_surface_radius: f32, diff --git a/server-rs/crates/spacetime-client/src/wooden_fish.rs b/server-rs/crates/spacetime-client/src/wooden_fish.rs index 66304b09..2a3e36eb 100644 --- a/server-rs/crates/spacetime-client/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/wooden_fish.rs @@ -801,6 +801,7 @@ mod tests { const SESSION_ID: &str = "wooden-fish-session-test"; const OWNER_USER_ID: &str = "user-test"; + const AUTHOR_DISPLAY_NAME: &str = "木鱼作者"; const PROFILE_ID: &str = "wooden-fish-profile-test"; const NOW_MICROS: i64 = 1_763_456_789_000_000; @@ -814,7 +815,13 @@ mod tests { payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); let (plan, draft) = - build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) .expect("compile-draft should build plan"); let WoodenFishActionProcedure::Compile(input) = plan else { @@ -863,7 +870,13 @@ mod tests { payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back")); let error = - match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { + match build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) { Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"), Err(error) => error, }; @@ -884,7 +897,13 @@ mod tests { payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back")); let error = - match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { + match build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) { Ok(_) => panic!("compile-draft should not publish without background asset"), Err(error) => error, }; @@ -905,7 +924,13 @@ mod tests { payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound")); let error = - match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) { + match build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) { Ok(_) => panic!("compile-draft should not publish without back button asset"), Err(error) => error, }; @@ -927,7 +952,13 @@ mod tests { payload.back_button_asset = Some(generated_back_button_asset("generated-back")); let (plan, _draft) = - build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) .expect("regenerate-hit-object should build plan"); let WoodenFishActionProcedure::Compile(input) = plan else { @@ -988,7 +1019,13 @@ mod tests { ]); let (plan, draft) = - build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + build_wooden_fish_action_plan( + &session, + OWNER_USER_ID, + AUTHOR_DISPLAY_NAME, + &payload, + NOW_MICROS, + ) .expect("update-floating-words should build plan"); let WoodenFishActionProcedure::Update(input) = plan else { diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index 6f73da86..e78e5dd7 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -245,6 +245,29 @@ pub fn restart_jump_hop_run( } } +#[spacetimedb::procedure] +pub fn get_jump_hop_leaderboard( + ctx: &mut ProcedureContext, + input: JumpHopLeaderboardGetInput, +) -> JumpHopLeaderboardProcedureResult { + match ctx.try_with_tx(|tx| get_jump_hop_leaderboard_tx(tx, input.clone())) { + Ok((profile_id, items, viewer_best)) => JumpHopLeaderboardProcedureResult { + ok: true, + profile_id, + items, + viewer_best, + error_message: None, + }, + Err(message) => JumpHopLeaderboardProcedureResult { + ok: false, + profile_id: input.profile_id, + items: Vec::new(), + viewer_best: None, + error_message: Some(message), + }, + } +} + fn create_jump_hop_agent_session_tx( ctx: &ReducerContext, input: JumpHopAgentSessionCreateInput, @@ -543,6 +566,12 @@ fn start_jump_hop_run_tx( ) -> Result { require_non_empty(&input.run_id, "jump_hop run_id")?; let work = find_work(ctx, &input.profile_id)?; + let runtime_mode = normalize_runtime_mode(&input.runtime_mode); + if runtime_mode == JUMP_HOP_RUNTIME_MODE_PUBLISHED + && work.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED + { + return Err("jump_hop published runtime 只能启动已发布作品".to_string()); + } let path = parse_json::(&work.path_json)?; let domain_run = start_run( input.run_id.clone(), @@ -554,7 +583,9 @@ fn start_jump_hop_run_tx( .map_err(|error| error.to_string())?; let snapshot = domain_run; upsert_run(ctx, &snapshot, input.started_at_ms); - increment_work_play_count(ctx, &work, input.started_at_ms); + if runtime_mode == JUMP_HOP_RUNTIME_MODE_PUBLISHED { + increment_work_play_count(ctx, &work, input.started_at_ms); + } insert_event( ctx, input.client_event_id, @@ -582,10 +613,19 @@ fn jump_hop_jump_tx( ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = parse_json::(&row.snapshot_json)?; - let domain_next = apply_jump(&snapshot, input.charge_ms, input.jumped_at_ms as u64) - .map_err(|error| error.to_string())?; + let domain_next = apply_jump( + &snapshot, + input.drag_distance, + input.drag_vector_x, + input.drag_vector_y, + input.jumped_at_ms as u64, + ) + .map_err(|error| error.to_string())?; let next = domain_next; replace_run(ctx, &row, &next, input.jumped_at_ms); + if next.status == module_jump_hop::JumpHopRunStatus::Failed { + upsert_jump_hop_leaderboard_entry(ctx, &next, input.jumped_at_ms); + } insert_event( ctx, input.client_event_id, @@ -602,6 +642,47 @@ fn jump_hop_jump_tx( Ok(next) } +fn get_jump_hop_leaderboard_tx( + ctx: &ReducerContext, + input: JumpHopLeaderboardGetInput, +) -> Result< + ( + String, + Vec, + Option, + ), + String, +> { + require_non_empty(&input.profile_id, "jump_hop profile_id")?; + let _ = find_work(ctx, &input.profile_id)?; + let limit = input.limit.clamp(1, 50) as usize; + let mut rows = ctx + .db + .jump_hop_leaderboard_entry() + .by_jump_hop_leaderboard_profile_id() + .filter(input.profile_id.as_str()) + .collect::>(); + sort_jump_hop_leaderboard_rows(&mut rows); + let ranked_rows = rows + .iter() + .enumerate() + .map(|(index, row)| (index as u32 + 1, row)) + .collect::>(); + let viewer_best = clean_optional(&input.viewer_player_id).and_then(|viewer_player_id| { + ranked_rows + .iter() + .find(|(_, row)| row.player_id == viewer_player_id) + .map(|(rank, row)| leaderboard_entry_snapshot(*rank, row)) + }); + let items = ranked_rows + .into_iter() + .take(limit) + .map(|(rank, row)| leaderboard_entry_snapshot(rank, row)) + .collect::>(); + + Ok((input.profile_id, items, viewer_best)) +} + fn restart_jump_hop_run_tx( ctx: &ReducerContext, input: JumpHopRunRestartInput, @@ -971,9 +1052,121 @@ fn insert_event( }); } +fn normalize_runtime_mode(value: &str) -> &'static str { + if value + .trim() + .eq_ignore_ascii_case(JUMP_HOP_RUNTIME_MODE_DRAFT) + { + JUMP_HOP_RUNTIME_MODE_DRAFT + } else { + JUMP_HOP_RUNTIME_MODE_PUBLISHED + } +} + +fn build_jump_hop_leaderboard_entry_id(player_id: &str, profile_id: &str) -> String { + format!("jump-hop-leaderboard-{player_id}-{profile_id}") +} + +fn upsert_jump_hop_leaderboard_entry( + ctx: &ReducerContext, + snapshot: &JumpHopRunSnapshot, + updated_at_ms: i64, +) { + let Some(finished_at_ms) = snapshot.finished_at_ms else { + return; + }; + let successful_jump_count = snapshot.current_platform_index; + let duration_ms = finished_at_ms.saturating_sub(snapshot.started_at_ms); + let entry_id = + build_jump_hop_leaderboard_entry_id(&snapshot.owner_user_id, &snapshot.profile_id); + let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)); + if let Some(existing) = ctx + .db + .jump_hop_leaderboard_entry() + .entry_id() + .find(&entry_id) + { + let should_replace = + is_jump_hop_leaderboard_candidate_better(successful_jump_count, duration_ms, &existing); + ctx.db + .jump_hop_leaderboard_entry() + .entry_id() + .delete(&entry_id); + ctx.db + .jump_hop_leaderboard_entry() + .insert(JumpHopLeaderboardEntryRow { + entry_id, + profile_id: existing.profile_id, + player_id: existing.player_id, + successful_jump_count: if should_replace { + successful_jump_count + } else { + existing.successful_jump_count + }, + duration_ms: if should_replace { + duration_ms + } else { + existing.duration_ms + }, + run_id: if should_replace { + snapshot.run_id.clone() + } else { + existing.run_id + }, + updated_at, + }); + return; + } + + ctx.db + .jump_hop_leaderboard_entry() + .insert(JumpHopLeaderboardEntryRow { + entry_id, + profile_id: snapshot.profile_id.clone(), + player_id: snapshot.owner_user_id.clone(), + successful_jump_count, + duration_ms, + run_id: snapshot.run_id.clone(), + updated_at, + }); +} + +fn is_jump_hop_leaderboard_candidate_better( + successful_jump_count: u32, + duration_ms: u64, + existing: &JumpHopLeaderboardEntryRow, +) -> bool { + successful_jump_count > existing.successful_jump_count + || (successful_jump_count == existing.successful_jump_count + && duration_ms < existing.duration_ms) +} + +fn sort_jump_hop_leaderboard_rows(rows: &mut [JumpHopLeaderboardEntryRow]) { + rows.sort_by(|left, right| { + right + .successful_jump_count + .cmp(&left.successful_jump_count) + .then_with(|| left.duration_ms.cmp(&right.duration_ms)) + .then_with(|| left.updated_at.cmp(&right.updated_at)) + .then_with(|| left.player_id.cmp(&right.player_id)) + }); +} + +fn leaderboard_entry_snapshot( + rank: u32, + row: &JumpHopLeaderboardEntryRow, +) -> JumpHopLeaderboardEntrySnapshot { + JumpHopLeaderboardEntrySnapshot { + rank, + player_id: row.player_id.clone(), + successful_jump_count: row.successful_jump_count, + duration_ms: row.duration_ms, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn is_publish_ready(row: &JumpHopWorkProfileRow) -> bool { !row.work_title.trim().is_empty() - && !row.character_asset_json.trim().is_empty() && !row.tile_atlas_asset_json.trim().is_empty() && !row.tile_assets_json.trim().is_empty() && !row.path_json.trim().is_empty() @@ -985,8 +1178,8 @@ fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot { theme_text: seed.clone(), difficulty: JumpHopDifficulty::Standard.as_str().to_string(), style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(), - character_prompt: format!("{seed}的俯视角主角,透明背景,全身可见"), - tile_prompt: format!("{seed}的等距地块图集,包含起点、普通、目标和终点地块"), + character_prompt: "内置默认 3D 角色".to_string(), + tile_prompt: format!("{seed}主题的俯视角清爽游戏化立体感平台素材"), end_mood_prompt: String::new(), } } @@ -1185,3 +1378,64 @@ fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow { updated_at: row.updated_at, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn leaderboard_row( + player_id: &str, + successful_jump_count: u32, + duration_ms: u64, + updated_at_micros: i64, + ) -> JumpHopLeaderboardEntryRow { + JumpHopLeaderboardEntryRow { + entry_id: format!("entry-{player_id}"), + profile_id: "jump-hop-profile-test".to_string(), + player_id: player_id.to_string(), + successful_jump_count, + duration_ms, + run_id: format!("run-{player_id}"), + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + } + } + + #[test] + fn jump_hop_leaderboard_sorts_by_jump_count_duration_and_update_time() { + let mut rows = vec![ + leaderboard_row("player-slow", 8, 8_000, 30), + leaderboard_row("player-late", 9, 6_000, 20), + leaderboard_row("player-fast", 9, 5_000, 40), + leaderboard_row("player-early", 9, 5_000, 10), + ]; + + sort_jump_hop_leaderboard_rows(&mut rows); + + let player_ids = rows + .into_iter() + .map(|row| row.player_id) + .collect::>(); + assert_eq!( + player_ids, + vec!["player-early", "player-fast", "player-late", "player-slow"] + ); + } + + #[test] + fn jump_hop_leaderboard_replaces_only_better_player_score() { + let existing = leaderboard_row("player", 6, 4_000, 10); + + assert!(is_jump_hop_leaderboard_candidate_better( + 7, 8_000, &existing + )); + assert!(is_jump_hop_leaderboard_candidate_better( + 6, 3_500, &existing + )); + assert!(!is_jump_hop_leaderboard_candidate_better( + 6, 4_500, &existing + )); + assert!(!is_jump_hop_leaderboard_candidate_better( + 5, 1_000, &existing + )); + } +} diff --git a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs index 31715f0e..1524f75c 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs @@ -94,3 +94,19 @@ pub struct JumpHopEventRow { pub(crate) result: String, pub(crate) occurred_at: Timestamp, } + +#[spacetimedb::table( + accessor = jump_hop_leaderboard_entry, + index(accessor = by_jump_hop_leaderboard_profile_id, btree(columns = [profile_id])), + index(accessor = by_jump_hop_leaderboard_player_profile, btree(columns = [player_id, profile_id])) +)] +pub struct JumpHopLeaderboardEntryRow { + #[primary_key] + pub(crate) entry_id: String, + pub(crate) profile_id: String, + pub(crate) player_id: String, + pub(crate) successful_jump_count: u32, + pub(crate) duration_ms: u64, + pub(crate) run_id: String, + pub(crate) updated_at: Timestamp, +} diff --git a/server-rs/crates/spacetime-module/src/jump_hop/types.rs b/server-rs/crates/spacetime-module/src/jump_hop/types.rs index fe514a3d..05f6092f 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/types.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/types.rs @@ -14,6 +14,8 @@ pub const JUMP_HOP_GENERATION_READY: &str = "ready"; pub const JUMP_HOP_EVENT_RUN_STARTED: &str = "run-started"; pub const JUMP_HOP_EVENT_RUN_RESTARTED: &str = "run-restarted"; pub const JUMP_HOP_EVENT_JUMP: &str = "jump"; +pub const JUMP_HOP_RUNTIME_MODE_DRAFT: &str = "draft"; +pub const JUMP_HOP_RUNTIME_MODE_PUBLISHED: &str = "published"; #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct JumpHopAgentSessionCreateInput { @@ -96,6 +98,7 @@ pub struct JumpHopRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub runtime_mode: String, pub client_event_id: String, pub started_at_ms: i64, } @@ -106,11 +109,13 @@ pub struct JumpHopRunGetInput { pub owner_user_id: String, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct JumpHopRunJumpInput { pub run_id: String, pub owner_user_id: String, - pub charge_ms: u32, + pub drag_distance: f32, + pub drag_vector_x: Option, + pub drag_vector_y: Option, pub client_event_id: String, pub jumped_at_ms: i64, } @@ -152,6 +157,31 @@ pub struct JumpHopRunProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopLeaderboardEntrySnapshot { + pub rank: u32, + pub player_id: String, + pub successful_jump_count: u32, + pub duration_ms: u64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopLeaderboardGetInput { + pub profile_id: String, + pub viewer_player_id: String, + pub limit: u32, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopLeaderboardProcedureResult { + pub ok: bool, + pub profile_id: String, + pub items: Vec, + pub viewer_best: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct JumpHopCreatorConfigSnapshot { @@ -181,10 +211,16 @@ pub struct JumpHopCharacterAssetSnapshot { #[serde(rename_all = "camelCase")] pub struct JumpHopTileAssetSnapshot { pub tile_type: String, + #[serde(default)] + pub tile_id: Option, pub image_src: String, pub image_object_key: String, pub asset_object_id: String, pub source_atlas_cell: String, + #[serde(default)] + pub atlas_row: Option, + #[serde(default)] + pub atlas_col: Option, pub visual_width: u32, pub visual_height: u32, pub top_surface_radius: f32, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index fade23b3..c2b4ff24 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -13,7 +13,8 @@ use crate::bark_battle::tables::{ }; use crate::big_fish::big_fish_runtime_run; use crate::jump_hop::tables::{ - jump_hop_agent_session, jump_hop_event, jump_hop_runtime_run, jump_hop_work_profile, + jump_hop_agent_session, jump_hop_event, jump_hop_leaderboard_entry, jump_hop_runtime_run, + jump_hop_work_profile, }; use crate::match3d::tables::{ match_3_d_work_profile, match3d_agent_message, match3d_agent_session, match3d_runtime_run, @@ -244,6 +245,7 @@ macro_rules! migration_tables { jump_hop_work_profile, jump_hop_runtime_run, jump_hop_event, + jump_hop_leaderboard_entry, wooden_fish_agent_session, wooden_fish_work_profile, wooden_fish_runtime_run, diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index 3ee5e0cf..9da9b282 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -237,6 +237,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { migrate_bark_battle_entry_to_open_default(ctx, now); migrate_baby_object_match_entry_from_old_coming_soon_default(ctx, now); migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now); + migrate_jump_hop_entry_from_old_puzzle_default(ctx, now); } fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) { @@ -388,6 +389,35 @@ fn migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx: &ReducerContext, }); } +fn migrate_jump_hop_entry_from_old_puzzle_default(ctx: &ReducerContext, now: Timestamp) { + let id = "jump-hop".to_string(); + let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { + return; + }; + + // 中文注释:只纠偏跳一跳重设计前的系统默认入口,避免覆盖后台手动配置。 + let still_old_puzzle_default = row.title == "跳一跳" + && row.subtitle == "俯视角跳跃闯关" + && row.badge == "可创建" + && row.image_src == "/creation-type-references/puzzle.webp" + && row.visible + && row.open + && row.sort_order == 45; + if !still_old_puzzle_default { + return; + } + + ctx.db + .creation_entry_type_config() + .id() + .update(CreationEntryTypeConfig { + subtitle: "主题驱动平台跳跃".to_string(), + image_src: "/creation-type-references/jump-hop.webp".to_string(), + updated_at: now, + ..row + }); +} + fn default_creation_entry_type_configs(now: Timestamp) -> Vec { module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch()) .into_iter() diff --git a/src/components/jump-hop-creation/JumpHopWorkspace.test.tsx b/src/components/jump-hop-creation/JumpHopWorkspace.test.tsx new file mode 100644 index 00000000..79ce86a8 --- /dev/null +++ b/src/components/jump-hop-creation/JumpHopWorkspace.test.tsx @@ -0,0 +1,60 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; +import { JumpHopWorkspace } from './JumpHopWorkspace'; + +vi.mock('../../services/jump-hop/jumpHopClient', () => ({ + jumpHopClient: { + createSession: vi.fn(), + }, +})); + +beforeEach(() => { + vi.mocked(jumpHopClient.createSession).mockReset(); + vi.mocked(jumpHopClient.createSession).mockResolvedValue({ + session: { + sessionId: 'jump-hop-session-test', + ownerUserId: 'user-test', + status: 'draft', + draft: null, + createdAt: '2026-05-27T00:00:00Z', + updatedAt: '2026-05-27T00:00:00Z', + }, + }); +}); + +test('跳一跳工作台只保留主题输入并自动派生提交 payload', async () => { + const onSubmitted = vi.fn(); + + render( + {}} onSubmitted={onSubmitted} />, + ); + + expect(screen.getByLabelText('主题')).toBeTruthy(); + expect(screen.queryByLabelText('作品标题')).toBeNull(); + expect(screen.queryByLabelText('作品简介')).toBeNull(); + expect(screen.queryByLabelText('角色提示词')).toBeNull(); + expect(screen.queryByLabelText('地块提示词')).toBeNull(); + + fireEvent.change(screen.getByLabelText('主题'), { + target: { value: '竹林茶馆' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1)); + expect(jumpHopClient.createSession).toHaveBeenCalledWith({ + templateId: 'jump-hop', + themeText: '竹林茶馆', + workTitle: '竹林茶馆跳一跳', + workDescription: '竹林茶馆主题的俯视角平台跳跃作品', + themeTags: ['竹林茶馆', '跳一跳', '休闲'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + characterPrompt: '内置默认 3D 角色', + tilePrompt: '竹林茶馆主题的俯视角清爽游戏化立体感平台素材', + endMoodPrompt: null, + }); +}); diff --git a/src/components/jump-hop-creation/JumpHopWorkspace.tsx b/src/components/jump-hop-creation/JumpHopWorkspace.tsx index d5b31e63..e61301c6 100644 --- a/src/components/jump-hop-creation/JumpHopWorkspace.tsx +++ b/src/components/jump-hop-creation/JumpHopWorkspace.tsx @@ -2,9 +2,7 @@ import { ArrowLeft, Loader2, Send } from 'lucide-react'; import { useMemo, useState } from 'react'; import type { - JumpHopDifficulty, JumpHopSessionResponse, - JumpHopStylePreset, JumpHopWorkspaceCreateRequest, } from '../../../packages/shared/src/contracts/jumpHop'; import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; @@ -20,27 +18,31 @@ type JumpHopWorkspaceProps = { }; type JumpHopWorkspaceFormState = { - workTitle: string; - workDescription: string; - themeTags: string; - difficulty: JumpHopDifficulty; - stylePreset: JumpHopStylePreset; - characterPrompt: string; - tilePrompt: string; - endMoodPrompt: string; + themeText: string; }; const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = { - workTitle: '', - workDescription: '', - themeTags: '', - difficulty: 'easy', - stylePreset: 'minimal-blocks', - characterPrompt: '', - tilePrompt: '', - endMoodPrompt: '', + themeText: '', }; +function buildJumpHopWorkspacePayload( + formState: JumpHopWorkspaceFormState, +): JumpHopWorkspaceCreateRequest { + const themeText = formState.themeText.trim(); + return { + templateId: 'jump-hop', + themeText, + workTitle: `${themeText}跳一跳`, + workDescription: `${themeText}主题的俯视角平台跳跃作品`, + themeTags: [themeText, '跳一跳', '休闲'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + characterPrompt: '内置默认 3D 角色', + tilePrompt: `${themeText}主题的俯视角清爽游戏化立体感平台素材`, + endMoodPrompt: null, + }; +} + export function JumpHopWorkspace({ isBusy = false, error = null, @@ -52,14 +54,7 @@ export function JumpHopWorkspace({ const [isSubmitting, setIsSubmitting] = useState(false); const canSubmit = useMemo( - () => - Boolean( - formState.workTitle.trim() && - formState.workDescription.trim() && - formState.themeTags.trim() && - formState.characterPrompt.trim() && - formState.tilePrompt.trim(), - ), + () => Boolean(formState.themeText.trim()), [formState], ); @@ -73,20 +68,7 @@ export function JumpHopWorkspace({ setLocalError(null); try { - const payload: JumpHopWorkspaceCreateRequest = { - templateId: 'jump-hop', - workTitle: formState.workTitle.trim(), - workDescription: formState.workDescription.trim(), - themeTags: formState.themeTags - .split(/[,,、\s]+/) - .map((item) => item.trim()) - .filter(Boolean), - difficulty: formState.difficulty, - stylePreset: formState.stylePreset, - characterPrompt: formState.characterPrompt.trim(), - tilePrompt: formState.tilePrompt.trim(), - endMoodPrompt: formState.endMoodPrompt.trim() || null, - }; + const payload = buildJumpHopWorkspacePayload(formState); const response = await jumpHopClient.createSession(payload); onSubmitted(response, payload); } catch (caughtError) { @@ -111,143 +93,22 @@ export function JumpHopWorkspace({ -
+
-