diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index bb78521f..60187169 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1139,13 +1139,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 4aff3fc0..9b4f695e 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1643,14 +1643,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,...`,终端日志会被数百万字符淹没。 @@ -1737,6 +1768,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 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。 @@ -1752,3 +1799,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 33dea4b7..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/unified-creation/workspaces/JumpHopCreationWorkspace.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 053fe85c..858606b5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -406,6 +406,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 53ce1a7c..a1fe2e14 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -134,23 +134,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/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 70b4d70d..5089422a 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -270,6 +270,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/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 9dd69508..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(()); } @@ -427,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, &[], @@ -526,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(|| { @@ -537,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> { @@ -582,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; @@ -597,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) @@ -616,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)] @@ -647,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(), @@ -657,7 +750,7 @@ async fn persist_jump_hop_tile_asset( state, owner_user_id, profile_id, - slot, + slot.as_str(), &format!( "跳一跳地块切片 {}:{}", tile_index + 1, @@ -673,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, @@ -684,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, @@ -867,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() @@ -896,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, @@ -916,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, @@ -1019,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); @@ -1064,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/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/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 902089a5..c6729d1c 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -399,9 +399,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 18a02ee9..64811424 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -417,6 +417,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 16f2eea8..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,22 +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") ); } - /// 构造不携带资产覆盖的 JumpHop action,单测按需再覆盖字段。 fn action(action_type: JumpHopActionType) -> JumpHopActionRequest { JumpHopActionRequest { action_type, profile_id: None, + theme_text: None, work_title: None, work_description: None, theme_tags: None, @@ -1185,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() } } @@ -1195,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, @@ -1233,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/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 5b0ef784..a821a317 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -296,26 +296,30 @@ pub(crate) fn build_creation_entry_config_record_from_rows( event_banners_json: header.event_banners_json, creation_types: 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: creation_entry_text_or_default( - item.category_id, - module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID, - ), - category_label: creation_entry_text_or_default( - item.category_label, - module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL, - ), - category_sort_order: item.category_sort_order, - updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), - unified_creation_spec_json: item.unified_creation_spec_json, + .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: creation_entry_text_or_default( + item.category_id, + module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID, + ), + category_label: creation_entry_text_or_default( + item.category_label, + module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL, + ), + category_sort_order: item.category_sort_order, + updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), + unified_creation_spec_json: item.unified_creation_spec_json, + }, + ) }) .collect(), updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), @@ -353,20 +357,22 @@ 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, - unified_creation_spec_json: item.unified_creation_spec_json, + .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, + unified_creation_spec_json: item.unified_creation_spec_json, + }) }) .collect(), updated_at_micros: snapshot.updated_at_micros, @@ -380,6 +386,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 0018fa5f..a62b5d82 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -366,6 +366,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; @@ -434,6 +435,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; @@ -1407,6 +1413,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; @@ -1475,6 +1482,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; @@ -2404,6 +2416,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, @@ -2618,6 +2631,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)?, ), @@ -3047,6 +3063,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", @@ -3532,6 +3554,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)?), @@ -3875,6 +3900,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)?), @@ -4134,6 +4162,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>, @@ -4426,6 +4455,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, @@ -5448,6 +5482,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); @@ -5560,6 +5595,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-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 d0a0dbd0..dc95fad8 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 a3daa9b0..232fc1e3 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 @@ -296,6 +296,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) { @@ -447,6 +448,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/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 28e35014..ee12fd88 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -525,6 +525,7 @@ test('creation start card maps backend jump-hop draft to template card', () => { profileId: 'jump-hop-profile-1', ownerUserId: 'user-1', sourceSessionId: 'jump-hop-session-1', + themeText: '跳一跳生成草稿', workTitle: '跳一跳生成草稿', workDescription: '后端仍在生成跳一跳玩法。', themeTags: ['跳一跳'], diff --git a/src/components/jump-hop-result/JumpHopResultView.test.tsx b/src/components/jump-hop-result/JumpHopResultView.test.tsx index 70b2fa12..3faeb959 100644 --- a/src/components/jump-hop-result/JumpHopResultView.test.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.test.tsx @@ -1,144 +1,180 @@ /* @vitest-environment jsdom */ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { expect, test, vi } from 'vitest'; -import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { JumpHopWorkProfileResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; import { JumpHopResultView } from './JumpHopResultView'; -const draft: JumpHopDraftResponse = { - templateId: 'jump-hop', - templateName: '跳一跳', - profileId: 'profile-1', - workTitle: '云端跳台', - workDescription: '一路跳到星星。', - themeTags: ['云朵', '星空'], - difficulty: 'standard', - stylePreset: 'paper-toy', - characterPrompt: '纸片小兔', - tilePrompt: '柔软云朵平台', - endMoodPrompt: '星光门', - characterAsset: { - assetId: 'character-1', - imageSrc: 'data:image/png;base64,character', - imageObjectKey: 'jump-hop/character.png', - assetObjectId: 'asset-character', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '角色图', - width: 1024, - height: 1024, - }, - tileAtlasAsset: { - assetId: 'tiles-1', - imageSrc: 'data:image/png;base64,tiles', - imageObjectKey: 'jump-hop/tiles.png', - assetObjectId: 'asset-tiles', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '地块图', - width: 1024, - height: 1024, - }, - tileAssets: [ - { - tileType: 'start', - imageSrc: 'data:image/png;base64,tile-start', - imageObjectKey: 'jump-hop/tile-start.png', - assetObjectId: 'asset-tile-start', - sourceAtlasCell: 'A1', - visualWidth: 128, - visualHeight: 96, - topSurfaceRadius: 24, - landingRadius: 28, - }, - { - tileType: 'finish', - imageSrc: 'data:image/png;base64,tile-finish', - imageObjectKey: 'jump-hop/tile-finish.png', - assetObjectId: 'asset-tile-finish', - sourceAtlasCell: 'A2', - visualWidth: 128, - visualHeight: 96, - topSurfaceRadius: 24, - landingRadius: 28, - }, - ], - path: { - seed: 'jump-hop-seed', - difficulty: 'standard', - platforms: [ - { - platformId: 'platform-1', - tileType: 'start', - x: 0, - y: 0, - width: 48, - height: 36, - landingRadius: 22, - perfectRadius: 12, - scoreValue: 1, - }, - { - platformId: 'platform-2', - tileType: 'finish', - x: 16, - y: 18, - width: 60, - height: 42, - landingRadius: 22, - perfectRadius: 12, - scoreValue: 2, - }, - ], - finishIndex: 1, - cameraPreset: 'default', - scoring: { - chargeToDistanceRatio: 1.2, - maxChargeMs: 1800, - hitBonus: 20, - perfectBonus: 50, - }, - }, - coverComposite: 'data:image/png;base64,cover', - generationStatus: 'ready', -}; +vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({ + useJumpHopLeaderboard: vi.fn(), +})); -test('jump hop result view exposes test run and publish actions', async () => { - const user = userEvent.setup(); - const onBack = vi.fn(); - const onEdit = vi.fn(); - const onStartTestRun = vi.fn(); - const onPublish = vi.fn(); - const onRegenerateCharacter = vi.fn(); - const onRegenerateTiles = vi.fn(); +test('跳一跳结果页展示排行榜列表', () => { + vi.mocked(useJumpHopLeaderboard).mockReturnValue({ + leaderboard: { + profileId: 'jump-hop-profile-test', + items: [ + { + rank: 1, + playerId: 'player-1', + successfulJumpCount: 12, + durationMs: 40123, + updatedAt: '2026-05-27T00:00:00Z', + }, + { + rank: 2, + playerId: 'player-2', + successfulJumpCount: 10, + durationMs: 38210, + updatedAt: '2026-05-26T00:00:00Z', + }, + ], + viewerBest: null, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + }); render( {}} + onEdit={() => {}} + onStartTestRun={() => {}} + onPublish={() => {}} + onRegenerateTiles={() => {}} />, ); - expect(screen.getByText('云端跳台')).toBeTruthy(); - expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '发布' })).toBeTruthy(); - - await user.click(screen.getByRole('button', { name: '试玩' })); - await user.click(screen.getByRole('button', { name: '发布' })); - await user.click(screen.getByRole('button', { name: '返回' })); - await user.click(screen.getByRole('button', { name: '返回编辑' })); - await user.click(screen.getByRole('button', { name: '角色' })); - await user.click(screen.getByRole('button', { name: '地块' })); - - expect(onStartTestRun).toHaveBeenCalledTimes(1); - expect(onPublish).toHaveBeenCalledTimes(1); - expect(onBack).toHaveBeenCalledTimes(1); - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onRegenerateCharacter).toHaveBeenCalledTimes(1); - expect(onRegenerateTiles).toHaveBeenCalledTimes(1); + expect(screen.getByText('排行榜')).toBeTruthy(); + expect(screen.getByText('player-1')).toBeTruthy(); + expect(screen.getByText('12 跳')).toBeTruthy(); + expect(screen.getByText('00:40')).toBeTruthy(); + expect(screen.getByText('player-2')).toBeTruthy(); }); + +test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => { + vi.mocked(useJumpHopLeaderboard).mockReturnValue({ + leaderboard: null, + isLoading: false, + error: null, + refresh: vi.fn(), + }); + + render( + {}} + onEdit={() => {}} + onStartTestRun={() => {}} + onPublish={() => {}} + onRegenerateTiles={() => {}} + />, + ); + + expect(screen.getByTestId('jump-hop-result-character-logo').getAttribute('src')).toBe( + '/branding/jump-hop-taonier-character.png', + ); +}); + +function buildProfile(): JumpHopWorkProfileResponse { + return { + summary: { + runtimeKind: 'jump-hop', + workId: 'jump-hop-profile-test', + profileId: 'jump-hop-profile-test', + ownerUserId: 'user-test', + sourceSessionId: 'jump-hop-session-test', + themeText: '测试', + workTitle: '测试', + workDescription: '测试', + themeTags: ['测试'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-27T00:00:00Z', + publishedAt: null, + publishReady: true, + generationStatus: 'ready', + }, + draft: { + templateId: 'jump-hop', + templateName: '跳一跳', + profileId: 'jump-hop-profile-test', + themeText: '测试', + workTitle: '测试', + workDescription: '测试', + themeTags: ['测试'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + defaultCharacter: { + characterId: 'jump-hop-default-runner', + displayName: '默认角色', + modelKind: 'builtin-three', + bodyColor: '#f59e0b', + accentColor: '#2563eb', + }, + characterPrompt: '默认角色', + tilePrompt: '地块', + endMoodPrompt: null, + characterAsset: { + assetId: 'builtin', + imageSrc: 'builtin://jump-hop/default-character', + imageObjectKey: '', + assetObjectId: 'builtin', + generationProvider: 'builtin-three', + prompt: '默认角色', + width: 0, + height: 0, + }, + tileAtlasAsset: { + assetId: 'builtin', + imageSrc: 'builtin://jump-hop/default-character', + imageObjectKey: '', + assetObjectId: 'builtin', + generationProvider: 'builtin-three', + prompt: '默认角色', + width: 0, + height: 0, + }, + tileAssets: [], + path: null, + coverComposite: null, + generationStatus: 'ready', + }, + path: null as never, + defaultCharacter: { + characterId: 'jump-hop-default-runner', + displayName: '默认角色', + modelKind: 'builtin-three', + bodyColor: '#f59e0b', + accentColor: '#2563eb', + }, + characterAsset: { + assetId: 'builtin', + imageSrc: 'builtin://jump-hop/default-character', + imageObjectKey: '', + assetObjectId: 'builtin', + generationProvider: 'builtin-three', + prompt: '默认角色', + width: 0, + height: 0, + }, + tileAtlasAsset: { + assetId: 'builtin', + imageSrc: 'builtin://jump-hop/default-character', + imageObjectKey: '', + assetObjectId: 'builtin', + generationProvider: 'builtin-three', + prompt: '默认角色', + width: 0, + height: 0, + }, + tileAssets: [], + }; +} diff --git a/src/components/jump-hop-result/JumpHopResultView.tsx b/src/components/jump-hop-result/JumpHopResultView.tsx index d2f6cd78..cbfaaaed 100644 --- a/src/components/jump-hop-result/JumpHopResultView.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.tsx @@ -2,18 +2,22 @@ import { ArrowLeft, Loader2, Play, - RefreshCcw, Send, Shuffle, } from 'lucide-react'; -import { type CSSProperties, useMemo, useState } from 'react'; +import { type CSSProperties, useState } from 'react'; import type { JumpHopDraftResponse, JumpHopPath, - JumpHopPlatform, + JumpHopTileAsset, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import { + formatJumpHopDurationLabel, + selectJumpHopTileAsset, +} from '../../services/jump-hop/jumpHopRuntimeModel'; +import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type JumpHopResultViewProps = { @@ -34,7 +38,6 @@ type JumpHopResultViewProps = { onEdit: () => void; onStartTestRun: () => void; onPublish: () => void; - onRegenerateCharacter: () => void; onRegenerateTiles: () => void; }; @@ -44,43 +47,6 @@ function isJumpHopWorkProfile( return 'summary' in profile; } -type MiniMapPlatform = { - platform: JumpHopPlatform; - index: number; - x: number; - y: number; - width: number; - height: number; - isStart: boolean; - isFinish: boolean; -}; - -const difficultyToneByValue: Record< - JumpHopPath['difficulty'], - { accent: string; soft: string; label: string } -> = { - advanced: { - accent: '#df7f40', - soft: 'rgba(249, 115, 22, 0.16)', - label: '进阶', - }, - challenge: { - accent: '#b64a35', - soft: 'rgba(182, 98, 63, 0.16)', - label: '挑战', - }, - easy: { - accent: '#14b8a6', - soft: 'rgba(20, 184, 166, 0.16)', - label: '轻松', - }, - standard: { - accent: '#2563eb', - soft: 'rgba(37, 99, 235, 0.16)', - label: '标准', - }, -}; - const tileToneByType: Record = { accent: '#c4b5fd', bonus: '#fde68a', @@ -90,155 +56,191 @@ const tileToneByType: Record = { target: '#fecdd3', }; -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value); +const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC = + '/branding/jump-hop-taonier-character.png'; + +function JumpHopDefaultCharacterPreview() { + return ( +
+
+ +
+ ); } -function normalizePathPlatforms(path: JumpHopPath | null | undefined) { - const platforms = path?.platforms ?? []; - if (platforms.length === 0) { - return []; +function JumpHopTilePoolPreview({ + tileAssets, + tileAtlasAsset, + tileAtlasFallbackSrc, +}: { + tileAssets: JumpHopTileAsset[]; + tileAtlasAsset?: JumpHopDraftResponse['tileAtlasAsset'] | null; + tileAtlasFallbackSrc?: string | null; +}) { + const visibleTiles = tileAssets.slice(0, 25); + const atlasSrc = + tileAtlasAsset?.imageSrc?.trim() || tileAtlasFallbackSrc?.trim() || ''; + const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc; + if (visibleTiles.length > 0) { + return ( +
+ {visibleTiles.map((tile, index) => ( +
+ {tile.imageSrc ? ( + + ) : ( + + )} +
+ ))} +
+ ); } - const coordinatePlatforms = platforms.filter( - (platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y), - ); - const shouldUseCoordinates = coordinatePlatforms.length >= 2; - const xValues = shouldUseCoordinates - ? coordinatePlatforms.map((platform) => platform.x) - : []; - const yValues = shouldUseCoordinates - ? coordinatePlatforms.map((platform) => platform.y) - : []; - const minX = Math.min(...xValues); - const maxX = Math.max(...xValues); - const minY = Math.min(...yValues); - const maxY = Math.max(...yValues); - const xRange = Math.max(maxX - minX, 1); - const yRange = Math.max(maxY - minY, 1); - const denominator = Math.max(platforms.length - 1, 1); - - return platforms.map((platform, index): MiniMapPlatform => { - const sequenceRatio = index / denominator; - const hasCoordinates = - shouldUseCoordinates && - isFiniteNumber(platform.x) && - isFiniteNumber(platform.y); - const x = hasCoordinates - ? 12 + ((platform.x - minX) / xRange) * 76 - : 12 + sequenceRatio * 76; - const y = hasCoordinates - ? 14 + ((platform.y - minY) / yRange) * 72 - : 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18; - - return { - platform, - index, - x, - y, - width: Math.min(Math.max(platform.width || 54, 42), 82), - height: Math.min(Math.max(platform.height || 42, 34), 68), - isStart: index === 0 || platform.tileType === 'start', - isFinish: - index === path?.finishIndex || - platform.tileType === 'finish' || - platform.tileType === 'target', - }; - }); -} - -function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) { - const platforms = useMemo(() => normalizePathPlatforms(path), [path]); - const tone = - difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard; - const pathPoints = platforms - .map((platform) => `${platform.x},${platform.y}`) - .join(' '); - - if (platforms.length === 0) { - return null; + if (atlasSrc) { + return ( + + ); } return ( -
-
- - - - {platforms.map((item) => { - const tileTone = - tileToneByType[item.platform.tileType] ?? tileToneByType.normal; - const scoreBoost = - isFiniteNumber(item.platform.scoreValue) && - item.platform.scoreValue > 1; - const style = { - left: `${item.x}%`, - top: `${item.y}%`, - width: `${item.width}%`, - height: `${item.height}%`, - background: tileTone, - borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)', - zIndex: 10 + item.index, - } as CSSProperties; + ))} +
+ ); +} +function JumpHopFirstPlatformsPreview({ + path, + tileAssets, +}: { + path: JumpHopPath | null | undefined; + tileAssets: JumpHopTileAsset[]; +}) { + const platforms = (path?.platforms ?? []).slice(0, 3); + + return ( +
+
+ {platforms.map((platform, index) => { + const asset = selectJumpHopTileAsset( + tileAssets, + path?.seed, + index, + platform.platformId, + ); + const style = { + left: `${50 + (index - 1) * 24}%`, + top: `${68 - index * 22}%`, + width: `${34 - index * 3}%`, + zIndex: 10 + index, + } as CSSProperties; return (
- - {item.isStart || item.isFinish ? ( - - {item.isStart ? '起' : '终'} - - ) : null} +
+ {asset?.imageSrc ? ( + + ) : ( +
+ )}
); })} -
- {tone.label} + {platforms.length === 0 ? ( +
+ 路径 +
+ ) : null} +
+ ); +} + +function JumpHopResultLeaderboard({ + profileId, +}: { + profileId?: string | null; +}) { + const { leaderboard, isLoading, error } = useJumpHopLeaderboard(profileId); + const items = leaderboard?.items ?? []; + + return ( +
+
+
+ 排行榜 +
+ {isLoading ? ( + + ) : null}
-
- {platforms.length} +
+ {items.slice(0, 5).map((entry) => ( +
+ + {entry.rank} + + {entry.playerId} + {entry.successfulJumpCount} 跳 + {formatJumpHopDurationLabel(entry.durationMs)} +
+ ))} + {items.length === 0 ? ( +
+ {error ?? '暂无成绩'} +
+ ) : null}
); @@ -252,7 +254,6 @@ export function JumpHopResultView({ onEdit, onStartTestRun, onPublish, - onRegenerateCharacter, onRegenerateTiles, }: JumpHopResultViewProps) { const [isPublishing, setIsPublishing] = useState(false); @@ -264,12 +265,13 @@ export function JumpHopResultView({ path: NonNullable; }; const path = isWorkProfile ? profile.path : safeDraft.path; - const characterAsset = isWorkProfile - ? profile.characterAsset - : safeDraft.characterAsset; const tileAtlasAsset = isWorkProfile ? profile.tileAtlasAsset : safeDraft.tileAtlasAsset; + const tileAssets = isWorkProfile ? profile.tileAssets : safeDraft.tileAssets; + const profileId = isWorkProfile + ? profile.summary.profileId + : safeDraft.profileId; const titleSource = isWorkProfile ? profile.summary.workTitle : profile.workTitle; @@ -278,15 +280,12 @@ export function JumpHopResultView({ : profile.workDescription; const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳'; const summary = summarySource?.trim() || safeDraft.workDescription.trim(); - const pathPlatforms = normalizePathPlatforms(path); - const canRenderPathMiniMap = pathPlatforms.length > 0; const hasAssets = Boolean( - profile.characterImageSrc?.trim() || - profile.tileAtlasImageSrc?.trim() || + profile.tileAtlasImageSrc?.trim() || profile.pathPreviewImageSrc?.trim() || - characterAsset?.imageSrc?.trim() || tileAtlasAsset?.imageSrc?.trim() || - canRenderPathMiniMap, + tileAssets.length > 0 || + path?.platforms.length, ); const handlePublish = async () => { @@ -310,15 +309,6 @@ export function JumpHopResultView({ 返回
-
{!hasAssets ? ( @@ -419,6 +365,7 @@ export function JumpHopResultView({
结果操作
+ {error ? (
{error} diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index 9b626482..7b16d9a1 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -1,212 +1,919 @@ /* @vitest-environment jsdom */ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { expect, test, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; import type { JumpHopRuntimeRunSnapshotResponse, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { buildJumpHopVisiblePlatforms } from '../../services/jump-hop/jumpHopRuntimeModel'; +import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; import { JumpHopRuntimeShell } from './JumpHopRuntimeShell'; -const profile: JumpHopWorkProfileResponse = { - summary: { - runtimeKind: 'jump-hop', - workId: 'work-1', - profileId: 'profile-1', - ownerUserId: 'user-1', - sourceSessionId: 'session-1', - workTitle: '云端跳台', - workDescription: '一路跳到星星。', - themeTags: ['云朵'], - difficulty: 'standard', - stylePreset: 'paper-toy', - coverImageSrc: 'data:image/png;base64,cover', - publicationStatus: 'draft', - playCount: 0, - updatedAt: '2026-05-30T10:00:00.000Z', - publishedAt: null, - publishReady: true, - generationStatus: 'ready', - }, - draft: { - templateId: 'jump-hop', - templateName: '跳一跳', - profileId: 'profile-1', - workTitle: '云端跳台', - workDescription: '一路跳到星星。', - themeTags: ['云朵'], - difficulty: 'standard', - stylePreset: 'paper-toy', - characterPrompt: '纸片小兔', - tilePrompt: '云朵平台', - endMoodPrompt: '星光门', - characterAsset: { - assetId: 'character-1', - imageSrc: 'data:image/png;base64,character', - imageObjectKey: 'jump-hop/character.png', - assetObjectId: 'asset-character', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '角色图', - width: 1024, - height: 1024, - }, - tileAtlasAsset: { - assetId: 'tiles-1', - imageSrc: 'data:image/png;base64,tiles', - imageObjectKey: 'jump-hop/tiles.png', - assetObjectId: 'asset-tiles', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '地块图', - width: 1024, - height: 1024, - }, - tileAssets: [ - { - tileType: 'start', - imageSrc: 'data:image/png;base64,tile-start', - imageObjectKey: 'jump-hop/tile-start.png', - assetObjectId: 'asset-tile-start', - sourceAtlasCell: 'A1', - visualWidth: 128, - visualHeight: 96, - topSurfaceRadius: 24, - landingRadius: 28, - }, - ], - path: { - seed: 'jump-hop-seed', - difficulty: 'standard', - platforms: [ - { - platformId: 'platform-1', - tileType: 'start', - x: 0, - y: 0, - width: 48, - height: 36, - landingRadius: 22, - perfectRadius: 12, - scoreValue: 1, - }, - ], - finishIndex: 0, - cameraPreset: 'default', - scoring: { - chargeToDistanceRatio: 1.2, - maxChargeMs: 1800, - hitBonus: 20, - perfectBonus: 50, - }, - }, - coverComposite: 'data:image/png;base64,cover', - generationStatus: 'ready', - }, - path: { - seed: 'jump-hop-seed', - difficulty: 'standard', - platforms: [ - { - platformId: 'platform-1', - tileType: 'start', - x: 0, - y: 0, - width: 48, - height: 36, - landingRadius: 22, - perfectRadius: 12, - scoreValue: 1, - }, - ], - finishIndex: 0, - cameraPreset: 'default', - scoring: { - chargeToDistanceRatio: 1.2, - maxChargeMs: 1800, - hitBonus: 20, - perfectBonus: 50, - }, - }, - characterAsset: { - assetId: 'character-1', - imageSrc: 'data:image/png;base64,character', - imageObjectKey: 'jump-hop/character.png', - assetObjectId: 'asset-character', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '角色图', - width: 1024, - height: 1024, - }, - tileAtlasAsset: { - assetId: 'tiles-1', - imageSrc: 'data:image/png;base64,tiles', - imageObjectKey: 'jump-hop/tiles.png', - assetObjectId: 'asset-tiles', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '地块图', - width: 1024, - height: 1024, - }, - tileAssets: [ - { - tileType: 'start', - imageSrc: 'data:image/png;base64,tile-start', - imageObjectKey: 'jump-hop/tile-start.png', - assetObjectId: 'asset-tile-start', - sourceAtlasCell: 'A1', - visualWidth: 128, - visualHeight: 96, - topSurfaceRadius: 24, - landingRadius: 28, - }, - ], -}; +vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ + useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({ + resolvedUrl: source?.trim() ?? '', + isResolving: false, + shouldResolve: Boolean(source?.trim().startsWith('/generated-')), + })), +})); -const run: JumpHopRuntimeRunSnapshotResponse = { - runId: 'run-1', - profileId: 'profile-1', - ownerUserId: 'user-1', - status: 'playing', - currentPlatformIndex: 0, - score: 0, - combo: 0, - path: profile.path, - lastJump: null, - startedAtMs: 1000, - finishedAtMs: null, -}; +vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({ + useJumpHopLeaderboard: vi.fn(), +})); -test('jump hop runtime shell supports jump, restart and exit actions', async () => { - const user = userEvent.setup(); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useJumpHopLeaderboard).mockReturnValue({ + leaderboard: null, + isLoading: false, + error: null, + refresh: vi.fn(), + }); +}); + +function dispatchPointerEvent( + target: HTMLElement, + type: string, + options: { pointerId: number; clientX: number; clientY: number }, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.assign(event, options); + target.dispatchEvent(event); +} + +test('跳一跳运行态松手时提交向后拖动向量', async () => { + vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); - const onRestart = vi.fn(); - const onExit = vi.fn(); + const run = buildRun(); + const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []); + const current = visiblePlatforms[0]!; + const target = visiblePlatforms[1]!; + const stageSize = { width: 320, height: 568 }; + const xPixelsPerWorldUnit = + Math.abs( + ((target.screenX - current.screenX) / 100) * stageSize.width, + ) / Math.abs(target.platform.x - current.platform.x); + const yPixelsPerWorldUnit = + Math.abs( + ((target.screenY - current.screenY) / 100) * stageSize.height, + ) / Math.abs(target.platform.y - current.platform.y); render( {}} />, ); - await user.pointer([ - { target: screen.getByRole('button', { name: '起跳' }), keys: '[MouseLeft>]' }, - ]); - await user.pointer([ - { target: screen.getByRole('button', { name: '起跳' }), keys: '[/MouseLeft]' }, - ]); - - await waitFor(() => { - expect(onJump).toHaveBeenCalledWith({ chargeMs: expect.any(Number) }); + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); }); - await user.click(screen.getByRole('button', { name: '重开' })); - await user.click(screen.getByRole('button', { name: '返回' })); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); - expect(onRestart).toHaveBeenCalledTimes(1); - expect(onExit).toHaveBeenCalledTimes(1); + expect(onJump).toHaveBeenCalledTimes(1); + const jumpPayload = onJump.mock.calls[0]?.[0]; + expect(jumpPayload?.dragVectorX).toBeCloseTo(-48 / xPixelsPerWorldUnit, 2); + expect(jumpPayload?.dragVectorY).toBeCloseTo(58 / yPixelsPerWorldUnit, 2); + expect(jumpPayload?.dragDistance).toBeGreaterThan(74); + expect(jumpPayload?.dragDistance).toBeLessThan(76); + vi.useRealTimers(); }); + +test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () => { + const onJump = vi.fn().mockResolvedValue(undefined); + const run = buildRun(); + const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []); + const current = visiblePlatforms[0]!; + const target = visiblePlatforms[1]!; + const stageSize = { width: 320, height: 568 }; + const xPixelsPerWorldUnit = + Math.abs( + ((target.screenX - current.screenX) / 100) * stageSize.width, + ) / Math.abs(target.platform.x - current.platform.x); + const yPixelsPerWorldUnit = + Math.abs( + ((target.screenY - current.screenY) / 100) * stageSize.height, + ) / Math.abs(target.platform.y - current.platform.y); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 40, + clientY: 40, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 10, + clientY: 20, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 10, + clientY: 20, + }); + }); + + const jumpPayload = onJump.mock.calls[0]?.[0]; + expect(jumpPayload?.dragVectorX).toBeLessThan(0); + expect(jumpPayload?.dragVectorY).toBeLessThan(0); + expect(Math.abs(jumpPayload?.dragVectorX ?? 0)).toBeLessThan(30); + expect(Math.abs(jumpPayload?.dragVectorY ?? 0)).toBeLessThan(20); + expect(jumpPayload?.dragVectorX).toBeCloseTo(-30 / xPixelsPerWorldUnit, 2); + expect(jumpPayload?.dragVectorY).toBeCloseTo(-20 / yPixelsPerWorldUnit, 2); +}); + +test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', async () => { + const onJump = vi.fn().mockResolvedValue(undefined); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + + expect(screen.queryByText('起跳')).toBeNull(); + expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); +}); + +test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => { + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + + const character = screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement; + const stretchTransform = character.style.getPropertyValue( + '--jump-hop-character-stretch-transform', + ); + const styleText = Array.from(document.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n'); + + expect(stretchTransform).toContain('matrix('); + expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)'); + expect(styleText).toContain('var(--jump-hop-character-stretch-transform)'); + expect(styleText).not.toContain( + 'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))', + ); +}); + +test('跳一跳运行态需要三维场景宿主和排行榜面板', () => { + const runtimeRequestOptions = { + runtimeGuestToken: 'runtime-guest-token', + }; + vi.mocked(useJumpHopLeaderboard).mockReturnValue({ + leaderboard: { + profileId: 'jump-hop-profile-test', + items: [ + { + rank: 1, + playerId: 'player-1', + successfulJumpCount: 8, + durationMs: 8123, + updatedAt: '2026-05-27T00:00:00Z', + }, + ], + viewerBest: null, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + }); + + render( + {}} + />, + ); + + expect(useJumpHopLeaderboard).toHaveBeenCalledWith( + 'jump-hop-profile-test', + runtimeRequestOptions, + ); + expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy(); + expect(screen.getByTestId('jump-hop-runtime-leaderboard')).toBeTruthy(); + expect(screen.getByText('player-1')).toBeTruthy(); + expect(screen.getByText('8 跳')).toBeTruthy(); + expect(screen.getByText('00:08')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull(); +}); + +test('跳一跳角色层永远压在地块层之上', () => { + render( + {}} + />, + ); + + const threeScene = screen.getByTestId('jump-hop-three-scene'); + const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0] + ?.parentElement?.parentElement as HTMLElement | undefined; + + expect(threeScene.style.zIndex).toBe('100'); + expect(Number(threeScene.style.zIndex)).toBeGreaterThan( + Number(firstPlatform?.style.zIndex ?? 0), + ); +}); + +test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', async () => { + const onJump = vi.fn().mockResolvedValue(undefined); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + + expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); + + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 148, + clientY: 454, + }); + }); + + const firstAssist = screen.getByTestId('jump-hop-landing-assist'); + const firstLeft = firstAssist.style.left; + const firstTop = firstAssist.style.top; + expect(firstAssist.getAttribute('data-target-index')).toBe('1'); + expect(firstLeft).not.toBe('62.288%'); + + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 112, + clientY: 492, + }); + }); + + const secondAssist = screen.getByTestId('jump-hop-landing-assist'); + expect(secondAssist.style.left).not.toBe(firstLeft); + expect(secondAssist.style.top).not.toBe(firstTop); +}); + +test('跳一跳运行态直接渲染生成的地块切片图片', () => { + render( + {}} + />, + ); + + const tileImages = screen.getAllByTestId('jump-hop-tile-image'); + expect(tileImages).toHaveLength(3); + const generatedReadUrlCalls = vi + .mocked(useResolvedAssetReadUrl) + .mock.calls.filter(([source]) => + source?.includes('/generated-jump-hop-assets/'), + ); + expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3); + for (const [, options] of generatedReadUrlCalls) { + expect(options).toEqual( + expect.objectContaining({ + refreshKey: expect.stringMatching(/^asset-object-/), + }), + ); + } + + for (const image of tileImages) { + expect(image.getAttribute('src')).toContain( + '/generated-jump-hop-assets/jump-hop-profile-test/tile-', + ); + fireEvent.load(image); + expect(image.getAttribute('data-loaded')).toBe('true'); + } +}); + +test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => { + render( + {}} + />, + ); + + const tileImages = screen.getAllByTestId('jump-hop-tile-image'); + expect(tileImages).toHaveLength(3); + const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined; + const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined; + const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined; + expect(first?.style.top).toBe('78%'); + expect(second?.style.top).toBe('50%'); + expect(third?.style.top).toBe('22%'); +}); + +test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => { + render( + {}} + />, + ); + + const firstTile = screen.getAllByTestId('jump-hop-tile-image')[0] + ?.parentElement?.parentElement as HTMLElement | undefined; + + expect(firstTile?.style.width).toBe('116px'); + expect(firstTile?.style.height).toBe('96px'); + expect(firstTile?.style.getPropertyValue('--jump-hop-platform-scale')).toBe( + '1.08', + ); +}); + +test('跳一跳运行态使用陶泥儿透明 logo 作为角色形象', () => { + render( + {}} + />, + ); + + const logo = screen.getByTestId('jump-hop-character-logo'); + expect(logo.getAttribute('src')).toBe( + '/branding/jump-hop-taonier-character.png', + ); + expect( + screen.queryByTestId('jump-hop-character-fallback-shape'), + ).toBeNull(); +}); + +test('跳一跳蓄力和计时刷新不会重建三维画布宿主', async () => { + vi.useFakeTimers(); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + const canvas = screen.getByTestId('jump-hop-three-canvas'); + + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + vi.advanceTimersByTime(520); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 160, + clientY: 460, + }); + }); + + expect(screen.getByTestId('jump-hop-three-canvas')).toBe(canvas); + vi.useRealTimers(); +}); + +test('跳一跳后端回包较慢时角色停在目标点等待推进', async () => { + vi.useFakeTimers(); + const onJump = vi.fn().mockResolvedValue(undefined); + const initialRun = buildRun(); + const nextRun: JumpHopRuntimeRunSnapshotResponse = { + ...buildRun(), + currentPlatformIndex: 1, + successfulJumpCount: 1, + score: 1, + lastJump: { + chargeMs: 150, + jumpDistance: 1.44, + targetPlatformIndex: 1, + landedX: 0.8, + landedY: 1.2, + result: 'hit', + }, + }; + const { rerender } = render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(580); + }); + + const character = screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement; + expect(stage.getAttribute('data-jump-animating')).toBe('true'); + expect(stage.getAttribute('data-platform-advancing')).toBe('false'); + expect(Number.parseFloat(character.style.left)).not.toBeCloseTo(50, 2); + expect(character.style.getPropertyValue('--jump-hop-flight-from-x')).not.toBe( + '0px', + ); + expect(character.style.getPropertyValue('--jump-hop-flight-from-y')).not.toBe( + '0px', + ); + + rerender( + {}} + />, + ); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( + 'false', + ); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'true', + ); + vi.useRealTimers(); +}); + +test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => { + vi.useFakeTimers(); + const onJump = vi.fn().mockResolvedValue(undefined); + const initialRun = buildRun(); + const nextRun: JumpHopRuntimeRunSnapshotResponse = { + ...buildRun(), + currentPlatformIndex: 1, + successfulJumpCount: 1, + score: 1, + lastJump: { + chargeMs: 150, + jumpDistance: 1.44, + targetPlatformIndex: 1, + landedX: 0.8, + landedY: 1.2, + result: 'hit', + }, + }; + const { rerender } = render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + + expect(onJump).toHaveBeenCalledTimes(1); + expect(stage.getAttribute('data-jump-animating')).toBe('true'); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( + '78%', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( + 'true', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p0', + ); + + rerender( + {}} + />, + ); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( + 'true', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( + '78%', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( + 'true', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p0', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(580); + }); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( + 'false', + ); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'true', + ); + const landedCharacter = screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement; + expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true'); + expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe( + '0px', + ); + expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-y')).not.toBe( + '0px', + ); + const cameraLayer = screen.getByTestId('jump-hop-camera-layer'); + expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true'); + expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe( + '-28%', + ); + expect( + Number.parseFloat( + cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'), + ), + ).toBeCloseTo(12.29, 2); + const styleText = Array.from(document.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n'); + expect(styleText).toContain('@keyframes jump-hop-character-recoil'); + expect(styleText).toMatch( + /data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/, + ); + expect(screen.getByTestId('jump-hop-three-scene').parentElement).toBe( + cameraLayer, + ); + expect( + screen + .getByTestId('jump-hop-stage') + .querySelector("[data-advance-state='settling']"), + ).toBeNull(); + expect( + screen + .getByTestId('jump-hop-stage') + .querySelector("[data-advance-state='entering']"), + ).toBeNull(); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p0', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p1', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe( + '78%', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe( + '1.08', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p2', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe( + '50%', + ); + + await act(async () => { + vi.advanceTimersByTime(720); + }); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'true', + ); + expect( + ( + screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement + ).getAttribute('data-landing-recoil'), + ).toBe('false'); + + await act(async () => { + vi.advanceTimersByTime(660); + }); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'true', + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'false', + ); + expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe( + 'false', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( + '78%', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( + 'true', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p1', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe( + '50%', + ); + + vi.useRealTimers(); +}); + +function buildRun(): JumpHopRuntimeRunSnapshotResponse { + return { + runId: 'jump-hop-run-test', + profileId: 'jump-hop-profile-test', + ownerUserId: 'user-test', + status: 'playing', + currentPlatformIndex: 0, + successfulJumpCount: 0, + durationMs: 0, + score: 0, + combo: 0, + path: { + seed: 'test', + difficulty: 'standard', + finishIndex: 4294967295, + cameraPreset: 'portrait-isometric-9x16', + scoring: { + chargeToDistanceRatio: 0.004, + maxChargeMs: 900, + hitBonus: 20, + perfectBonus: 60, + }, + platforms: [ + { + platformId: 'p0', + tileType: 'start', + x: 0, + y: 0, + width: 1, + height: 1, + landingRadius: 0.5, + perfectRadius: 0.2, + scoreValue: 1, + }, + { + platformId: 'p1', + tileType: 'normal', + x: 0.8, + y: 1.2, + width: 1, + height: 1, + landingRadius: 0.5, + perfectRadius: 0.2, + scoreValue: 1, + }, + { + platformId: 'p2', + tileType: 'target', + x: -0.2, + y: 2.4, + width: 1, + height: 1, + landingRadius: 0.5, + perfectRadius: 0.2, + scoreValue: 1, + }, + ], + }, + lastJump: null, + startedAtMs: 1000, + finishedAtMs: null, + }; +} + +function buildTileAssets() { + return Array.from({ length: 25 }, (_, index) => { + const tileNumber = String(index + 1).padStart(2, '0'); + return { + tileType: index === 0 ? 'start' : 'normal', + tileId: `tile-${tileNumber}`, + imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`, + imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`, + assetObjectId: `asset-object-${tileNumber}`, + sourceAtlasCell: `row-${Math.floor(index / 5) + 1}-col-${(index % 5) + 1}`, + atlasRow: Math.floor(index / 5) + 1, + atlasCol: (index % 5) + 1, + visualWidth: 256, + visualHeight: 192, + topSurfaceRadius: 42, + landingRadius: 34, + } satisfies JumpHopWorkProfileResponse['tileAssets'][number]; + }); +} + +function buildProfile(options: { + tileAssets?: JumpHopWorkProfileResponse['tileAssets']; +} = {}): JumpHopWorkProfileResponse { + const characterAsset = { + assetId: 'builtin', + imageSrc: 'builtin://jump-hop/default-character', + imageObjectKey: '', + assetObjectId: 'builtin', + generationProvider: 'builtin-three', + prompt: '默认角色', + width: 0, + height: 0, + }; + return { + summary: { + runtimeKind: 'jump-hop', + workId: 'jump-hop-profile-test', + profileId: 'jump-hop-profile-test', + ownerUserId: 'user-test', + sourceSessionId: 'jump-hop-session-test', + themeText: '测试', + workTitle: '测试', + workDescription: '测试', + themeTags: ['测试'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-27T00:00:00Z', + publishedAt: null, + publishReady: true, + generationStatus: 'ready', + }, + draft: { + templateId: 'jump-hop', + templateName: '跳一跳', + profileId: 'jump-hop-profile-test', + themeText: '测试', + workTitle: '测试', + workDescription: '测试', + themeTags: ['测试'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + defaultCharacter: { + characterId: 'jump-hop-default-runner', + displayName: '默认角色', + modelKind: 'builtin-three', + bodyColor: '#f59e0b', + accentColor: '#2563eb', + }, + characterPrompt: '默认角色', + tilePrompt: '地块', + endMoodPrompt: null, + characterAsset, + tileAtlasAsset: characterAsset, + tileAssets: options.tileAssets ?? [], + path: buildRun().path, + coverComposite: null, + generationStatus: 'ready', + }, + path: buildRun().path, + defaultCharacter: { + characterId: 'jump-hop-default-runner', + displayName: '默认角色', + modelKind: 'builtin-three', + bodyColor: '#f59e0b', + accentColor: '#2563eb', + }, + characterAsset, + tileAtlasAsset: characterAsset, + tileAssets: options.tileAssets ?? [], + }; +} diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 7d99810c..8393f75d 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -1,7 +1,10 @@ -import { ArrowLeft, Hand, Loader2, RotateCcw } from 'lucide-react'; +import { ArrowLeft, Loader2, RotateCcw } from 'lucide-react'; import { type CSSProperties, + type Dispatch, type PointerEvent, + type SetStateAction, + useCallback, useEffect, useMemo, useRef, @@ -9,11 +12,39 @@ import { } from 'react'; import type { - JumpHopPlatform, JumpHopRuntimeRunSnapshotResponse, JumpHopTileAsset, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import type { JumpHopRuntimeRequestOptions } from '../../services/jump-hop/jumpHopClient'; +import { + buildJumpHopVisiblePlatforms, + formatJumpHopDurationLabel, + getJumpHopBackendDragVector, + getJumpHopCharacterVisualPosition, + getJumpHopJumpFeedbackLabel, + getJumpHopLandingAssistVisualPosition, + getJumpHopPlatformVisualSize, + getJumpHopRunDurationMs, + getJumpHopStatusLabel, + getJumpHopTileTone, + type JumpHopCharacterVisualPosition, + type JumpHopVisiblePlatform, + resolveJumpHopCharacterCanvasPosition, +} from '../../services/jump-hop/jumpHopRuntimeModel'; +import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; + +type JumpHopRuntimeJumpPayload = { + dragDistance: number; + dragVectorX: number; + dragVectorY: number; +}; + +type JumpHopVisualJump = { + from: JumpHopCharacterVisualPosition; + to: JumpHopCharacterVisualPosition; +}; type JumpHopRuntimeShellProps = { profile?: JumpHopWorkProfileResponse | null; @@ -21,38 +52,62 @@ type JumpHopRuntimeShellProps = { snapshot?: JumpHopRuntimeRunSnapshotResponse | null; isBusy?: boolean; error?: string | null; - onJump: (payload: { chargeMs: number }) => Promise; + runtimeRequestOptions?: JumpHopRuntimeRequestOptions; + onJump: (payload: JumpHopRuntimeJumpPayload) => Promise; onRestart: () => void; onExit?: () => void; onBack?: () => void; }; -type VisiblePlatform = { - platform: JumpHopPlatform; - index: number; - screenX: number; - screenY: number; - scale: number; - asset: JumpHopTileAsset | null; -}; - const MAX_CHARGE_RATIO = 1; -const DEFAULT_MAX_CHARGE_MS = 1800; -const VISIBLE_FORWARD_COUNT = 6; - -const tileToneByType: Record = { - accent: '#e0f2fe', - bonus: '#fef3c7', - finish: '#dcfce7', - normal: '#f8fafc', - start: '#e0f2fe', - target: '#fee2e2', -}; +const DEFAULT_MAX_DRAG_DISTANCE_PX = 180; +const JUMP_HOP_ANIMATION_DURATION_MS = 560; +const JUMP_HOP_LANDING_RECOIL_DURATION_MS = 560; +const JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS = 1440; +const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC = + '/branding/jump-hop-taonier-character.png'; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +function formatJumpHopCssNumber(value: number) { + if (!Number.isFinite(value)) { + return '0'; + } + return value.toFixed(4).replace(/\.?0+$/, ''); +} + +function buildJumpHopDirectionalScaleMatrix({ + directionX, + directionY, + stretchScale, + crossScale, +}: { + directionX: number; + directionY: number; + stretchScale: number; + crossScale: number; +}) { + const distance = Math.hypot(directionX, directionY); + if (distance < 0.1) { + return 'matrix(1, 0, 0, 1, 0, 0)'; + } + + const unitX = directionX / distance; + const unitY = directionY / distance; + const stretchDelta = stretchScale - crossScale; + const a = crossScale + stretchDelta * unitX * unitX; + const b = stretchDelta * unitX * unitY; + const c = stretchDelta * unitX * unitY; + const d = crossScale + stretchDelta * unitY * unitY; + return `matrix(${formatJumpHopCssNumber(a)}, ${formatJumpHopCssNumber( + b, + )}, ${formatJumpHopCssNumber(c)}, ${formatJumpHopCssNumber( + d, + )}, 0, 0)`; +} + function getRun( run: JumpHopRuntimeRunSnapshotResponse | null | undefined, snapshot: JumpHopRuntimeRunSnapshotResponse | null | undefined, @@ -60,121 +115,66 @@ function getRun( return run ?? snapshot ?? null; } -function buildTileAssetMap(tileAssets: JumpHopTileAsset[] | undefined) { - const map = new Map(); - for (const asset of tileAssets ?? []) { - if (!map.has(asset.tileType)) { - map.set(asset.tileType, asset); - } - } - return map; -} - -function getStatusLabel( - status: JumpHopRuntimeRunSnapshotResponse['status'] | undefined, +function hasJumpHopRunDisplayChange( + current: JumpHopRuntimeRunSnapshotResponse, + next: JumpHopRuntimeRunSnapshotResponse, ) { - if (status === 'cleared') { - return '通关'; - } - if (status === 'failed') { - return '失败'; - } - return '进行中'; -} - -function getJumpFeedback(run: JumpHopRuntimeRunSnapshotResponse | null) { - const result = run?.lastJump?.result; - if (result === 'perfect') { - return 'Perfect'; - } - if (result === 'finish') { - return 'Finish'; - } - if (result === 'hit') { - return 'Hit'; - } - if (result === 'miss') { - return 'Miss'; - } - return null; -} - -function projectPlatformPath( - platforms: JumpHopPlatform[], - currentIndex: number, - tileAssetMap: Map, -) { - const current = platforms[currentIndex] ?? platforms[0]; - if (!current) { - return []; - } - - const start = Math.max(0, currentIndex - 1); - const end = Math.min(platforms.length, currentIndex + VISIBLE_FORWARD_COUNT); - const visible = platforms.slice(start, end); - const worldScale = 0.86; - - return visible.map((platform, offset): VisiblePlatform => { - const index = start + offset; - const dx = platform.x - current.x; - const dy = platform.y - current.y; - const isoX = (dx - dy) * worldScale; - const isoY = (dx + dy) * 0.46 * worldScale; - const depth = index - currentIndex; - - return { - platform, - index, - screenX: 50 + isoX, - screenY: 58 + isoY - depth * 0.8, - scale: clamp(1 - Math.max(0, depth) * 0.035, 0.78, 1.08), - asset: - tileAssetMap.get(platform.tileType) ?? - tileAssetMap.get('normal') ?? - tileAssetMap.get('start') ?? - null, - }; - }); -} - -function getCharacterPosition( - run: JumpHopRuntimeRunSnapshotResponse | null, - platforms: VisiblePlatform[], -) { - if (!run) { - return null; - } - - const landedPlatform = platforms.find( - (item) => item.index === run.currentPlatformIndex, + return ( + current.currentPlatformIndex !== next.currentPlatformIndex || + current.status !== next.status || + current.successfulJumpCount !== next.successfulJumpCount || + current.durationMs !== next.durationMs || + current.score !== next.score || + current.combo !== next.combo || + current.finishedAtMs !== next.finishedAtMs || + current.lastJump?.targetPlatformIndex !== next.lastJump?.targetPlatformIndex || + current.lastJump?.result !== next.lastJump?.result || + current.lastJump?.chargeMs !== next.lastJump?.chargeMs ); - if (landedPlatform) { +} + +function shouldAnimateJumpHopPlatformAdvance( + current: JumpHopRuntimeRunSnapshotResponse, + next: JumpHopRuntimeRunSnapshotResponse, +) { + return ( + current.runId === next.runId && + next.currentPlatformIndex > current.currentPlatformIndex && + next.status === 'playing' + ); +} + +function buildJumpHopCharacterVisualPositionFromPlatform( + platform: JumpHopVisiblePlatform, + isMiss = false, +): JumpHopCharacterVisualPosition { + if (isMiss) { return { - x: landedPlatform.screenX, - y: landedPlatform.screenY - 8, - isMiss: false, + screenX: platform.screenX + 8, + screenY: platform.screenY - 2, + sceneX: platform.sceneX + 0.7, + sceneY: platform.sceneY + 0.48, + sceneZ: platform.sceneZ - 0.4, + isMiss: true, }; } - const lastJump = run.lastJump; - if (lastJump && run.status === 'failed') { - const targetPlatform = platforms.find( - (item) => item.index === lastJump.targetPlatformIndex, - ); - if (targetPlatform) { - return { - x: targetPlatform.screenX + 8, - y: targetPlatform.screenY - 2, - isMiss: true, - }; - } - } - - return null; + return { + screenX: platform.screenX, + screenY: platform.screenY - 3, + sceneX: platform.sceneX, + sceneY: platform.sceneY + 0.84, + sceneZ: platform.sceneZ, + isMiss: false, + }; } -function IsometricFallbackTile({ platform }: { platform: JumpHopPlatform }) { - const tone = tileToneByType[platform.tileType] ?? tileToneByType.normal; +function IsometricFallbackTile({ + platform, +}: { + platform: JumpHopVisiblePlatform['platform']; +}) { + const tone = getJumpHopTileTone(platform.tileType); const style = { '--jump-hop-tile-tone': tone, } as CSSProperties; @@ -192,101 +192,1026 @@ function IsometricFallbackTile({ platform }: { platform: JumpHopPlatform }) { ); } +function JumpHopTileImage({ + asset, + platform, +}: { + asset: JumpHopTileAsset | null; + platform: JumpHopVisiblePlatform['platform']; +}) { + const assetRefreshKey = + asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null; + const { resolvedUrl } = useResolvedAssetReadUrl(asset?.imageSrc, { + refreshKey: assetRefreshKey, + }); + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + setIsLoaded(false); + setHasError(false); + }, [resolvedUrl]); + + const shouldShowFallback = !resolvedUrl || !isLoaded || hasError; + + return ( +
+ {shouldShowFallback ? : null} + {resolvedUrl && !hasError ? ( + { + setIsLoaded(true); + }} + onError={() => { + setHasError(true); + }} + /> + ) : null} +
+ ); +} + +function hasJumpHopWebGLSupport() { + if (import.meta.env.MODE === 'test') { + return false; + } + + try { + const canvas = document.createElement('canvas'); + return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')); + } catch { + return false; + } +} + +function applyJumpHopCanvasLayout(canvas: HTMLCanvasElement) { + canvas.style.display = 'block'; + canvas.style.height = '100%'; + canvas.style.inset = '0'; + canvas.style.position = 'absolute'; + canvas.style.width = '100%'; +} + +function disposeJumpHopThreeObject(object: import('three').Object3D) { + object.traverse((child) => { + const mesh = child as import('three').Mesh; + mesh.geometry?.dispose(); + const material = mesh.material; + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material?.dispose(); + } + }); +} + +function JumpHopThreeScene({ + characterPosition, + chargeRatio, + isJumpAnimating, + platformCount, + renderCharacter, + onCharacterLayerReadyChange, +}: { + characterPosition: JumpHopCharacterVisualPosition | null; + chargeRatio: number; + isJumpAnimating: boolean; + platformCount: number; + renderCharacter: boolean; + onCharacterLayerReadyChange: Dispatch>; +}) { + const hostRef = useRef(null); + const characterPositionRef = useRef(characterPosition); + const chargeRatioRef = useRef(chargeRatio); + const isJumpAnimatingRef = useRef(isJumpAnimating); + + useEffect(() => { + characterPositionRef.current = characterPosition; + }, [characterPosition]); + + useEffect(() => { + chargeRatioRef.current = chargeRatio; + }, [chargeRatio]); + + useEffect(() => { + isJumpAnimatingRef.current = isJumpAnimating; + }, [isJumpAnimating]); + + useEffect(() => { + const host = hostRef.current; + if (!host) { + return undefined; + } + + onCharacterLayerReadyChange(false); + host.replaceChildren(); + const fallbackCanvas = document.createElement('canvas'); + applyJumpHopCanvasLayout(fallbackCanvas); + fallbackCanvas.setAttribute('data-testid', 'jump-hop-three-canvas'); + host.appendChild(fallbackCanvas); + + if (!renderCharacter || !hasJumpHopWebGLSupport()) { + return () => { + onCharacterLayerReadyChange(false); + fallbackCanvas.remove(); + }; + } + + let disposed = false; + let animationId: number | null = null; + let cleanup: (() => void) | null = null; + + const setup = async () => { + const three = await import('three'); + if (disposed || !hostRef.current) { + return; + } + + const renderer = new three.WebGLRenderer({ + alpha: true, + antialias: true, + canvas: fallbackCanvas, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); + renderer.outputColorSpace = three.SRGBColorSpace; + + const scene = new three.Scene(); + scene.background = null; + + const camera = new three.OrthographicCamera(0, 320, 0, 568, -100, 100); + camera.position.set(0, 0, 50); + camera.lookAt(0, 0, 0); + + scene.add(new three.AmbientLight(0xffffff, 1.45)); + const keyLight = new three.DirectionalLight(0xffffff, 2.2); + keyLight.position.set(-80, 120, 80); + scene.add(keyLight); + const rimLight = new three.DirectionalLight(0xffedd5, 0.8); + rimLight.position.set(120, 80, 60); + scene.add(rimLight); + + const character = new three.Group(); + const body = new three.Mesh( + new three.CapsuleGeometry(10, 22, 8, 18), + new three.MeshStandardMaterial({ + color: 0xdf7f40, + roughness: 0.74, + }), + ); + body.position.y = -28; + const head = new three.Mesh( + new three.SphereGeometry(11, 28, 20), + new three.MeshStandardMaterial({ + color: 0xf59e0b, + roughness: 0.7, + }), + ); + head.position.y = -62; + const accent = new three.Mesh( + new three.BoxGeometry(15, 7, 7), + new three.MeshStandardMaterial({ + color: 0x2563eb, + roughness: 0.64, + }), + ); + accent.position.set(0, -36, 10); + character.add(body, head, accent); + scene.add(character); + + const size = { + width: 320, + height: 568, + }; + const resize = () => { + const rect = host.getBoundingClientRect(); + const width = Math.max(1, rect.width || host.clientWidth || 320); + const height = Math.max(1, rect.height || host.clientHeight || 568); + size.width = width; + size.height = height; + renderer.setSize(width, height, false); + camera.left = 0; + camera.right = width; + camera.top = 0; + camera.bottom = height; + camera.updateProjectionMatrix(); + renderer.render(scene, camera); + }; + + const resizeObserver = window.ResizeObserver + ? new window.ResizeObserver(resize) + : null; + resizeObserver?.observe(host); + resize(); + onCharacterLayerReadyChange(true); + + const animate = () => { + const nextCharacterPosition = characterPositionRef.current; + if (nextCharacterPosition) { + const nextChargeRatio = chargeRatioRef.current; + const canvasPosition = resolveJumpHopCharacterCanvasPosition( + nextCharacterPosition, + size, + ); + character.visible = true; + character.position.set(canvasPosition?.x ?? 0, canvasPosition?.y ?? 0, 0); + if (isJumpAnimatingRef.current) { + const now = window.performance.now(); + character.rotation.z = Math.sin(now / 42) * 1.22; + character.rotation.x = Math.sin(now / 28) * 0.28; + character.rotation.y = Math.sin(now / 34) * 0.2; + character.position.y += Math.sin(now / 26) * 8 - 14; + } else { + character.rotation.z = nextCharacterPosition.isMiss ? -0.32 : 0; + character.rotation.x = 0; + character.rotation.y = 0; + } + character.scale.set( + 1 + nextChargeRatio * 0.08, + 1 - nextChargeRatio * 0.12, + 1 + nextChargeRatio * 0.08, + ); + } else { + character.visible = false; + } + renderer.render(scene, camera); + animationId = window.requestAnimationFrame(animate); + }; + animate(); + + cleanup = () => { + if (animationId != null) { + window.cancelAnimationFrame(animationId); + } + resizeObserver?.disconnect(); + disposeJumpHopThreeObject(scene); + renderer.dispose(); + onCharacterLayerReadyChange(false); + }; + }; + + void setup(); + + return () => { + disposed = true; + cleanup?.(); + fallbackCanvas.remove(); + host.replaceChildren(); + }; + }, [onCharacterLayerReadyChange, renderCharacter]); + + return ( +
+ ); +} + +function JumpHopLeaderboardPanel({ + profileId, + runtimeRequestOptions, +}: { + profileId?: string | null; + runtimeRequestOptions?: JumpHopRuntimeRequestOptions; +}) { + const { leaderboard, isLoading, error } = useJumpHopLeaderboard( + profileId, + runtimeRequestOptions, + ); + const items = leaderboard?.items ?? []; + + return ( + + ); +} + export function JumpHopRuntimeShell({ profile = null, run, snapshot, isBusy = false, error = null, + runtimeRequestOptions, onExit, onBack, onRestart, onJump, }: JumpHopRuntimeShellProps) { const activeRun = getRun(run, snapshot); + const [displayRun, setDisplayRun] = useState(activeRun); + const [isJumpAnimating, setIsJumpAnimating] = useState(false); + const [isLandingRecoilAnimating, setIsLandingRecoilAnimating] = + useState(false); const [isCharging, setIsCharging] = useState(false); - const [chargeMs, setChargeMs] = useState(0); - const chargeStartRef = useRef(null); - - const maxChargeMs = - activeRun?.path.scoring.maxChargeMs && - activeRun.path.scoring.maxChargeMs > 0 - ? activeRun.path.scoring.maxChargeMs - : DEFAULT_MAX_CHARGE_MS; - const chargeRatio = clamp(chargeMs / maxChargeMs, 0, MAX_CHARGE_RATIO); - const canJump = Boolean( - activeRun && activeRun.status === 'playing' && !isBusy, - ); - const exitHandler = onExit ?? onBack; - const tileAssetMap = useMemo( - () => buildTileAssetMap(profile?.tileAssets), - [profile?.tileAssets], - ); - const visiblePlatforms = useMemo( - () => - projectPlatformPath( - activeRun?.path.platforms ?? [], - activeRun?.currentPlatformIndex ?? 0, - tileAssetMap, - ), - [activeRun?.currentPlatformIndex, activeRun?.path.platforms, tileAssetMap], - ); - const characterPosition = getCharacterPosition(activeRun, visiblePlatforms); - const jumpFeedback = getJumpFeedback(activeRun); - const isSettled = - activeRun?.status === 'failed' || activeRun?.status === 'cleared'; + const [dragDistance, setDragDistance] = useState(0); + const [visualJump, setVisualJump] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [isThreeCharacterLayerReady, setIsThreeCharacterLayerReady] = + useState(false); + const [dragPointerPosition, setDragPointerPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [dragVector, setDragVector] = useState({ x: 0, y: 0 }); + const [jumpAnimationProgress, setJumpAnimationProgress] = useState(0); + const [isPlatformAdvancing, setIsPlatformAdvancing] = useState(false); + const [platformAdvanceExitingPlatforms, setPlatformAdvanceExitingPlatforms] = + useState([]); + const [platformAdvanceCameraOffsetX, setPlatformAdvanceCameraOffsetX] = + useState(0); + const [platformAdvanceCameraOffsetY, setPlatformAdvanceCameraOffsetY] = + useState(0); + const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); + const stageRef = useRef(null); + const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const dragCurrentRef = useRef<{ x: number; y: number } | null>(null); + const animationFrameRef = useRef(null); + const animationEndTimerRef = useRef(null); + const landingRecoilEndTimerRef = useRef(null); + const animationStartAtRef = useRef(0); + const hasJumpAnimationReachedTargetRef = useRef(false); + const platformAdvanceEndTimerRef = useRef(null); + const activeRunRef = useRef(activeRun); + const displayRunRef = useRef(displayRun); + const visiblePlatformsRef = useRef([]); + const tileAssetsRef = useRef(profile?.tileAssets); useEffect(() => { - if (!isCharging) { + activeRunRef.current = activeRun; + }, [activeRun]); + + useEffect(() => { + displayRunRef.current = displayRun; + }, [displayRun]); + + const stageRun = displayRun ?? activeRun; + const maxDragDistancePx = + stageRun?.path.scoring.maxChargeMs && stageRun.path.scoring.maxChargeMs > 0 + ? stageRun.path.scoring.maxChargeMs + : DEFAULT_MAX_DRAG_DISTANCE_PX; + const chargeRatio = clamp( + dragDistance / maxDragDistancePx, + 0, + MAX_CHARGE_RATIO, + ); + const canJump = Boolean( + activeRun && + activeRun.status === 'playing' && + !isBusy && + !isJumpAnimating && + !isPlatformAdvancing, + ); + const exitHandler = onExit ?? onBack; + const visiblePlatforms = useMemo( + () => + buildJumpHopVisiblePlatforms( + stageRun?.path, + stageRun?.currentPlatformIndex ?? 0, + profile?.tileAssets, + ), + [profile?.tileAssets, stageRun?.currentPlatformIndex, stageRun?.path], + ); + const platformRenderItems = useMemo(() => { + const exitingItems = platformAdvanceExitingPlatforms.map((item) => ({ + ...item, + renderKey: `${item.platform.platformId}-exiting`, + advanceState: 'exiting' as const, + })); + const visibleItems = visiblePlatforms.map((item) => ({ + ...item, + renderKey: item.platform.platformId, + advanceState: isPlatformAdvancing ? ('camera' as const) : ('idle' as const), + })); + + return [...exitingItems, ...visibleItems]; + }, [ + isPlatformAdvancing, + platformAdvanceExitingPlatforms, + visiblePlatforms, + ]); + const showLandingAssist = + import.meta.env.MODE !== 'production' && isCharging && !isJumpAnimating; + const characterPosition = getJumpHopCharacterVisualPosition( + stageRun, + visiblePlatforms, + ); + const jumpTargetPlatform = useMemo(() => { + if (!stageRun) { + return null; + } + return ( + visiblePlatforms.find( + (item) => item.index === stageRun.currentPlatformIndex + 1, + ) ?? null + ); + }, [stageRun, visiblePlatforms]); + const visualCharacterPosition = useMemo(() => { + if (!characterPosition) { + return null; + } + if (isJumpAnimating && visualJump) { + return visualJump.to; + } + if (!isJumpAnimating || !jumpTargetPlatform) { + return characterPosition; + } + + const targetCharacterPosition = buildJumpHopCharacterVisualPositionFromPlatform( + jumpTargetPlatform, + false, + ); + const easedProgress = 1 - Math.pow(1 - clamp(jumpAnimationProgress, 0, 1), 3); + const arcOffset = Math.sin(Math.PI * easedProgress) * -24; + + return { + screenX: + characterPosition.screenX + + (targetCharacterPosition.screenX - characterPosition.screenX) * easedProgress, + screenY: + characterPosition.screenY + + (targetCharacterPosition.screenY - characterPosition.screenY) * easedProgress + + arcOffset, + sceneX: + characterPosition.sceneX + + (targetCharacterPosition.sceneX - characterPosition.sceneX) * easedProgress, + sceneY: + characterPosition.sceneY + + (targetCharacterPosition.sceneY - characterPosition.sceneY) * easedProgress, + sceneZ: + characterPosition.sceneZ + + (targetCharacterPosition.sceneZ - characterPosition.sceneZ) * easedProgress, + isMiss: characterPosition.isMiss, + }; + }, [ + characterPosition, + isJumpAnimating, + jumpAnimationProgress, + jumpTargetPlatform, + visualJump, + ]); + const landingAssistStageSize = + stageSize.width > 0 && stageSize.height > 0 + ? stageSize + : { width: 320, height: 568 }; + const characterMotionStyle = useMemo(() => { + const idleTransform = 'matrix(1, 0, 0, 1, 0, 0)'; + const recoilDistance = Math.hypot(dragVector.x, dragVector.y); + const recoilUnitX = recoilDistance > 0 ? dragVector.x / recoilDistance : 0; + const recoilUnitY = recoilDistance > 0 ? dragVector.y / recoilDistance : 0; + let stretchTransform = idleTransform; + + if (isCharging && dragPointerPosition && characterPosition) { + const anchorX = + landingAssistStageSize.width * (characterPosition.screenX / 100); + const anchorY = + landingAssistStageSize.height * (characterPosition.screenY / 100); + stretchTransform = buildJumpHopDirectionalScaleMatrix({ + directionX: dragPointerPosition.x - anchorX, + directionY: dragPointerPosition.y - anchorY, + stretchScale: 1 + chargeRatio * 0.62, + crossScale: 1 - chargeRatio * 0.16, + }); + } + + return { + stretchTransform, + flightFromX: visualJump + ? `${formatJumpHopCssNumber( + ((visualJump.from.screenX - visualJump.to.screenX) / 100) * + landingAssistStageSize.width, + )}px` + : '0px', + flightFromY: visualJump + ? `${formatJumpHopCssNumber( + ((visualJump.from.screenY - visualJump.to.screenY) / 100) * + landingAssistStageSize.height, + )}px` + : '0px', + recoilX: `${formatJumpHopCssNumber(recoilUnitX * 11)}px`, + recoilY: `${formatJumpHopCssNumber(recoilUnitY * 11)}px`, + }; + }, [ + chargeRatio, + characterPosition, + dragPointerPosition, + dragVector.x, + dragVector.y, + isCharging, + landingAssistStageSize.height, + landingAssistStageSize.width, + visualJump, + ]); + const landingAssistPosition = showLandingAssist + ? getJumpHopLandingAssistVisualPosition( + stageRun, + visiblePlatforms, + visualCharacterPosition, + landingAssistStageSize, + dragDistance, + dragVector.x, + dragVector.y, + ) + : null; + const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun); + const isSettled = + stageRun?.status === 'failed' || stageRun?.status === 'cleared'; + const successfulJumpCount = stageRun?.successfulJumpCount ?? 0; + const durationLabel = formatJumpHopDurationLabel( + getJumpHopRunDurationMs(stageRun, nowMs), + ); + + useEffect(() => { + visiblePlatformsRef.current = visiblePlatforms; + }, [visiblePlatforms]); + + useEffect(() => { + tileAssetsRef.current = profile?.tileAssets; + }, [profile?.tileAssets]); + + const clearPlatformAdvanceState = useCallback(() => { + if (platformAdvanceEndTimerRef.current != null) { + window.clearTimeout(platformAdvanceEndTimerRef.current); + platformAdvanceEndTimerRef.current = null; + } + setIsPlatformAdvancing(false); + setPlatformAdvanceExitingPlatforms([]); + setPlatformAdvanceCameraOffsetX(0); + setPlatformAdvanceCameraOffsetY(0); + }, []); + + const clearLandingRecoilState = useCallback(() => { + if (landingRecoilEndTimerRef.current != null) { + window.clearTimeout(landingRecoilEndTimerRef.current); + landingRecoilEndTimerRef.current = null; + } + setIsLandingRecoilAnimating(false); + }, []); + + const beginPlatformAdvance = useCallback( + ( + fromRun: JumpHopRuntimeRunSnapshotResponse, + toRun: JumpHopRuntimeRunSnapshotResponse, + ) => { + if (!shouldAnimateJumpHopPlatformAdvance(fromRun, toRun)) { + clearPlatformAdvanceState(); + return; + } + + const fromVisiblePlatforms = visiblePlatformsRef.current; + const toVisiblePlatforms = buildJumpHopVisiblePlatforms( + toRun.path, + toRun.currentPlatformIndex, + tileAssetsRef.current, + ); + const toPlatformIds = new Set( + toVisiblePlatforms.map((item) => item.platform.platformId), + ); + const fromLandingPlatform = fromVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); + const toCurrentPlatform = toVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); + const cameraOffsetX = + (fromLandingPlatform?.screenX ?? toCurrentPlatform?.screenX ?? 0) - + (toCurrentPlatform?.screenX ?? fromLandingPlatform?.screenX ?? 0); + const cameraOffsetY = Math.max( + 0, + (toCurrentPlatform?.screenY ?? 0) - + (fromLandingPlatform?.screenY ?? 0), + ); + + setPlatformAdvanceExitingPlatforms( + fromVisiblePlatforms + .filter((item) => !toPlatformIds.has(item.platform.platformId)) + .map((item) => ({ + ...item, + screenX: item.screenX - cameraOffsetX, + screenY: item.screenY + cameraOffsetY, + })), + ); + setPlatformAdvanceCameraOffsetX(cameraOffsetX); + setPlatformAdvanceCameraOffsetY(cameraOffsetY); + setIsPlatformAdvancing(true); + + if (platformAdvanceEndTimerRef.current != null) { + window.clearTimeout(platformAdvanceEndTimerRef.current); + } + platformAdvanceEndTimerRef.current = window.setTimeout(() => { + platformAdvanceEndTimerRef.current = null; + setIsPlatformAdvancing(false); + setPlatformAdvanceExitingPlatforms([]); + setPlatformAdvanceCameraOffsetX(0); + setPlatformAdvanceCameraOffsetY(0); + }, JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS); + }, + [clearPlatformAdvanceState], + ); + + const finishJumpHopFlightAnimation = useCallback( + ( + fromRun: JumpHopRuntimeRunSnapshotResponse, + toRun: JumpHopRuntimeRunSnapshotResponse, + ) => { + if ( + fromRun.runId === toRun.runId && + hasJumpHopRunDisplayChange(fromRun, toRun) + ) { + beginPlatformAdvance(fromRun, toRun); + setDisplayRun(toRun); + } + + const shouldPlayLandingRecoil = + toRun.lastJump && toRun.lastJump.result !== 'miss'; + if (shouldPlayLandingRecoil) { + if (landingRecoilEndTimerRef.current != null) { + window.clearTimeout(landingRecoilEndTimerRef.current); + } + setIsLandingRecoilAnimating(true); + landingRecoilEndTimerRef.current = window.setTimeout(() => { + landingRecoilEndTimerRef.current = null; + setIsLandingRecoilAnimating(false); + }, JUMP_HOP_LANDING_RECOIL_DURATION_MS); + } else { + clearLandingRecoilState(); + } + + setIsJumpAnimating(false); + setJumpAnimationProgress(0); + setVisualJump(null); + hasJumpAnimationReachedTargetRef.current = false; + setNowMs(Date.now()); + }, + [beginPlatformAdvance, clearLandingRecoilState], + ); + + useEffect(() => { + if (stageRun?.status !== 'playing') { return undefined; } const timer = window.setInterval(() => { - if (chargeStartRef.current == null) { - return; - } - setChargeMs(clamp(Date.now() - chargeStartRef.current, 0, maxChargeMs)); - }, 16); + setNowMs(Date.now()); + }, 250); return () => window.clearInterval(timer); - }, [isCharging, maxChargeMs]); + }, [stageRun?.runId, stageRun?.status]); useEffect(() => { - setIsCharging(false); - chargeStartRef.current = null; - setChargeMs(0); - }, [activeRun?.runId, activeRun?.currentPlatformIndex, activeRun?.status]); + const stage = stageRef.current; + if (!stage) { + return undefined; + } + + const updateStageSize = () => { + const rect = stage.getBoundingClientRect(); + setStageSize({ + width: rect.width, + height: rect.height, + }); + }; + + updateStageSize(); + const resizeObserver = window.ResizeObserver + ? new window.ResizeObserver(updateStageSize) + : null; + resizeObserver?.observe(stage); + + return () => { + resizeObserver?.disconnect(); + }; + }, []); + + useEffect(() => { + if (!activeRun) { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + animationEndTimerRef.current = null; + } + clearPlatformAdvanceState(); + clearLandingRecoilState(); + hasJumpAnimationReachedTargetRef.current = false; + setDisplayRun(null); + setVisualJump(null); + setIsJumpAnimating(false); + setJumpAnimationProgress(0); + setIsCharging(false); + dragStartRef.current = null; + dragCurrentRef.current = null; + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); + setDragPointerPosition(null); + setNowMs(Date.now()); + return; + } + + if (!displayRun || displayRun.runId !== activeRun.runId) { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + animationEndTimerRef.current = null; + } + clearPlatformAdvanceState(); + clearLandingRecoilState(); + hasJumpAnimationReachedTargetRef.current = false; + setDisplayRun(activeRun); + setVisualJump(null); + setIsJumpAnimating(false); + setJumpAnimationProgress(0); + setIsCharging(false); + dragStartRef.current = null; + dragCurrentRef.current = null; + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); + setDragPointerPosition(null); + setNowMs(Date.now()); + return; + } + + if (isJumpAnimating) { + if ( + (jumpAnimationProgress >= 1 || + hasJumpAnimationReachedTargetRef.current) && + displayRun && + displayRun.runId === activeRun.runId && + hasJumpHopRunDisplayChange(displayRun, activeRun) + ) { + finishJumpHopFlightAnimation(displayRun, activeRun); + } + return; + } + + if (hasJumpHopRunDisplayChange(displayRun, activeRun)) { + clearPlatformAdvanceState(); + clearLandingRecoilState(); + setDisplayRun(activeRun); + } + }, [ + activeRun, + clearLandingRecoilState, + clearPlatformAdvanceState, + displayRun, + finishJumpHopFlightAnimation, + isJumpAnimating, + jumpAnimationProgress, + ]); + + useEffect(() => { + return () => { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + } + if (platformAdvanceEndTimerRef.current != null) { + window.clearTimeout(platformAdvanceEndTimerRef.current); + } + if (landingRecoilEndTimerRef.current != null) { + window.clearTimeout(landingRecoilEndTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isJumpAnimating) { + hasJumpAnimationReachedTargetRef.current = false; + setJumpAnimationProgress(0); + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + animationEndTimerRef.current = null; + } + return undefined; + } + + animationStartAtRef.current = window.performance.now(); + hasJumpAnimationReachedTargetRef.current = false; + animationEndTimerRef.current = window.setTimeout(() => { + animationEndTimerRef.current = null; + hasJumpAnimationReachedTargetRef.current = true; + setJumpAnimationProgress(1); + const latestDisplayRun = displayRunRef.current; + const latestActiveRun = activeRunRef.current; + if ( + latestDisplayRun && + latestActiveRun && + latestDisplayRun.runId === latestActiveRun.runId && + hasJumpHopRunDisplayChange(latestDisplayRun, latestActiveRun) + ) { + finishJumpHopFlightAnimation(latestDisplayRun, latestActiveRun); + } + }, JUMP_HOP_ANIMATION_DURATION_MS); + const tick = (now: number) => { + if (hasJumpAnimationReachedTargetRef.current) { + animationFrameRef.current = null; + return; + } + const elapsed = now - animationStartAtRef.current; + const progress = clamp( + elapsed / JUMP_HOP_ANIMATION_DURATION_MS, + 0, + 1, + ); + setJumpAnimationProgress(progress); + if (progress < 1) { + animationFrameRef.current = window.requestAnimationFrame(tick); + } else { + animationFrameRef.current = null; + } + }; + + animationFrameRef.current = window.requestAnimationFrame(tick); + + return () => { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + animationEndTimerRef.current = null; + } + }; + }, [finishJumpHopFlightAnimation, isJumpAnimating]); + + const getStageLocalPoint = (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + }; + + const updateDragState = (x: number, y: number) => { + const dragStart = dragStartRef.current; + dragCurrentRef.current = { x, y }; + setDragPointerPosition({ x, y }); + if (!dragStart) { + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); + return; + } + setDragVector({ + x: x - dragStart.x, + y: y - dragStart.y, + }); + setDragDistance(Math.hypot(x - dragStart.x, y - dragStart.y)); + }; const beginCharge = (event: PointerEvent) => { if (!canJump) { return; } event.currentTarget.setPointerCapture?.(event.pointerId); - chargeStartRef.current = Date.now(); + const dragPoint = getStageLocalPoint(event); + dragStartRef.current = dragPoint; + dragCurrentRef.current = dragPoint; + setDragPointerPosition(dragPoint); setIsCharging(true); - setChargeMs(0); + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); }; - const finishCharge = async () => { + const updateDragVector = (event: PointerEvent) => { if (!isCharging) { return; } + const dragPoint = getStageLocalPoint(event); + updateDragState(dragPoint.x, dragPoint.y); + }; - const nextChargeMs = clamp( - chargeStartRef.current ? Date.now() - chargeStartRef.current : chargeMs, - 0, - maxChargeMs, + const finishCharge = async (event?: PointerEvent) => { + if (!isCharging) { + return; + } + if (event) { + const dragPoint = getStageLocalPoint(event); + updateDragState(dragPoint.x, dragPoint.y); + } + + const dragStart = dragStartRef.current; + const dragCurrent = dragCurrentRef.current ?? dragStart; + const dragVectorX = + dragStart && dragCurrent ? dragCurrent.x - dragStart.x : 0; + const dragVectorY = + dragStart && dragCurrent ? dragCurrent.y - dragStart.y : 0; + const nextDragDistance = Math.hypot(dragVectorX, dragVectorY); + const backendDragVector = getJumpHopBackendDragVector( + activeRun, + visiblePlatforms, + landingAssistStageSize, + dragVectorX, + dragVectorY, ); - chargeStartRef.current = null; + const predictedLandingPosition = + activeRun && characterPosition + ? getJumpHopLandingAssistVisualPosition( + activeRun, + visiblePlatforms, + characterPosition, + landingAssistStageSize, + nextDragDistance, + dragVectorX, + dragVectorY, + ) + : null; + const fallbackLandingPosition = jumpTargetPlatform + ? buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform) + : characterPosition; + if (characterPosition && (predictedLandingPosition || fallbackLandingPosition)) { + setVisualJump({ + from: characterPosition, + to: predictedLandingPosition + ? { + ...characterPosition, + screenX: predictedLandingPosition.screenX, + screenY: predictedLandingPosition.screenY, + isMiss: false, + } + : fallbackLandingPosition!, + }); + } else { + setVisualJump(null); + } + dragStartRef.current = null; + dragCurrentRef.current = null; + clearLandingRecoilState(); setIsCharging(false); - setChargeMs(nextChargeMs); - await onJump({ chargeMs: nextChargeMs }); + setJumpAnimationProgress(0); + hasJumpAnimationReachedTargetRef.current = false; + setIsJumpAnimating(true); + setDragDistance(nextDragDistance); + setDragVector({ + x: dragVectorX, + y: dragVectorY, + }); + setDragPointerPosition(null); + await onJump({ + dragDistance: nextDragDistance, + dragVectorX: backendDragVector.dragVectorX, + dragVectorY: backendDragVector.dragVectorY, + }); }; const cancelCharge = () => { - chargeStartRef.current = null; + dragStartRef.current = null; + dragCurrentRef.current = null; + clearLandingRecoilState(); + hasJumpAnimationReachedTargetRef.current = false; + setVisualJump(null); setIsCharging(false); - setChargeMs(0); + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); + setDragPointerPosition(null); }; return ( @@ -304,9 +1229,9 @@ export function JumpHopRuntimeShell({ 返回
- {activeRun?.score ?? 0} + {successfulJumpCount} - {activeRun?.combo ?? 0}x + {durationLabel}
- +