feat(jump-hop): redesign sling platform gameplay
This commit is contained in:
@@ -1028,13 +1028,38 @@
|
|||||||
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。
|
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 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`。
|
- 关联文档:`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 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型。
|
- 背景:旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。
|
||||||
- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`。
|
- 决策:`jump-hop` v1 创作端只保留主题输入;image2 生成一张 `5x5`、共 25 个 2D 地块图标的图集,后端按均匀网格切出 25 个 `JumpHopTileAsset`。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按住后拉蓄力、松手反向弹出,前端提交 `chargeMs + dragVectorX + dragVectorY`,后端裁决落点。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
|
||||||
- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。
|
||||||
- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径。
|
- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。
|
||||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
- 验证方式:`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 陶泥儿主视觉配色回收为暖白/陶土橙
|
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
|
||||||
|
|
||||||
|
|||||||
@@ -1564,14 +1564,45 @@
|
|||||||
- 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。
|
- 验证:`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`。
|
- 关联:`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 预览路径,地块切片没有真正落盘。
|
- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。
|
||||||
- 原因:跳一跳地块只有 6 个固定 tileType,但旧实现把它塞进通用系列素材 helper,并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`。
|
- 原因:旧模板先后尝试过通用系列素材 helper 和 `2x3` 六格固定 tileType,但当前跳一跳已经重设计为“主题 -> 5x5 地块图集 -> 25 个等权地块池 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。
|
||||||
- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt,按 `start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind。
|
- 处理:跳一跳地块固定生成一张 `5x5` 主题图集,后端按均匀网格切出 25 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列`、`start / normal / target / finish / bonus / accent` 六格口径。
|
||||||
- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`。
|
- 验证:`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`。
|
- 关联:`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/<profile>/<slot>/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
|
## image2 dry-run 带参考图时不要直接打印 data URL
|
||||||
|
|
||||||
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
|
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
|
||||||
@@ -1650,6 +1681,22 @@
|
|||||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。
|
- 验证:`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`。
|
- 关联:`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 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
|
- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
|
||||||
@@ -1665,3 +1712,19 @@
|
|||||||
- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。
|
- 处理:推荐页拖拽只校验当前是否有作品、多作品可切换以及是否正在提交动画,不再要求登录;登录态相关操作仍由点赞、改造等按钮自身权限控制。
|
||||||
- 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。
|
- 验证:`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`。
|
- 关联:`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`。
|
||||||
|
|||||||
@@ -2,491 +2,193 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
新增一个可创作、可试玩、可发布的玩法模板:
|
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `5x5` 地块资源图集,切成 25 个 2D 地块素材;运行态使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色,并和这些 2D 地块资产组成无限平台流。
|
||||||
|
|
||||||
```text
|
首版目标:
|
||||||
跳一跳
|
|
||||||
```
|
|
||||||
|
|
||||||
本模板参考拼图模板的创作闭环,沿用“创作入口 -> 生成过程页 -> 结果页 -> 试玩 -> 发布”的平台链路,但玩法本体改为俯视角 / 等距视角的跳跃闯关。
|
1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生;
|
||||||
|
2. image2 只生成一张 `5x5` 地块图集,后端均匀切成 25 张 PNG;
|
||||||
首版要求:
|
3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG;
|
||||||
|
4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块;
|
||||||
1. 初始草稿生成时,角色形象单独调用一次生图;
|
5. 操作方式为按住屏幕向后拖动蓄力,松手后角色向拖拽反方向弹出;
|
||||||
2. 初始草稿生成时,地块只调用一次生图,输出 3D 视图的 2D 图片图集;
|
6. 只要落点未命中下一个地块,本局立即失败并冻结计时;
|
||||||
3. 运行态不接真实 3D 网格,不生成 GLB / glTF;
|
7. 成绩记录成功跳跃次数和游戏时长;
|
||||||
4. 作品可以直接进入试玩和发布。
|
8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
|
||||||
|
|
||||||
## 2. 模板定位
|
## 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
|
```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
|
```text
|
||||||
跳一跳
|
successfulJumpCount desc -> durationMs asc -> updatedAt asc
|
||||||
```
|
```
|
||||||
|
|
||||||
体验关键词:
|
展示字段:
|
||||||
|
|
||||||
1. 俯视角;
|
1. rank;
|
||||||
2. 等距感地块;
|
2. playerId;
|
||||||
3. 单局闯关;
|
3. successfulJumpCount;
|
||||||
4. 长按蓄力,松手起跳;
|
4. durationMs;
|
||||||
5. 轻量休闲。
|
5. updatedAt。
|
||||||
|
|
||||||
首版采用竖屏优先的移动端体验,桌面端保持居中展示,画面比例以 `9:16` 为主。参考图的核心视觉要点是:
|
草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。
|
||||||
|
|
||||||
1. 大面积留白或浅色渐变背景;
|
## 8. 结果页
|
||||||
2. 角色站在单个地块上;
|
|
||||||
3. 地块有明显顶面、侧面和投影;
|
结果页展示:
|
||||||
4. 整体是俯视角 / 等距视角,而不是横版平台跳跃;
|
|
||||||
5. UI 克制,只保留必要控制,不堆说明文案。
|
1. 陶泥儿 logo 透明角色预览;
|
||||||
|
2. 25 个地块资源池预览;
|
||||||
## 3. 与拼图模板的复用边界
|
3. 首屏 3 块平台预览;
|
||||||
|
4. 试玩;
|
||||||
可以复用:
|
5. 发布;
|
||||||
|
6. 返回编辑;
|
||||||
1. 创作入口和模板分流;
|
7. 重生成地块。
|
||||||
2. 生成过程页;
|
|
||||||
3. 结果页的草稿保存、返回编辑、试玩、发布、分享链路;
|
结果页不再展示角色图片生成槽位,也不提供独立角色重生成。
|
||||||
4. 作品架展示和草稿恢复口径;
|
|
||||||
5. 平台统一的发布与公开展示流程。
|
## 9. 契约要点
|
||||||
|
|
||||||
不复用:
|
公开语义保留:
|
||||||
|
|
||||||
1. 拼图关卡切片逻辑;
|
1. `themeText`;
|
||||||
2. 拼图拖拽拼块逻辑;
|
2. `tileAtlasAsset`;
|
||||||
3. 拼图 UI 背景和多关卡编辑结构;
|
3. `tileAssets[]`;
|
||||||
4. 任何方格拼合语义。
|
4. `defaultCharacter`;
|
||||||
|
5. `path.platforms[]` 作为服务端路径缓冲;
|
||||||
## 4. 工程接入范围
|
6. `currentPlatformIndex`;
|
||||||
|
7. `successfulJumpCount`;
|
||||||
首版需要做到完整玩法闭环,不只做入口占位。
|
8. `startedAtMs` / `finishedAtMs` / `durationMs`;
|
||||||
|
9. `leaderboard`。
|
||||||
新增前端阶段:
|
|
||||||
|
旧语义处理:
|
||||||
```text
|
|
||||||
jump-hop-workspace
|
1. `characterAsset` 仅作为角色描述兼容字段,不再表示生成图片;前端固定使用陶泥儿 logo 透明 PNG;
|
||||||
jump-hop-generating
|
2. `score` 兼容映射为成功跳跃次数;
|
||||||
jump-hop-result
|
3. `combo` 固定为 0,不作为公开玩法语义;
|
||||||
jump-hop-runtime
|
4. `cleared` 状态不再由 v1 产生;
|
||||||
jump-hop-gallery-detail
|
5. 旧 finite path 只作为服务端路径缓冲兼容形态。
|
||||||
```
|
|
||||||
|
## 10. 验收
|
||||||
新增前端组件建议:
|
|
||||||
|
1. 创作页只显示主题输入;
|
||||||
1. `src/components/jump-hop-creation/JumpHopWorkspace.tsx`;
|
2. 生成链路只调用一次地块图集 image2,不再调用角色生图;
|
||||||
2. `src/components/jump-hop-result/JumpHopResultView.tsx`;
|
3. 地块图集为 `5x5`,后端切出 25 个地块 PNG;
|
||||||
3. `src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`;
|
4. 结果页不依赖旧角色图片槽;
|
||||||
4. `src/services/jump-hop/jumpHopClient.ts`。
|
5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
|
||||||
|
6. 拖拽方向和力度会影响落点;
|
||||||
新增共享契约建议:
|
7. 未落到下一个地块立即失败;
|
||||||
|
8. 成功跳跃次数累加,失败后计时冻结;
|
||||||
1. `packages/shared/src/contracts/jumpHop.ts`;
|
9. 排行榜按成功跳跃次数优先排序;
|
||||||
2. `server-rs/crates/shared-contracts/src/jump_hop.rs`。
|
10. 作品可保存、发布、分享并从公开入口启动。
|
||||||
|
11. 运行态地块必须显示 `tileAssets[]` 中的生成切片图片;拖拽蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台图片层或 DOM 角色层。
|
||||||
新增后端模块建议:
|
12. 同等跳跃距离的拖动距离必须比旧 `0.004` 系数缩短一半,松手后必须先看到角色飞行动画,再看到地块窗口前移。
|
||||||
|
|
||||||
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` 通过。
|
|
||||||
|
|||||||
@@ -404,6 +404,12 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`JumpHopEventRow`
|
- Rust 结构体:`JumpHopEventRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
|
- 源码:`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`
|
### `jump_hop_runtime_run`
|
||||||
|
|
||||||
- Rust 结构体:`JumpHopRuntimeRunRow`
|
- Rust 结构体:`JumpHopRuntimeRunRow`
|
||||||
|
|||||||
@@ -122,23 +122,31 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
||||||
|
|
||||||
首版定位为俯视角 / 等距视角 2D 休闲跳跃模板,链路对齐拼图的创作闭环:
|
当前定位为竖屏俯视角 2D 平台跳跃模板,链路对齐平台创作闭环:
|
||||||
|
|
||||||
```text
|
```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. 初始草稿生成时,角色形象单独调用一次生图;
|
1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生;
|
||||||
2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集;
|
2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色;
|
||||||
3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG;
|
3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、纯绿色绿幕背景的主题地块图集;
|
||||||
4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图;
|
4. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位;
|
||||||
5. 显式重生成角色或地块时,只重生成对应资产槽位。
|
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。
|
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export type JumpHopTileType =
|
|||||||
|
|
||||||
export type JumpHopActionType =
|
export type JumpHopActionType =
|
||||||
| 'compile-draft'
|
| 'compile-draft'
|
||||||
| 'regenerate-character'
|
|
||||||
| 'regenerate-tiles'
|
| 'regenerate-tiles'
|
||||||
| 'update-work-meta'
|
| 'update-work-meta'
|
||||||
| 'update-difficulty';
|
| 'update-difficulty';
|
||||||
@@ -35,19 +34,21 @@ export type JumpHopJumpResult = 'miss' | 'hit' | 'perfect' | 'finish';
|
|||||||
|
|
||||||
export interface JumpHopWorkspaceCreateRequest {
|
export interface JumpHopWorkspaceCreateRequest {
|
||||||
templateId: string;
|
templateId: string;
|
||||||
workTitle: string;
|
themeText: string;
|
||||||
workDescription: string;
|
workTitle?: string;
|
||||||
themeTags: string[];
|
workDescription?: string;
|
||||||
difficulty: JumpHopDifficulty;
|
themeTags?: string[];
|
||||||
stylePreset: JumpHopStylePreset;
|
difficulty?: JumpHopDifficulty;
|
||||||
characterPrompt: string;
|
stylePreset?: JumpHopStylePreset;
|
||||||
tilePrompt: string;
|
characterPrompt?: string;
|
||||||
|
tilePrompt?: string;
|
||||||
endMoodPrompt?: string | null;
|
endMoodPrompt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopActionRequest {
|
export interface JumpHopActionRequest {
|
||||||
actionType: JumpHopActionType;
|
actionType: JumpHopActionType;
|
||||||
profileId?: string | null;
|
profileId?: string | null;
|
||||||
|
themeText?: string | null;
|
||||||
workTitle?: string | null;
|
workTitle?: string | null;
|
||||||
workDescription?: string | null;
|
workDescription?: string | null;
|
||||||
themeTags?: string[] | null;
|
themeTags?: string[] | null;
|
||||||
@@ -73,12 +74,23 @@ export interface JumpHopCharacterAsset {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JumpHopDefaultCharacter {
|
||||||
|
characterId: string;
|
||||||
|
displayName: string;
|
||||||
|
modelKind: 'builtin-three';
|
||||||
|
bodyColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JumpHopTileAsset {
|
export interface JumpHopTileAsset {
|
||||||
tileType: JumpHopTileType;
|
tileType: JumpHopTileType;
|
||||||
|
tileId?: string;
|
||||||
imageSrc: string;
|
imageSrc: string;
|
||||||
imageObjectKey: string;
|
imageObjectKey: string;
|
||||||
assetObjectId: string;
|
assetObjectId: string;
|
||||||
sourceAtlasCell: string;
|
sourceAtlasCell: string;
|
||||||
|
atlasRow?: number;
|
||||||
|
atlasCol?: number;
|
||||||
visualWidth: number;
|
visualWidth: number;
|
||||||
visualHeight: number;
|
visualHeight: number;
|
||||||
topSurfaceRadius: number;
|
topSurfaceRadius: number;
|
||||||
@@ -126,11 +138,13 @@ export interface JumpHopDraftResponse {
|
|||||||
templateId: string;
|
templateId: string;
|
||||||
templateName: string;
|
templateName: string;
|
||||||
profileId: string | null;
|
profileId: string | null;
|
||||||
|
themeText: string;
|
||||||
workTitle: string;
|
workTitle: string;
|
||||||
workDescription: string;
|
workDescription: string;
|
||||||
themeTags: string[];
|
themeTags: string[];
|
||||||
difficulty: JumpHopDifficulty;
|
difficulty: JumpHopDifficulty;
|
||||||
stylePreset: JumpHopStylePreset;
|
stylePreset: JumpHopStylePreset;
|
||||||
|
defaultCharacter?: JumpHopDefaultCharacter | null;
|
||||||
characterPrompt: string;
|
characterPrompt: string;
|
||||||
tilePrompt: string;
|
tilePrompt: string;
|
||||||
endMoodPrompt: string | null;
|
endMoodPrompt: string | null;
|
||||||
@@ -167,6 +181,7 @@ export interface JumpHopWorkSummaryResponse {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
sourceSessionId: string | null;
|
sourceSessionId: string | null;
|
||||||
|
themeText: string;
|
||||||
workTitle: string;
|
workTitle: string;
|
||||||
workDescription: string;
|
workDescription: string;
|
||||||
themeTags: string[];
|
themeTags: string[];
|
||||||
@@ -185,6 +200,7 @@ export interface JumpHopWorkProfileResponse {
|
|||||||
summary: JumpHopWorkSummaryResponse;
|
summary: JumpHopWorkSummaryResponse;
|
||||||
draft: JumpHopDraftResponse;
|
draft: JumpHopDraftResponse;
|
||||||
path: JumpHopPath;
|
path: JumpHopPath;
|
||||||
|
defaultCharacter?: JumpHopDefaultCharacter | null;
|
||||||
characterAsset: JumpHopCharacterAsset;
|
characterAsset: JumpHopCharacterAsset;
|
||||||
tileAtlasAsset: JumpHopCharacterAsset;
|
tileAtlasAsset: JumpHopCharacterAsset;
|
||||||
tileAssets: JumpHopTileAsset[];
|
tileAssets: JumpHopTileAsset[];
|
||||||
@@ -208,6 +224,7 @@ export interface JumpHopGalleryCardResponse {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
authorDisplayName: string;
|
authorDisplayName: string;
|
||||||
|
themeText: string;
|
||||||
workTitle: string;
|
workTitle: string;
|
||||||
workDescription: string;
|
workDescription: string;
|
||||||
coverImageSrc: string | null;
|
coverImageSrc: string | null;
|
||||||
@@ -237,6 +254,8 @@ export interface JumpHopRuntimeRunSnapshotResponse {
|
|||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
status: JumpHopRunStatus;
|
status: JumpHopRunStatus;
|
||||||
currentPlatformIndex: number;
|
currentPlatformIndex: number;
|
||||||
|
successfulJumpCount: number;
|
||||||
|
durationMs: number;
|
||||||
score: number;
|
score: number;
|
||||||
combo: number;
|
combo: number;
|
||||||
path: JumpHopPath;
|
path: JumpHopPath;
|
||||||
@@ -251,10 +270,13 @@ export interface JumpHopRunResponse {
|
|||||||
|
|
||||||
export interface JumpHopStartRunRequest {
|
export interface JumpHopStartRunRequest {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
|
runtimeMode?: 'draft' | 'published';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopJumpRequest {
|
export interface JumpHopJumpRequest {
|
||||||
chargeMs: number;
|
dragDistance: number;
|
||||||
|
dragVectorX?: number;
|
||||||
|
dragVectorY?: number;
|
||||||
clientEventId: string;
|
clientEventId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,3 +287,17 @@ export interface JumpHopRestartRunRequest {
|
|||||||
export interface JumpHopJumpResponse {
|
export interface JumpHopJumpResponse {
|
||||||
run: JumpHopRuntimeRunSnapshotResponse;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/branding/jump-hop-taonier-character.png
Normal file
BIN
public/branding/jump-hop-taonier-character.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
BIN
public/creation-type-references/jump-hop.webp
Normal file
BIN
public/creation-type-references/jump-hop.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -94,13 +94,11 @@ pub async fn generate_character_visual(
|
|||||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||||
|
|
||||||
let result = async {
|
let result = async {
|
||||||
let settings = require_openai_image_settings(&state)?
|
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
&request_context,
|
||||||
&request_context,
|
Some(owner_user_id.clone()),
|
||||||
Some(owner_user_id.clone()),
|
Some(character_id.clone()),
|
||||||
Some(character_id.clone()),
|
);
|
||||||
)
|
|
||||||
;
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
|
|
||||||
state
|
state
|
||||||
@@ -324,10 +322,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
|
|||||||
&model,
|
&model,
|
||||||
&prompt,
|
&prompt,
|
||||||
)?;
|
)?;
|
||||||
let settings = require_openai_image_settings(state)?.with_external_api_audit_actor(
|
let settings = require_openai_image_settings(state)?
|
||||||
Some(owner_user_id.to_string()),
|
.with_external_api_audit_actor(Some(owner_user_id.to_string()), Some(character_id.clone()));
|
||||||
Some(character_id.clone()),
|
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
state
|
state
|
||||||
.ai_task_service()
|
.ai_task_service()
|
||||||
|
|||||||
@@ -255,6 +255,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_creation_entry_config_response_updates_jump_hop_metadata() {
|
||||||
|
let config = test_creation_entry_config_response();
|
||||||
|
let jump_hop = config
|
||||||
|
.creation_types
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.id == "jump-hop")
|
||||||
|
.expect("test creation entry config should include jump-hop");
|
||||||
|
|
||||||
|
assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}");
|
||||||
|
assert!(jump_hop.visible);
|
||||||
|
assert!(jump_hop.open);
|
||||||
|
assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}");
|
||||||
|
assert_eq!(
|
||||||
|
jump_hop.subtitle,
|
||||||
|
"\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
jump_hop.image_src,
|
||||||
|
"/creation-type-references/jump-hop.webp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
|
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
|
||||||
let config = test_creation_entry_config_response();
|
let config = test_creation_entry_config_response();
|
||||||
|
|||||||
@@ -553,12 +553,11 @@ pub async fn generate_custom_world_scene_image(
|
|||||||
"scene_image",
|
"scene_image",
|
||||||
asset_id.as_str(),
|
asset_id.as_str(),
|
||||||
async {
|
async {
|
||||||
let settings = require_openai_image_settings(&state)?
|
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
&request_context,
|
||||||
&request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
normalized.profile_id.clone(),
|
||||||
normalized.profile_id.clone(),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let reference_image =
|
let reference_image =
|
||||||
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ use serde_json::{Value, json};
|
|||||||
use shared_contracts::jump_hop::{
|
use shared_contracts::jump_hop::{
|
||||||
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
||||||
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
||||||
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
|
JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse,
|
||||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType,
|
JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
|
||||||
JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse,
|
JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||||
JumpHopWorkspaceCreateRequest,
|
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
|
||||||
};
|
};
|
||||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||||
use spacetime_client::SpacetimeClientError;
|
use spacetime_client::SpacetimeClientError;
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::{BTreeMap, VecDeque},
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,8 +46,7 @@ use crate::{
|
|||||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||||
};
|
};
|
||||||
|
|
||||||
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] =
|
const JUMP_HOP_TILE_ITEM_COUNT: usize = 25;
|
||||||
["start", "normal", "target", "finish", "bonus", "accent"];
|
|
||||||
|
|
||||||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||||||
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
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_ID: &str = "jump-hop";
|
||||||
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
||||||
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
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_ROWS: u32 = 5;
|
||||||
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
|
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
struct JumpHopTileAtlasSlice {
|
struct JumpHopTileAtlasSlice {
|
||||||
@@ -239,6 +238,35 @@ pub async fn get_jump_hop_runtime_work(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_jump_hop_leaderboard(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(profile_id): Path<String>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(principal): Extension<RuntimePrincipal>,
|
||||||
|
) -> Result<Json<Value>, 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(
|
pub async fn start_jump_hop_run(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
@@ -247,6 +275,7 @@ pub async fn start_jump_hop_run(
|
|||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||||||
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
|
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 owner_user_id = principal.subject().to_string();
|
||||||
let principal_kind = principal.kind().as_str();
|
let principal_kind = principal.kind().as_str();
|
||||||
let run = state
|
let run = state
|
||||||
@@ -261,23 +290,25 @@ pub async fn start_jump_hop_run(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
record_work_play_start_after_success(
|
if !is_draft_runtime {
|
||||||
&state,
|
record_work_play_start_after_success(
|
||||||
&request_context,
|
&state,
|
||||||
build_jump_hop_work_play_tracking_draft(
|
&request_context,
|
||||||
&principal,
|
build_jump_hop_work_play_tracking_draft(
|
||||||
run.profile_id.clone(),
|
&principal,
|
||||||
JUMP_HOP_RUNTIME_RUNS_ROUTE,
|
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())
|
.await;
|
||||||
.run_id(run.run_id.clone())
|
}
|
||||||
.profile_id(run.profile_id.clone())
|
|
||||||
.extra(json!({
|
|
||||||
"runStatus": run.status,
|
|
||||||
"principalKind": principal_kind,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
@@ -391,15 +422,17 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
payload: &mut JumpHopActionRequest,
|
payload: &mut JumpHopActionRequest,
|
||||||
) -> Result<(), Response> {
|
) -> Result<(), Response> {
|
||||||
if !matches!(payload.action_type, JumpHopActionType::CompileDraft) {
|
if !matches!(
|
||||||
|
payload.action_type,
|
||||||
|
JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
|
||||||
|
) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if payload.character_asset.is_some()
|
if payload.tile_atlas_asset.is_some()
|
||||||
&& payload.tile_atlas_asset.is_some()
|
|
||||||
&& payload
|
&& payload
|
||||||
.tile_assets
|
.tile_assets
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|assets| !assets.is_empty())
|
.is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT)
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -414,12 +447,11 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
|
|
||||||
let settings = require_openai_image_settings(state)
|
let settings = require_openai_image_settings(state)
|
||||||
.map(|settings| {
|
.map(|settings| {
|
||||||
settings
|
settings.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.clone()),
|
||||||
Some(profile_id.clone()),
|
)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||||
@@ -428,58 +460,19 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let character_prompt = payload
|
let theme_text = payload
|
||||||
.character_prompt
|
.theme_text
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("俯视角可爱主角,透明背景");
|
.or(payload.work_title.as_deref())
|
||||||
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集");
|
.unwrap_or("跳一跳");
|
||||||
|
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or(theme_text);
|
||||||
|
|
||||||
let character_generated = create_openai_image_generation(
|
let sheet_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
|
||||||
&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 tile_generated = create_openai_image_generation(
|
let tile_generated = create_openai_image_generation(
|
||||||
&http_client,
|
&http_client,
|
||||||
&settings,
|
&settings,
|
||||||
sheet_prompt.as_str(),
|
sheet_prompt.as_str(),
|
||||||
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
|
Some(build_jump_hop_tile_atlas_negative_prompt()),
|
||||||
"1024*1024",
|
"1024*1024",
|
||||||
1,
|
1,
|
||||||
&[],
|
&[],
|
||||||
@@ -527,7 +520,12 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
.await?,
|
.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_atlas_asset = Some(tile_atlas_asset);
|
||||||
payload.tile_assets = Some(tile_assets);
|
payload.tile_assets = Some(tile_assets);
|
||||||
payload.cover_composite = payload.cover_composite.clone().or_else(|| {
|
payload.cover_composite = payload.cover_composite.clone().or_else(|| {
|
||||||
@@ -538,28 +536,29 @@ async fn maybe_generate_jump_hop_assets(
|
|||||||
Ok(())
|
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 = tile_prompt.trim();
|
||||||
let subject_text = if subject_text.is_empty() {
|
let subject_text = if subject_text.is_empty() {
|
||||||
"等距立体地块图集"
|
theme_text
|
||||||
} else {
|
} else {
|
||||||
subject_text
|
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!(
|
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(
|
fn slice_jump_hop_tile_atlas(
|
||||||
image: &crate::openai_image_generation::DownloadedOpenAiImage,
|
image: &crate::openai_image_generation::DownloadedOpenAiImage,
|
||||||
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
||||||
@@ -583,8 +582,8 @@ fn slice_jump_hop_tile_atlas(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut slices = Vec::with_capacity(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_NAMES.len() {
|
for index in 0..JUMP_HOP_TILE_ITEM_COUNT {
|
||||||
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||||||
let col = 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;
|
let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||||||
@@ -598,6 +597,9 @@ fn slice_jump_hop_tile_atlas(
|
|||||||
y1.saturating_sub(y0).max(1),
|
y1.saturating_sub(y0).max(1),
|
||||||
);
|
);
|
||||||
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
|
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());
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
cleaned
|
cleaned
|
||||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||||
@@ -617,26 +619,116 @@ fn slice_jump_hop_tile_atlas(
|
|||||||
Ok(slices)
|
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::<usize>::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::<usize>::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 {
|
fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
|
||||||
match index {
|
match index {
|
||||||
0 => JumpHopTileType::Start,
|
0 => JumpHopTileType::Start,
|
||||||
1 => JumpHopTileType::Normal,
|
value if value % 11 == 0 => JumpHopTileType::Bonus,
|
||||||
2 => JumpHopTileType::Target,
|
value if value % 7 == 0 => JumpHopTileType::Accent,
|
||||||
3 => JumpHopTileType::Finish,
|
value if value % 3 == 0 => JumpHopTileType::Target,
|
||||||
4 => JumpHopTileType::Bonus,
|
_ => JumpHopTileType::Normal,
|
||||||
_ => JumpHopTileType::Accent,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str {
|
fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String {
|
||||||
match tile_type {
|
format!("tile-{:02}", tile_index + 1)
|
||||||
JumpHopTileType::Start => "tile-start",
|
|
||||||
JumpHopTileType::Normal => "tile-normal",
|
|
||||||
JumpHopTileType::Target => "tile-target",
|
|
||||||
JumpHopTileType::Finish => "tile-finish",
|
|
||||||
JumpHopTileType::Bonus => "tile-bonus",
|
|
||||||
JumpHopTileType::Accent => "tile-accent",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -648,7 +740,7 @@ async fn persist_jump_hop_tile_asset(
|
|||||||
tile_slice: JumpHopTileAtlasSlice,
|
tile_slice: JumpHopTileAtlasSlice,
|
||||||
request_context: &RequestContext,
|
request_context: &RequestContext,
|
||||||
) -> Result<JumpHopTileAsset, Response> {
|
) -> Result<JumpHopTileAsset, Response> {
|
||||||
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 {
|
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||||||
bytes: tile_slice.bytes,
|
bytes: tile_slice.bytes,
|
||||||
mime_type: "image/png".to_string(),
|
mime_type: "image/png".to_string(),
|
||||||
@@ -658,7 +750,7 @@ async fn persist_jump_hop_tile_asset(
|
|||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
slot,
|
slot.as_str(),
|
||||||
&format!(
|
&format!(
|
||||||
"跳一跳地块切片 {}:{}",
|
"跳一跳地块切片 {}:{}",
|
||||||
tile_index + 1,
|
tile_index + 1,
|
||||||
@@ -674,10 +766,13 @@ async fn persist_jump_hop_tile_asset(
|
|||||||
|
|
||||||
Ok(JumpHopTileAsset {
|
Ok(JumpHopTileAsset {
|
||||||
tile_type: tile_slice.tile_type,
|
tile_type: tile_slice.tile_type,
|
||||||
|
tile_id: Some(slot),
|
||||||
image_src: persisted.image_src,
|
image_src: persisted.image_src,
|
||||||
image_object_key: persisted.image_object_key,
|
image_object_key: persisted.image_object_key,
|
||||||
asset_object_id: persisted.asset_object_id,
|
asset_object_id: persisted.asset_object_id,
|
||||||
source_atlas_cell: tile_slice.source_atlas_cell,
|
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_width: 256,
|
||||||
visual_height: 192,
|
visual_height: 192,
|
||||||
top_surface_radius: 42.0,
|
top_surface_radius: 42.0,
|
||||||
@@ -685,6 +780,22 @@ async fn persist_jump_hop_tile_asset(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_jump_hop_default_character_asset(
|
||||||
|
profile_id: &str,
|
||||||
|
theme_text: &str,
|
||||||
|
) -> JumpHopCharacterAsset {
|
||||||
|
JumpHopCharacterAsset {
|
||||||
|
asset_id: format!("{profile_id}-builtin-character"),
|
||||||
|
image_src: "builtin://jump-hop/default-character".to_string(),
|
||||||
|
image_object_key: String::new(),
|
||||||
|
asset_object_id: format!("{profile_id}-builtin-character"),
|
||||||
|
generation_provider: "builtin-three".to_string(),
|
||||||
|
prompt: format!("内置默认 3D 角色:{}", theme_text.trim()),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn persist_jump_hop_generated_image_asset(
|
async fn persist_jump_hop_generated_image_asset(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
@@ -868,17 +979,26 @@ fn build_jump_hop_work_play_tracking_draft(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
|
||||||
|
let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title);
|
||||||
JumpHopDraftResponse {
|
JumpHopDraftResponse {
|
||||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||||
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||||
profile_id: None,
|
profile_id: None,
|
||||||
work_title: payload.work_title.trim().to_string(),
|
theme_text: theme_text.clone(),
|
||||||
work_description: payload.work_description.trim().to_string(),
|
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()),
|
theme_tags: normalize_tags(payload.theme_tags.clone()),
|
||||||
difficulty: payload.difficulty.clone(),
|
difficulty: payload.difficulty.clone(),
|
||||||
style_preset: payload.style_preset.clone(),
|
style_preset: payload.style_preset.clone(),
|
||||||
character_prompt: payload.character_prompt.trim().to_string(),
|
default_character: Some(default_jump_hop_character()),
|
||||||
tile_prompt: payload.tile_prompt.trim().to_string(),
|
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: payload
|
||||||
.end_mood_prompt
|
.end_mood_prompt
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -897,13 +1017,7 @@ fn validate_workspace_request(
|
|||||||
request_context: &RequestContext,
|
request_context: &RequestContext,
|
||||||
payload: &JumpHopWorkspaceCreateRequest,
|
payload: &JumpHopWorkspaceCreateRequest,
|
||||||
) -> Result<(), Response> {
|
) -> Result<(), Response> {
|
||||||
ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
|
ensure_non_empty(request_context, &payload.theme_text, "themeText")?;
|
||||||
ensure_non_empty(
|
|
||||||
request_context,
|
|
||||||
&payload.character_prompt,
|
|
||||||
"characterPrompt",
|
|
||||||
)?;
|
|
||||||
ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?;
|
|
||||||
if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID {
|
if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID {
|
||||||
return Err(jump_hop_error_response(
|
return Err(jump_hop_error_response(
|
||||||
request_context,
|
request_context,
|
||||||
@@ -917,6 +1031,32 @@ fn validate_workspace_request(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_theme_text(theme_text: &str, fallback: &str) -> String {
|
||||||
|
clean_or_default(theme_text, fallback)
|
||||||
|
.chars()
|
||||||
|
.take(60)
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
fn ensure_non_empty(
|
||||||
request_context: &RequestContext,
|
request_context: &RequestContext,
|
||||||
value: &str,
|
value: &str,
|
||||||
@@ -1020,32 +1160,82 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() {
|
fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
|
||||||
let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块");
|
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
|
||||||
|
|
||||||
assert!(prompt.contains("2行*3列"));
|
assert!(prompt.contains("五行五列"));
|
||||||
assert!(prompt.contains("第1行第1列:start 起点地块"));
|
assert!(prompt.contains("共25个"));
|
||||||
assert!(prompt.contains("第2行第3列:accent 视觉强调地块"));
|
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("每个物品生成"));
|
||||||
assert!(!prompt.contains("不同视图"));
|
assert!(!prompt.contains("不同视图"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jump_hop_tile_atlas_slices_one_png_per_tile_type() {
|
fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() {
|
||||||
let width = 300;
|
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
|
||||||
let height = 200;
|
|
||||||
let colors = [
|
assert!(negative_prompt.contains("油亮高光"));
|
||||||
[220, 24, 24, 255],
|
assert!(negative_prompt.contains("厚重CG渲染"));
|
||||||
[240, 150, 32, 255],
|
assert!(negative_prompt.contains("游戏界面"));
|
||||||
[248, 220, 72, 255],
|
assert!(negative_prompt.contains("图标集页面"));
|
||||||
[52, 168, 84, 255],
|
assert!(negative_prompt.contains("建筑"));
|
||||||
[38, 132, 255, 255],
|
assert!(negative_prompt.contains("方形阴影"));
|
||||||
[156, 92, 220, 255],
|
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);
|
let mut atlas = image::RgbaImage::new(width, height);
|
||||||
for row in 0..2 {
|
for row in 0..5 {
|
||||||
for col in 0..3 {
|
for col in 0..5 {
|
||||||
let color = image::Rgba(colors[row * 3 + col]);
|
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 y in row as u32 * 100..(row as u32 + 1) * 100 {
|
||||||
for x in col as u32 * 100..(col as u32 + 1) * 100 {
|
for x in col as u32 * 100..(col as u32 + 1) * 100 {
|
||||||
atlas.put_pixel(x, y, color);
|
atlas.put_pixel(x, y, color);
|
||||||
@@ -1065,20 +1255,48 @@ mod tests {
|
|||||||
|
|
||||||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
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() {
|
for (index, slice) in slices.iter().enumerate() {
|
||||||
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
|
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
slice.source_atlas_cell,
|
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())
|
let decoded = image::load_from_memory(slice.bytes.as_slice())
|
||||||
.expect("tile slice should decode")
|
.expect("tile slice should decode")
|
||||||
.to_rgba8();
|
.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!(
|
assert!(
|
||||||
decoded.pixels().any(|pixel| pixel.0 == colors[index]),
|
decoded.pixels().any(|pixel| pixel.0 == color),
|
||||||
"第 {index} 个地块切片应保留对应格子的主体颜色"
|
"第 {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::<Vec<_>>();
|
||||||
|
let unique_slots = slots
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
unique_slots.len(),
|
||||||
|
JUMP_HOP_TILE_ITEM_COUNT,
|
||||||
|
"25 个地块切片必须写入 25 个独立 slot/path,不能按重复的 tile_type 互相覆盖"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -755,12 +755,11 @@ async fn generate_match3d_material_sheet_from_level_scene(
|
|||||||
config: &Match3DConfigJson,
|
config: &Match3DConfigJson,
|
||||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||||
) -> Result<Match3DMaterialSheet, AppError> {
|
) -> Result<Match3DMaterialSheet, AppError> {
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let prompt = build_match3d_item_spritesheet_prompt();
|
let prompt = build_match3d_item_spritesheet_prompt();
|
||||||
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
|
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;
|
||||||
|
|||||||
@@ -304,12 +304,11 @@ pub(super) async fn generate_match3d_cover_image_asset(
|
|||||||
reference_image_srcs: Vec<String>,
|
reference_image_srcs: Vec<String>,
|
||||||
) -> Result<Match3DAssetUpload, AppError> {
|
) -> Result<Match3DAssetUpload, AppError> {
|
||||||
require_match3d_oss_client(state)?;
|
require_match3d_oss_client(state)?;
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
|
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
|
||||||
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
|
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
|
||||||
@@ -459,12 +458,11 @@ pub(super) async fn generate_match3d_level_asset_bundle(
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||||
require_match3d_oss_client(state)?;
|
require_match3d_oss_client(state)?;
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
|
|
||||||
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
|
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
|
||||||
@@ -607,12 +605,11 @@ pub(super) async fn generate_match3d_container_image(
|
|||||||
prompt: &str,
|
prompt: &str,
|
||||||
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
|
||||||
require_match3d_oss_client(state)?;
|
require_match3d_oss_client(state)?;
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let reference_image = load_match3d_container_reference_image()?;
|
let reference_image = load_match3d_container_reference_image()?;
|
||||||
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
|
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ use crate::{
|
|||||||
auth::{require_bearer_auth, require_runtime_principal_auth},
|
auth::{require_bearer_auth, require_runtime_principal_auth},
|
||||||
jump_hop::{
|
jump_hop::{
|
||||||
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
|
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,
|
get_jump_hop_leaderboard, get_jump_hop_runtime_work, get_jump_hop_session,
|
||||||
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
|
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,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -54,6 +55,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
|||||||
"/api/runtime/jump-hop/works/{profile_id}",
|
"/api/runtime/jump-hop/works/{profile_id}",
|
||||||
get(get_jump_hop_runtime_work),
|
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(
|
.route(
|
||||||
"/api/runtime/jump-hop/runs",
|
"/api/runtime/jump-hop/runs",
|
||||||
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
|
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -310,12 +310,11 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
|
|||||||
level_name: &str,
|
level_name: &str,
|
||||||
puzzle_image: &PuzzleDownloadedImage,
|
puzzle_image: &PuzzleDownloadedImage,
|
||||||
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
|
||||||
let settings = require_puzzle_vector_engine_settings(state)?
|
let settings = require_puzzle_vector_engine_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(session_id.to_string()),
|
||||||
Some(session_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
|
||||||
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
|
||||||
let scene_generated = create_puzzle_vector_engine_image_generation(
|
let scene_generated = create_puzzle_vector_engine_image_generation(
|
||||||
|
|||||||
@@ -117,11 +117,9 @@ impl PuzzleVectorEngineSettings {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
self.external_api_audit_user_id = user_id;
|
self.external_api_audit_user_id = user_id;
|
||||||
self.external_api_audit_profile_id = profile_id;
|
self.external_api_audit_profile_id = profile_id;
|
||||||
self.external_api_audit_request_id =
|
self.external_api_audit_request_id = Some(request_context.request_id().to_string());
|
||||||
Some(request_context.request_id().to_string());
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct ParsedPuzzleImageDataUrl {
|
pub(crate) struct ParsedPuzzleImageDataUrl {
|
||||||
|
|||||||
@@ -398,12 +398,11 @@ async fn generate_square_hole_image_data_url(
|
|||||||
size: &str,
|
size: &str,
|
||||||
failure_context: &str,
|
failure_context: &str,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
let settings = require_openai_image_settings(state)?
|
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
|
||||||
.with_external_api_audit_context(
|
request_context,
|
||||||
request_context,
|
Some(owner_user_id.to_string()),
|
||||||
Some(owner_user_id.to_string()),
|
Some(profile_id.to_string()),
|
||||||
Some(profile_id.to_string()),
|
);
|
||||||
);
|
|
||||||
let http_client = build_openai_image_http_client(&settings)?;
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
let generated = create_openai_image_generation(
|
let generated = create_openai_image_generation(
|
||||||
&http_client,
|
&http_client,
|
||||||
|
|||||||
@@ -5,61 +5,18 @@ use crate::{
|
|||||||
JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType,
|
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 {
|
pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath {
|
||||||
let config = difficulty_config(difficulty);
|
let config = difficulty_config(difficulty);
|
||||||
let mut rng = DeterministicRng::new(seed, difficulty.as_str());
|
let platform_count = 8usize;
|
||||||
let platform_count = rng.range_u32(config.min_platforms, config.max_platforms) as usize;
|
let platforms = build_platforms_until(seed, difficulty, platform_count);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
JumpHopPath {
|
JumpHopPath {
|
||||||
seed: seed.trim().to_string(),
|
seed: seed.trim().to_string(),
|
||||||
difficulty,
|
difficulty,
|
||||||
finish_index: platform_count.saturating_sub(1) as u32,
|
finish_index: u32::MAX,
|
||||||
platforms,
|
platforms,
|
||||||
camera_preset: "portrait-isometric-9x16".to_string(),
|
camera_preset: "portrait-isometric-9x16".to_string(),
|
||||||
scoring: JumpHopScoring {
|
scoring: JumpHopScoring {
|
||||||
@@ -85,6 +42,7 @@ pub fn start_run(
|
|||||||
if path.platforms.is_empty() {
|
if path.platforms.is_empty() {
|
||||||
return Err(JumpHopError::EmptyPath);
|
return Err(JumpHopError::EmptyPath);
|
||||||
}
|
}
|
||||||
|
let path = normalize_jump_hop_path_platform_size(path);
|
||||||
|
|
||||||
Ok(JumpHopRunSnapshot {
|
Ok(JumpHopRunSnapshot {
|
||||||
run_id,
|
run_id,
|
||||||
@@ -103,7 +61,9 @@ pub fn start_run(
|
|||||||
|
|
||||||
pub fn apply_jump(
|
pub fn apply_jump(
|
||||||
run: &JumpHopRunSnapshot,
|
run: &JumpHopRunSnapshot,
|
||||||
charge_ms: u32,
|
drag_distance: f32,
|
||||||
|
drag_vector_x: Option<f32>,
|
||||||
|
drag_vector_y: Option<f32>,
|
||||||
jumped_at_ms: u64,
|
jumped_at_ms: u64,
|
||||||
) -> Result<JumpHopRunSnapshot, JumpHopError> {
|
) -> Result<JumpHopRunSnapshot, JumpHopError> {
|
||||||
if run.status != JumpHopRunStatus::Playing {
|
if run.status != JumpHopRunStatus::Playing {
|
||||||
@@ -111,46 +71,42 @@ pub fn apply_jump(
|
|||||||
}
|
}
|
||||||
let current_index = run.current_platform_index as usize;
|
let current_index = run.current_platform_index as usize;
|
||||||
let next_index = current_index + 1;
|
let next_index = current_index + 1;
|
||||||
|
let path = extend_jump_hop_path(run.path.clone(), next_index + 3);
|
||||||
let current = run
|
let current = run
|
||||||
.path
|
.path
|
||||||
.platforms
|
.platforms
|
||||||
.get(current_index)
|
.get(current_index)
|
||||||
.ok_or(JumpHopError::EmptyPath)?;
|
.ok_or(JumpHopError::EmptyPath)?;
|
||||||
let target = run
|
let target = path
|
||||||
.path
|
|
||||||
.platforms
|
.platforms
|
||||||
.get(next_index)
|
.get(next_index)
|
||||||
.ok_or(JumpHopError::NoNextPlatform)?;
|
.ok_or(JumpHopError::NoNextPlatform)?;
|
||||||
let capped_charge = charge_ms.min(run.path.scoring.max_charge_ms);
|
let capped_drag_distance = drag_distance.clamp(0.0, run.path.scoring.max_charge_ms as f32);
|
||||||
let jump_distance = capped_charge as f32 * run.path.scoring.charge_to_distance_ratio;
|
let jump_distance = capped_drag_distance * run.path.scoring.charge_to_distance_ratio;
|
||||||
let vector_x = target.x - current.x;
|
let vector_x = target.x - current.x;
|
||||||
let vector_y = target.y - current.y;
|
let vector_y = target.y - current.y;
|
||||||
let target_distance = vector_x.hypot(vector_y).max(0.0001);
|
let target_distance = vector_x.hypot(vector_y).max(0.0001);
|
||||||
let unit_x = vector_x / target_distance;
|
let (unit_x, unit_y) = normalize_jump_direction(
|
||||||
let unit_y = vector_y / target_distance;
|
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_x = current.x + unit_x * jump_distance;
|
||||||
let landed_y = current.y + unit_y * jump_distance;
|
let landed_y = current.y + unit_y * jump_distance;
|
||||||
let landing_error = (landed_x - target.x).hypot(landed_y - target.y);
|
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 mut next = run.clone();
|
||||||
let result = if landing_error <= target.perfect_radius {
|
next.path = path;
|
||||||
if next_index as u32 == run.path.finish_index {
|
let result = if landing_error <= target_landing_radius {
|
||||||
JumpHopJumpResultKind::Finish
|
JumpHopJumpResultKind::Hit
|
||||||
} else {
|
|
||||||
JumpHopJumpResultKind::Perfect
|
|
||||||
}
|
|
||||||
} else if landing_error <= target.landing_radius {
|
|
||||||
if next_index as u32 == run.path.finish_index {
|
|
||||||
JumpHopJumpResultKind::Finish
|
|
||||||
} else {
|
|
||||||
JumpHopJumpResultKind::Hit
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
JumpHopJumpResultKind::Miss
|
JumpHopJumpResultKind::Miss
|
||||||
};
|
};
|
||||||
|
|
||||||
next.last_jump = Some(JumpHopLastJump {
|
next.last_jump = Some(JumpHopLastJump {
|
||||||
charge_ms: capped_charge,
|
charge_ms: capped_drag_distance.round() as u32,
|
||||||
jump_distance,
|
jump_distance,
|
||||||
target_platform_index: next_index as u32,
|
target_platform_index: next_index as u32,
|
||||||
landed_x,
|
landed_x,
|
||||||
@@ -166,23 +122,8 @@ pub fn apply_jump(
|
|||||||
}
|
}
|
||||||
|
|
||||||
next.current_platform_index = next_index as u32;
|
next.current_platform_index = next_index as u32;
|
||||||
next.combo = next.combo.saturating_add(1);
|
next.combo = 0;
|
||||||
next.score = next.score.saturating_add(target.score_value);
|
next.score = next.current_platform_index;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(next)
|
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 {
|
struct DifficultyConfig {
|
||||||
min_platforms: u32,
|
|
||||||
max_platforms: u32,
|
|
||||||
min_gap: f32,
|
min_gap: f32,
|
||||||
max_gap: f32,
|
max_gap: f32,
|
||||||
min_width: f32,
|
min_width: f32,
|
||||||
@@ -214,54 +177,143 @@ struct DifficultyConfig {
|
|||||||
max_charge_ms: u32,
|
max_charge_ms: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_platforms_until(
|
||||||
|
seed: &str,
|
||||||
|
difficulty: JumpHopDifficulty,
|
||||||
|
required_count: usize,
|
||||||
|
) -> Vec<JumpHopPlatform> {
|
||||||
|
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<f32>,
|
||||||
|
drag_vector_y: Option<f32>,
|
||||||
|
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 {
|
fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig {
|
||||||
match difficulty {
|
match difficulty {
|
||||||
JumpHopDifficulty::Easy => DifficultyConfig {
|
JumpHopDifficulty::Easy => DifficultyConfig {
|
||||||
min_platforms: 12,
|
|
||||||
max_platforms: 14,
|
|
||||||
min_gap: 1.0,
|
min_gap: 1.0,
|
||||||
max_gap: 1.45,
|
max_gap: 1.45,
|
||||||
min_width: 0.9,
|
min_width: 0.9,
|
||||||
max_width: 1.08,
|
max_width: 1.08,
|
||||||
landing_radius_factor: 0.62,
|
landing_radius_factor: 0.62,
|
||||||
perfect_radius_factor: 0.32,
|
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,
|
max_charge_ms: 700,
|
||||||
},
|
},
|
||||||
JumpHopDifficulty::Standard => DifficultyConfig {
|
JumpHopDifficulty::Standard => DifficultyConfig {
|
||||||
min_platforms: 16,
|
|
||||||
max_platforms: 18,
|
|
||||||
min_gap: 1.22,
|
min_gap: 1.22,
|
||||||
max_gap: 1.78,
|
max_gap: 1.78,
|
||||||
min_width: 0.82,
|
min_width: 0.82,
|
||||||
max_width: 1.0,
|
max_width: 1.0,
|
||||||
landing_radius_factor: 0.54,
|
landing_radius_factor: 0.54,
|
||||||
perfect_radius_factor: 0.26,
|
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,
|
max_charge_ms: 780,
|
||||||
},
|
},
|
||||||
JumpHopDifficulty::Advanced => DifficultyConfig {
|
JumpHopDifficulty::Advanced => DifficultyConfig {
|
||||||
min_platforms: 20,
|
|
||||||
max_platforms: 24,
|
|
||||||
min_gap: 1.45,
|
min_gap: 1.45,
|
||||||
max_gap: 2.05,
|
max_gap: 2.05,
|
||||||
min_width: 0.72,
|
min_width: 0.72,
|
||||||
max_width: 0.94,
|
max_width: 0.94,
|
||||||
landing_radius_factor: 0.48,
|
landing_radius_factor: 0.48,
|
||||||
perfect_radius_factor: 0.22,
|
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,
|
max_charge_ms: 860,
|
||||||
},
|
},
|
||||||
JumpHopDifficulty::Challenge => DifficultyConfig {
|
JumpHopDifficulty::Challenge => DifficultyConfig {
|
||||||
min_platforms: 26,
|
|
||||||
max_platforms: 32,
|
|
||||||
min_gap: 1.7,
|
min_gap: 1.7,
|
||||||
max_gap: 2.35,
|
max_gap: 2.35,
|
||||||
min_width: 0.66,
|
min_width: 0.66,
|
||||||
max_width: 0.88,
|
max_width: 0.88,
|
||||||
landing_radius_factor: 0.42,
|
landing_radius_factor: 0.42,
|
||||||
perfect_radius_factor: 0.18,
|
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,
|
max_charge_ms: 950,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -289,13 +341,6 @@ impl DeterministicRng {
|
|||||||
(self.state >> 32) as u32
|
(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 {
|
fn range_f32(&mut self, min: f32, max: f32) -> f32 {
|
||||||
if max <= min {
|
if max <= min {
|
||||||
return min;
|
return min;
|
||||||
@@ -319,14 +364,67 @@ mod tests {
|
|||||||
let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge);
|
let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge);
|
||||||
|
|
||||||
assert_eq!(first, second);
|
assert_eq!(first, second);
|
||||||
assert!((16..=18).contains(&first.platforms.len()));
|
assert_eq!(first.platforms.len(), 8);
|
||||||
assert!((26..=32).contains(&challenge.platforms.len()));
|
assert_eq!(challenge.platforms.len(), 8);
|
||||||
assert_eq!(first.platforms.first().unwrap().tile_type.as_str(), "start");
|
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]
|
#[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 path = generate_jump_hop_path("seed-b", JumpHopDifficulty::Easy);
|
||||||
let run = start_run(
|
let run = start_run(
|
||||||
"run-1".to_string(),
|
"run-1".to_string(),
|
||||||
@@ -338,25 +436,25 @@ mod tests {
|
|||||||
.expect("run should start");
|
.expect("run should start");
|
||||||
let target = &run.path.platforms[1];
|
let target = &run.path.platforms[1];
|
||||||
let distance = target.x.hypot(target.y);
|
let distance = target.x.hypot(target.y);
|
||||||
let perfect_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32;
|
let target_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 hit =
|
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!(
|
assert_eq!(
|
||||||
hit.last_jump.as_ref().unwrap().result,
|
hit.last_jump.as_ref().unwrap().result,
|
||||||
JumpHopJumpResultKind::Hit
|
JumpHopJumpResultKind::Hit
|
||||||
);
|
);
|
||||||
|
assert_eq!(hit.status, JumpHopRunStatus::Playing);
|
||||||
|
assert_eq!(hit.current_platform_index, 1);
|
||||||
|
|
||||||
let miss =
|
let miss = apply_jump(
|
||||||
apply_jump(&run, perfect_charge.saturating_add(900), 200).expect("jump should resolve");
|
&run,
|
||||||
|
target_charge.saturating_add(900) as f32,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
.expect("jump should resolve");
|
||||||
assert_eq!(miss.status, JumpHopRunStatus::Failed);
|
assert_eq!(miss.status, JumpHopRunStatus::Failed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
miss.last_jump.as_ref().unwrap().result,
|
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]
|
#[test]
|
||||||
fn restart_returns_to_first_platform_and_playing_state() {
|
fn restart_returns_to_first_platform_and_playing_state() {
|
||||||
let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy);
|
let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy);
|
||||||
@@ -392,4 +523,32 @@ mod tests {
|
|||||||
assert_eq!(restarted.started_at_ms, 300);
|
assert_eq!(restarted.started_at_ms, 300);
|
||||||
assert!(restarted.finished_at_ms.is_none());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ pub fn default_creation_entry_type_snapshots(
|
|||||||
build_default_creation_entry_type_snapshot(
|
build_default_creation_entry_type_snapshot(
|
||||||
"jump-hop",
|
"jump-hop",
|
||||||
"跳一跳",
|
"跳一跳",
|
||||||
"俯视角跳跃闯关",
|
"主题驱动平台跳跃",
|
||||||
"可创建",
|
"可创建",
|
||||||
"/creation-type-references/puzzle.webp",
|
"/creation-type-references/jump-hop.webp",
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
45,
|
45,
|
||||||
|
|||||||
@@ -293,6 +293,29 @@ mod tests {
|
|||||||
assert_eq!(wooden_fish.image_src, "/wooden-fish/default-hit-object.png");
|
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]
|
#[test]
|
||||||
fn normalized_clamps_music_volume_into_valid_range() {
|
fn normalized_clamps_music_volume_into_valid_range() {
|
||||||
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ pub enum JumpHopTileType {
|
|||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum JumpHopActionType {
|
pub enum JumpHopActionType {
|
||||||
CompileDraft,
|
CompileDraft,
|
||||||
RegenerateCharacter,
|
|
||||||
RegenerateTiles,
|
RegenerateTiles,
|
||||||
UpdateWorkMeta,
|
UpdateWorkMeta,
|
||||||
UpdateDifficulty,
|
UpdateDifficulty,
|
||||||
@@ -71,12 +70,20 @@ pub enum JumpHopJumpResult {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct JumpHopWorkspaceCreateRequest {
|
pub struct JumpHopWorkspaceCreateRequest {
|
||||||
pub template_id: String,
|
pub template_id: String,
|
||||||
|
pub theme_text: String,
|
||||||
|
#[serde(default)]
|
||||||
pub work_title: String,
|
pub work_title: String,
|
||||||
|
#[serde(default)]
|
||||||
pub work_description: String,
|
pub work_description: String,
|
||||||
|
#[serde(default)]
|
||||||
pub theme_tags: Vec<String>,
|
pub theme_tags: Vec<String>,
|
||||||
|
#[serde(default = "default_jump_hop_difficulty")]
|
||||||
pub difficulty: JumpHopDifficulty,
|
pub difficulty: JumpHopDifficulty,
|
||||||
|
#[serde(default = "default_jump_hop_style_preset")]
|
||||||
pub style_preset: JumpHopStylePreset,
|
pub style_preset: JumpHopStylePreset,
|
||||||
|
#[serde(default)]
|
||||||
pub character_prompt: String,
|
pub character_prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
pub tile_prompt: String,
|
pub tile_prompt: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub end_mood_prompt: Option<String>,
|
pub end_mood_prompt: Option<String>,
|
||||||
@@ -89,6 +96,8 @@ pub struct JumpHopActionRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profile_id: Option<String>,
|
pub profile_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub theme_text: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub work_title: Option<String>,
|
pub work_title: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub work_description: Option<String>,
|
pub work_description: Option<String>,
|
||||||
@@ -127,14 +136,30 @@ pub struct JumpHopCharacterAsset {
|
|||||||
pub height: u32,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct JumpHopTileAsset {
|
pub struct JumpHopTileAsset {
|
||||||
pub tile_type: JumpHopTileType,
|
pub tile_type: JumpHopTileType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tile_id: Option<String>,
|
||||||
pub image_src: String,
|
pub image_src: String,
|
||||||
pub image_object_key: String,
|
pub image_object_key: String,
|
||||||
pub asset_object_id: String,
|
pub asset_object_id: String,
|
||||||
pub source_atlas_cell: String,
|
pub source_atlas_cell: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub atlas_row: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub atlas_col: Option<u32>,
|
||||||
pub visual_width: u32,
|
pub visual_width: u32,
|
||||||
pub visual_height: u32,
|
pub visual_height: u32,
|
||||||
pub top_surface_radius: f32,
|
pub top_surface_radius: f32,
|
||||||
@@ -193,11 +218,14 @@ pub struct JumpHopDraftResponse {
|
|||||||
pub template_name: String,
|
pub template_name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profile_id: Option<String>,
|
pub profile_id: Option<String>,
|
||||||
|
pub theme_text: String,
|
||||||
pub work_title: String,
|
pub work_title: String,
|
||||||
pub work_description: String,
|
pub work_description: String,
|
||||||
pub theme_tags: Vec<String>,
|
pub theme_tags: Vec<String>,
|
||||||
pub difficulty: JumpHopDifficulty,
|
pub difficulty: JumpHopDifficulty,
|
||||||
pub style_preset: JumpHopStylePreset,
|
pub style_preset: JumpHopStylePreset,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_character: Option<JumpHopDefaultCharacter>,
|
||||||
pub character_prompt: String,
|
pub character_prompt: String,
|
||||||
pub tile_prompt: String,
|
pub tile_prompt: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -251,6 +279,7 @@ pub struct JumpHopWorkSummaryResponse {
|
|||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub source_session_id: Option<String>,
|
pub source_session_id: Option<String>,
|
||||||
|
pub theme_text: String,
|
||||||
pub work_title: String,
|
pub work_title: String,
|
||||||
pub work_description: String,
|
pub work_description: String,
|
||||||
pub theme_tags: Vec<String>,
|
pub theme_tags: Vec<String>,
|
||||||
@@ -274,6 +303,8 @@ pub struct JumpHopWorkProfileResponse {
|
|||||||
pub summary: JumpHopWorkSummaryResponse,
|
pub summary: JumpHopWorkSummaryResponse,
|
||||||
pub draft: JumpHopDraftResponse,
|
pub draft: JumpHopDraftResponse,
|
||||||
pub path: JumpHopPath,
|
pub path: JumpHopPath,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_character: Option<JumpHopDefaultCharacter>,
|
||||||
pub character_asset: JumpHopCharacterAsset,
|
pub character_asset: JumpHopCharacterAsset,
|
||||||
pub tile_atlas_asset: JumpHopCharacterAsset,
|
pub tile_atlas_asset: JumpHopCharacterAsset,
|
||||||
pub tile_assets: Vec<JumpHopTileAsset>,
|
pub tile_assets: Vec<JumpHopTileAsset>,
|
||||||
@@ -305,6 +336,7 @@ pub struct JumpHopGalleryCardResponse {
|
|||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
pub author_display_name: String,
|
pub author_display_name: String,
|
||||||
|
pub theme_text: String,
|
||||||
pub work_title: String,
|
pub work_title: String,
|
||||||
pub work_description: String,
|
pub work_description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -343,6 +375,8 @@ pub struct JumpHopRuntimeRunSnapshotResponse {
|
|||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
pub status: JumpHopRunStatus,
|
pub status: JumpHopRunStatus,
|
||||||
pub current_platform_index: u32,
|
pub current_platform_index: u32,
|
||||||
|
pub successful_jump_count: u32,
|
||||||
|
pub duration_ms: u64,
|
||||||
pub score: u32,
|
pub score: u32,
|
||||||
pub combo: u32,
|
pub combo: u32,
|
||||||
pub path: JumpHopPath,
|
pub path: JumpHopPath,
|
||||||
@@ -363,15 +397,29 @@ pub struct JumpHopRunResponse {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct JumpHopStartRunRequest {
|
pub struct JumpHopStartRunRequest {
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub runtime_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct JumpHopJumpRequest {
|
pub struct JumpHopJumpRequest {
|
||||||
pub charge_ms: u32,
|
pub drag_distance: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub drag_vector_x: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub drag_vector_y: Option<f32>,
|
||||||
pub client_event_id: String,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct JumpHopRestartRunRequest {
|
pub struct JumpHopRestartRunRequest {
|
||||||
@@ -384,6 +432,25 @@ pub struct JumpHopJumpResponse {
|
|||||||
pub run: JumpHopRuntimeRunSnapshotResponse,
|
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<JumpHopLeaderboardEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub viewer_best: Option<JumpHopLeaderboardEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -393,6 +460,7 @@ mod tests {
|
|||||||
fn jump_hop_workspace_request_uses_camel_case() {
|
fn jump_hop_workspace_request_uses_camel_case() {
|
||||||
let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest {
|
let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest {
|
||||||
template_id: "jump-hop".to_string(),
|
template_id: "jump-hop".to_string(),
|
||||||
|
theme_text: "跳一跳".to_string(),
|
||||||
work_title: "跳一跳".to_string(),
|
work_title: "跳一跳".to_string(),
|
||||||
work_description: "俯视角跳跃闯关".to_string(),
|
work_description: "俯视角跳跃闯关".to_string(),
|
||||||
theme_tags: vec!["休闲".to_string()],
|
theme_tags: vec!["休闲".to_string()],
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::mapper::{
|
use crate::mapper::{
|
||||||
map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row,
|
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_leaderboard_procedure_result, map_jump_hop_run_procedure_result,
|
||||||
map_jump_hop_works_procedure_result,
|
map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result,
|
||||||
};
|
};
|
||||||
use shared_contracts::jump_hop::{
|
use shared_contracts::jump_hop::{
|
||||||
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
||||||
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
||||||
JumpHopJumpRequest, JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse,
|
JumpHopJumpRequest, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
|
||||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
|
JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
|
||||||
JumpHopTileType, JumpHopWorkProfileResponse,
|
JumpHopStylePreset, JumpHopWorkProfileResponse,
|
||||||
};
|
};
|
||||||
use shared_kernel::build_prefixed_uuid_id;
|
use shared_kernel::build_prefixed_uuid_id;
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ impl SpacetimeClient {
|
|||||||
let work = self
|
let work = self
|
||||||
.get_jump_hop_work_profile(profile_id, String::new())
|
.get_jump_hop_work_profile(profile_id, String::new())
|
||||||
.await?;
|
.await?;
|
||||||
validate_jump_hop_runtime_ready(&work)?;
|
validate_jump_hop_runtime_ready(&work, "published")?;
|
||||||
Ok(work)
|
Ok(work)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,13 +242,15 @@ impl SpacetimeClient {
|
|||||||
let work = self
|
let work = self
|
||||||
.get_jump_hop_work_profile(profile_id.clone(), String::new())
|
.get_jump_hop_work_profile(profile_id.clone(), String::new())
|
||||||
.await?;
|
.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 run_id = build_prefixed_uuid_id("jump-hop-run-");
|
||||||
let procedure_input = JumpHopRunStartInput {
|
let procedure_input = JumpHopRunStartInput {
|
||||||
client_event_id: format!("{run_id}:start"),
|
client_event_id: format!("{run_id}:start"),
|
||||||
run_id,
|
run_id,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
|
runtime_mode: runtime_mode.to_string(),
|
||||||
started_at_ms: current_unix_micros().div_euclid(1000),
|
started_at_ms: current_unix_micros().div_euclid(1000),
|
||||||
};
|
};
|
||||||
self.start_jump_hop_run_with_input(procedure_input).await
|
self.start_jump_hop_run_with_input(procedure_input).await
|
||||||
@@ -303,7 +305,9 @@ impl SpacetimeClient {
|
|||||||
let procedure_input = JumpHopRunJumpInput {
|
let procedure_input = JumpHopRunJumpInput {
|
||||||
run_id,
|
run_id,
|
||||||
owner_user_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,
|
client_event_id: payload.client_event_id,
|
||||||
jumped_at_ms: current_unix_micros().div_euclid(1000),
|
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())
|
self.get_jump_hop_work_profile(card.profile_id, String::new())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_jump_hop_leaderboard(
|
||||||
|
&self,
|
||||||
|
profile_id: String,
|
||||||
|
viewer_player_id: String,
|
||||||
|
) -> Result<JumpHopLeaderboardResponse, SpacetimeClientError> {
|
||||||
|
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(
|
fn validate_jump_hop_runtime_ready(
|
||||||
work: &JumpHopWorkProfileResponse,
|
work: &JumpHopWorkProfileResponse,
|
||||||
|
runtime_mode: &str,
|
||||||
) -> Result<(), SpacetimeClientError> {
|
) -> Result<(), SpacetimeClientError> {
|
||||||
let status = work.summary.publication_status.trim().to_ascii_lowercase();
|
let status = work.summary.publication_status.trim().to_ascii_lowercase();
|
||||||
if status != "published" {
|
if runtime_mode == "published" && status != "published" {
|
||||||
return Err(SpacetimeClientError::validation_failed(
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
"jump-hop runtime 只能启动已发布作品",
|
"jump-hop runtime 只能启动已发布作品",
|
||||||
));
|
));
|
||||||
@@ -412,11 +442,11 @@ fn validate_jump_hop_runtime_ready(
|
|||||||
"jump-hop runtime 需要 ready 状态作品",
|
"jump-hop runtime 需要 ready 状态作品",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?;
|
validate_jump_hop_default_character_ready(work)?;
|
||||||
validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
|
validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?;
|
||||||
if work.tile_assets.is_empty() {
|
if work.tile_assets.len() < 25 {
|
||||||
return Err(SpacetimeClientError::validation_failed(
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
"jump-hop runtime 缺少地块资产",
|
"jump-hop runtime 需要 25 个地块资产",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for (index, asset) in work.tile_assets.iter().enumerate() {
|
for (index, asset) in work.tile_assets.iter().enumerate() {
|
||||||
@@ -437,7 +467,34 @@ fn validate_jump_hop_runtime_ready(
|
|||||||
Ok(())
|
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,
|
asset: &JumpHopCharacterAsset,
|
||||||
field: &str,
|
field: &str,
|
||||||
) -> Result<(), SpacetimeClientError> {
|
) -> Result<(), SpacetimeClientError> {
|
||||||
@@ -475,7 +532,6 @@ enum JumpHopActionProcedure {
|
|||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum JumpHopDraftMergeScope {
|
enum JumpHopDraftMergeScope {
|
||||||
CompileDraft,
|
CompileDraft,
|
||||||
RegenerateCharacter,
|
|
||||||
RegenerateTiles,
|
RegenerateTiles,
|
||||||
UpdateWorkMeta,
|
UpdateWorkMeta,
|
||||||
UpdateDifficulty,
|
UpdateDifficulty,
|
||||||
@@ -484,7 +540,6 @@ enum JumpHopDraftMergeScope {
|
|||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum JumpHopAssetRefresh {
|
enum JumpHopAssetRefresh {
|
||||||
Preserve,
|
Preserve,
|
||||||
Character,
|
|
||||||
Tiles,
|
Tiles,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,12 +551,18 @@ fn build_jump_hop_action_plan(
|
|||||||
) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> {
|
) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> {
|
||||||
let scope = match payload.action_type {
|
let scope = match payload.action_type {
|
||||||
JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft,
|
JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft,
|
||||||
JumpHopActionType::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter,
|
|
||||||
JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles,
|
JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles,
|
||||||
JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta,
|
JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta,
|
||||||
JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty,
|
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)?;
|
let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?;
|
||||||
draft.profile_id = Some(profile_id.clone());
|
draft.profile_id = Some(profile_id.clone());
|
||||||
|
|
||||||
@@ -514,16 +575,6 @@ fn build_jump_hop_action_plan(
|
|||||||
JumpHopAssetRefresh::Preserve,
|
JumpHopAssetRefresh::Preserve,
|
||||||
now_micros,
|
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(
|
JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input(
|
||||||
current,
|
current,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
@@ -563,6 +614,13 @@ fn merge_action_into_draft(
|
|||||||
{
|
{
|
||||||
draft.work_title = value.trim().to_string();
|
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() {
|
if let Some(value) = payload.work_description.as_ref() {
|
||||||
draft.work_description = value.trim().to_string();
|
draft.work_description = value.trim().to_string();
|
||||||
}
|
}
|
||||||
@@ -590,10 +648,7 @@ fn merge_action_into_draft(
|
|||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if matches!(
|
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
|
||||||
scope,
|
|
||||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
|
||||||
) {
|
|
||||||
if let Some(value) = payload
|
if let Some(value) = payload
|
||||||
.character_prompt
|
.character_prompt
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -622,10 +677,7 @@ fn merge_action_into_draft(
|
|||||||
{
|
{
|
||||||
draft.profile_id = Some(profile_id.to_string());
|
draft.profile_id = Some(profile_id.to_string());
|
||||||
}
|
}
|
||||||
if matches!(
|
if matches!(scope, JumpHopDraftMergeScope::CompileDraft) {
|
||||||
scope,
|
|
||||||
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter
|
|
||||||
) {
|
|
||||||
if let Some(asset) = payload.character_asset.clone() {
|
if let Some(asset) = payload.character_asset.clone() {
|
||||||
draft.character_asset = Some(asset);
|
draft.character_asset = Some(asset);
|
||||||
}
|
}
|
||||||
@@ -665,28 +717,19 @@ fn build_compile_input(
|
|||||||
refresh: JumpHopAssetRefresh,
|
refresh: JumpHopAssetRefresh,
|
||||||
now_micros: i64,
|
now_micros: i64,
|
||||||
) -> Result<JumpHopDraftCompileInput, SpacetimeClientError> {
|
) -> Result<JumpHopDraftCompileInput, SpacetimeClientError> {
|
||||||
let force_character = matches!(refresh, JumpHopAssetRefresh::Character);
|
let character_asset = draft.character_asset.clone().unwrap_or_else(|| {
|
||||||
let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles);
|
build_jump_hop_default_character_asset(profile_id, draft.theme_text.as_str())
|
||||||
if force_character {
|
});
|
||||||
draft.character_asset = None;
|
draft.character_asset = Some(character_asset.clone());
|
||||||
}
|
draft.default_character = Some(default_jump_hop_default_character());
|
||||||
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 tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
|
let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| {
|
||||||
SpacetimeClientError::validation_failed(
|
SpacetimeClientError::validation_failed(
|
||||||
"jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object",
|
"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(
|
return Err(SpacetimeClientError::validation_failed(
|
||||||
"jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object",
|
"jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object",
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
draft.tile_assets.clone()
|
draft.tile_assets.clone()
|
||||||
@@ -705,7 +748,7 @@ fn build_compile_input(
|
|||||||
work_title: draft.work_title.clone(),
|
work_title: draft.work_title.clone(),
|
||||||
work_description: draft.work_description.clone(),
|
work_description: draft.work_description.clone(),
|
||||||
theme_tags_json: Some(json_string(&draft.theme_tags)?),
|
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()),
|
difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()),
|
||||||
style_preset: Some(style_to_str(&draft.style_preset).to_string()),
|
style_preset: Some(style_to_str(&draft.style_preset).to_string()),
|
||||||
character_prompt: Some(draft.character_prompt.clone()),
|
character_prompt: Some(draft.character_prompt.clone()),
|
||||||
@@ -785,13 +828,15 @@ fn default_draft() -> JumpHopDraftResponse {
|
|||||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||||
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||||
profile_id: None,
|
profile_id: None,
|
||||||
|
theme_text: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||||
work_title: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
work_title: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||||
work_description: "俯视角跳跃闯关".to_string(),
|
work_description: "俯视角跳跃闯关".to_string(),
|
||||||
theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()],
|
theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()],
|
||||||
difficulty: JumpHopDifficulty::Standard,
|
difficulty: JumpHopDifficulty::Standard,
|
||||||
style_preset: JumpHopStylePreset::MinimalBlocks,
|
style_preset: JumpHopStylePreset::MinimalBlocks,
|
||||||
character_prompt: "俯视角可爱主角,透明背景".to_string(),
|
default_character: Some(default_jump_hop_default_character()),
|
||||||
tile_prompt: "等距立体地块图集".to_string(),
|
character_prompt: "内置默认 3D 角色".to_string(),
|
||||||
|
tile_prompt: "跳一跳主题的俯视角清爽游戏化立体感平台素材".to_string(),
|
||||||
end_mood_prompt: None,
|
end_mood_prompt: None,
|
||||||
character_asset: None,
|
character_asset: None,
|
||||||
tile_atlas_asset: None,
|
tile_atlas_asset: None,
|
||||||
@@ -804,7 +849,7 @@ fn default_draft() -> JumpHopDraftResponse {
|
|||||||
|
|
||||||
fn build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeClientError> {
|
fn build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeClientError> {
|
||||||
serde_json::to_string(&serde_json::json!({
|
serde_json::to_string(&serde_json::json!({
|
||||||
"themeText": draft.work_title,
|
"themeText": draft.theme_text,
|
||||||
"difficulty": difficulty_to_str(&draft.difficulty),
|
"difficulty": difficulty_to_str(&draft.difficulty),
|
||||||
"stylePreset": style_to_str(&draft.style_preset),
|
"stylePreset": style_to_str(&draft.style_preset),
|
||||||
"characterPrompt": draft.character_prompt,
|
"characterPrompt": draft.character_prompt,
|
||||||
@@ -814,94 +859,6 @@ fn build_config_json(draft: &JumpHopDraftResponse) -> Result<String, SpacetimeCl
|
|||||||
.map_err(SpacetimeClientError::validation_failed)
|
.map_err(SpacetimeClientError::validation_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_character_asset(
|
|
||||||
existing: Option<JumpHopCharacterAsset>,
|
|
||||||
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<JumpHopCharacterAsset>,
|
|
||||||
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<JumpHopTileAsset>,
|
|
||||||
profile_id: &str,
|
|
||||||
force_new: bool,
|
|
||||||
now_micros: i64,
|
|
||||||
) -> Vec<JumpHopTileAsset> {
|
|
||||||
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(
|
fn resolve_cover_composite(
|
||||||
draft: &JumpHopDraftResponse,
|
draft: &JumpHopDraftResponse,
|
||||||
profile_id: &str,
|
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<i64>) -> String {
|
fn asset_revision_suffix(revision: Option<i64>) -> String {
|
||||||
revision
|
revision
|
||||||
.filter(|value| *value > 0)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -968,8 +951,9 @@ mod tests {
|
|||||||
const NOW_MICROS: i64 = 1_763_456_789_000_000;
|
const NOW_MICROS: i64 = 1_763_456_789_000_000;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jump_hop_action_compile_draft_builds_compile_input_with_assets() {
|
fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character()
|
||||||
let session = session_with_draft(draft_without_assets());
|
{
|
||||||
|
let session = session_with_draft(draft_without_character_asset());
|
||||||
let payload = action(JumpHopActionType::CompileDraft);
|
let payload = action(JumpHopActionType::CompileDraft);
|
||||||
|
|
||||||
let (plan, draft) =
|
let (plan, draft) =
|
||||||
@@ -987,7 +971,7 @@ mod tests {
|
|||||||
.character_asset_json
|
.character_asset_json
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.contains("-character")
|
.contains("builtin-three")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
input
|
input
|
||||||
@@ -1001,59 +985,19 @@ mod tests {
|
|||||||
.tile_assets_json
|
.tile_assets_json
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("")
|
.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);
|
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]
|
#[test]
|
||||||
fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() {
|
fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() {
|
||||||
let session = session_with_draft(draft_with_assets());
|
let session = session_with_draft(draft_with_assets());
|
||||||
let mut payload = action(JumpHopActionType::RegenerateTiles);
|
let mut payload = action(JumpHopActionType::RegenerateTiles);
|
||||||
payload.tile_prompt = Some("新的地块提示词".to_string());
|
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) =
|
let (plan, _draft) =
|
||||||
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
||||||
@@ -1067,7 +1011,7 @@ mod tests {
|
|||||||
.character_asset_json
|
.character_asset_json
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.contains("old-character")
|
.contains("builtin-three")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!input
|
!input
|
||||||
@@ -1081,24 +1025,43 @@ mod tests {
|
|||||||
.tile_assets_json
|
.tile_assets_json
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.contains("old-normal-tile")
|
.contains("old-tile-01-object")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
input
|
input
|
||||||
.tile_atlas_asset_json
|
.tile_atlas_asset_json
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.contains(&NOW_MICROS.to_string())
|
.contains("new-tile-atlas")
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
input
|
input
|
||||||
.tile_assets_json
|
.tile_assets_json
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("")
|
.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]
|
#[test]
|
||||||
fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() {
|
fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() {
|
||||||
let session = session_with_draft(draft_with_assets());
|
let session = session_with_draft(draft_with_assets());
|
||||||
@@ -1143,20 +1106,22 @@ mod tests {
|
|||||||
.character_asset
|
.character_asset
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|asset| asset.asset_id.as_str()),
|
.map(|asset| asset.asset_id.as_str()),
|
||||||
Some("old-character")
|
Some("jump-hop-profile-test-builtin-character")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
draft
|
draft
|
||||||
.tile_assets
|
.tile_assets
|
||||||
.first()
|
.first()
|
||||||
.map(|asset| asset.asset_object_id.as_str()),
|
.map(|asset| asset.asset_object_id.as_str()),
|
||||||
Some("old-normal-tile-object")
|
Some("old-tile-01-object")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
|
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
|
||||||
JumpHopActionRequest {
|
JumpHopActionRequest {
|
||||||
action_type,
|
action_type,
|
||||||
|
profile_id: None,
|
||||||
|
theme_text: None,
|
||||||
work_title: None,
|
work_title: None,
|
||||||
work_description: None,
|
work_description: None,
|
||||||
theme_tags: None,
|
theme_tags: None,
|
||||||
@@ -1165,6 +1130,10 @@ mod tests {
|
|||||||
character_prompt: None,
|
character_prompt: None,
|
||||||
tile_prompt: None,
|
tile_prompt: None,
|
||||||
end_mood_prompt: None,
|
end_mood_prompt: None,
|
||||||
|
character_asset: None,
|
||||||
|
tile_atlas_asset: None,
|
||||||
|
tile_assets: None,
|
||||||
|
cover_composite: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,9 +1148,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draft_without_assets() -> JumpHopDraftResponse {
|
fn draft_without_character_asset() -> JumpHopDraftResponse {
|
||||||
JumpHopDraftResponse {
|
JumpHopDraftResponse {
|
||||||
profile_id: None,
|
profile_id: None,
|
||||||
|
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
|
||||||
|
tile_assets: tile_assets("old", 25),
|
||||||
..base_draft()
|
..base_draft()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1189,37 +1160,9 @@ mod tests {
|
|||||||
fn draft_with_assets() -> JumpHopDraftResponse {
|
fn draft_with_assets() -> JumpHopDraftResponse {
|
||||||
JumpHopDraftResponse {
|
JumpHopDraftResponse {
|
||||||
profile_id: Some(PROFILE_ID.to_string()),
|
profile_id: Some(PROFILE_ID.to_string()),
|
||||||
character_asset: Some(JumpHopCharacterAsset {
|
character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")),
|
||||||
asset_id: "old-character".to_string(),
|
tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)),
|
||||||
image_src: "/generated-jump-hop-assets/old-character.png".to_string(),
|
tile_assets: tile_assets("old", 25),
|
||||||
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,
|
|
||||||
}],
|
|
||||||
path: Some(sample_jump_hop_path()),
|
path: Some(sample_jump_hop_path()),
|
||||||
cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()),
|
cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()),
|
||||||
generation_status: JumpHopGenerationStatus::Ready,
|
generation_status: JumpHopGenerationStatus::Ready,
|
||||||
@@ -1227,16 +1170,58 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tile_atlas_asset(asset_id: &str, revision: i64) -> JumpHopCharacterAsset {
|
||||||
|
let suffix = asset_revision_suffix((revision > 0).then_some(revision));
|
||||||
|
JumpHopCharacterAsset {
|
||||||
|
asset_id: asset_id.to_string(),
|
||||||
|
image_src: format!("/generated-jump-hop-assets/{asset_id}{suffix}.png"),
|
||||||
|
image_object_key: format!("generated-jump-hop-assets/{asset_id}{suffix}.png"),
|
||||||
|
asset_object_id: format!("{asset_id}-object"),
|
||||||
|
generation_provider: "vector-engine-image2".to_string(),
|
||||||
|
prompt: "旧地块提示词".to_string(),
|
||||||
|
width: 1024,
|
||||||
|
height: 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tile_assets(prefix: &str, count: usize) -> Vec<JumpHopTileAsset> {
|
||||||
|
(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 {
|
fn base_draft() -> JumpHopDraftResponse {
|
||||||
JumpHopDraftResponse {
|
JumpHopDraftResponse {
|
||||||
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
|
||||||
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
|
||||||
profile_id: None,
|
profile_id: None,
|
||||||
|
theme_text: "旧主题".to_string(),
|
||||||
work_title: "旧标题".to_string(),
|
work_title: "旧标题".to_string(),
|
||||||
work_description: "旧描述".to_string(),
|
work_description: "旧描述".to_string(),
|
||||||
theme_tags: vec!["旧标签".to_string()],
|
theme_tags: vec!["旧标签".to_string()],
|
||||||
difficulty: JumpHopDifficulty::Standard,
|
difficulty: JumpHopDifficulty::Standard,
|
||||||
style_preset: JumpHopStylePreset::MinimalBlocks,
|
style_preset: JumpHopStylePreset::MinimalBlocks,
|
||||||
|
default_character: Some(default_jump_hop_default_character()),
|
||||||
character_prompt: "旧角色提示词".to_string(),
|
character_prompt: "旧角色提示词".to_string(),
|
||||||
tile_prompt: "旧地块提示词".to_string(),
|
tile_prompt: "旧地块提示词".to_string(),
|
||||||
end_mood_prompt: None,
|
end_mood_prompt: None,
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ pub(crate) use self::inventory::{
|
|||||||
};
|
};
|
||||||
pub(crate) use self::jump_hop::{
|
pub(crate) use self::jump_hop::{
|
||||||
map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row,
|
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_leaderboard_procedure_result, map_jump_hop_run_procedure_result,
|
||||||
map_jump_hop_works_procedure_result,
|
map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result,
|
||||||
};
|
};
|
||||||
pub(crate) use self::match3d::{
|
pub(crate) use self::match3d::{
|
||||||
map_match3d_agent_session_procedure_result, map_match3d_click_item_procedure_result,
|
map_match3d_agent_session_procedure_result, map_match3d_click_item_procedure_result,
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ mod tests {
|
|||||||
let row = BarkBattleGalleryViewRow {
|
let row = BarkBattleGalleryViewRow {
|
||||||
work_id: "BB-33333333".to_string(),
|
work_id: "BB-33333333".to_string(),
|
||||||
owner_user_id: "user-3".to_string(),
|
owner_user_id: "user-3".to_string(),
|
||||||
|
author_display_name: "声浪玩家".to_string(),
|
||||||
source_draft_id: Some("bark-battle-draft-3".to_string()),
|
source_draft_id: Some("bark-battle-draft-3".to_string()),
|
||||||
config_version: 1,
|
config_version: 1,
|
||||||
ruleset_version: "bark-battle-ruleset-v1".to_string(),
|
ruleset_version: "bark-battle-ruleset-v1".to_string(),
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
pub use shared_contracts::jump_hop::{
|
pub use shared_contracts::jump_hop::{
|
||||||
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
||||||
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
JumpHopDefaultCharacter, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||||
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
||||||
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
|
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump,
|
||||||
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
|
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopPath, JumpHopPlatform,
|
||||||
|
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
|
||||||
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
||||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
|
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
|
||||||
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||||
@@ -61,6 +62,25 @@ pub(crate) fn map_jump_hop_run_procedure_result(
|
|||||||
Ok(map_jump_hop_run_snapshot(run))
|
Ok(map_jump_hop_run_snapshot(run))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn map_jump_hop_leaderboard_procedure_result(
|
||||||
|
result: JumpHopLeaderboardProcedureResult,
|
||||||
|
) -> Result<JumpHopLeaderboardResponse, SpacetimeClientError> {
|
||||||
|
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(
|
pub(crate) fn map_jump_hop_gallery_card_view_row(
|
||||||
row: JumpHopGalleryCardViewRow,
|
row: JumpHopGalleryCardViewRow,
|
||||||
) -> JumpHopGalleryCardResponse {
|
) -> JumpHopGalleryCardResponse {
|
||||||
@@ -70,6 +90,7 @@ pub(crate) fn map_jump_hop_gallery_card_view_row(
|
|||||||
profile_id: row.profile_id,
|
profile_id: row.profile_id,
|
||||||
owner_user_id: row.owner_user_id,
|
owner_user_id: row.owner_user_id,
|
||||||
author_display_name: row.author_display_name,
|
author_display_name: row.author_display_name,
|
||||||
|
theme_text: row.work_title.clone(),
|
||||||
work_title: row.work_title,
|
work_title: row.work_title,
|
||||||
work_description: row.work_description,
|
work_description: row.work_description,
|
||||||
cover_image_src: empty_string_to_none(row.cover_image_src),
|
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_id: "jump-hop".to_string(),
|
||||||
template_name: "跳一跳".to_string(),
|
template_name: "跳一跳".to_string(),
|
||||||
profile_id: Some(snapshot.profile_id.clone()),
|
profile_id: Some(snapshot.profile_id.clone()),
|
||||||
|
theme_text: snapshot.work_title.clone(),
|
||||||
work_title: snapshot.work_title.clone(),
|
work_title: snapshot.work_title.clone(),
|
||||||
work_description: snapshot.work_description.clone(),
|
work_description: snapshot.work_description.clone(),
|
||||||
theme_tags: snapshot.theme_tags.clone(),
|
theme_tags: snapshot.theme_tags.clone(),
|
||||||
difficulty: parse_difficulty(&snapshot.difficulty),
|
difficulty: parse_difficulty(&snapshot.difficulty),
|
||||||
style_preset: parse_style_preset(&snapshot.style_preset),
|
style_preset: parse_style_preset(&snapshot.style_preset),
|
||||||
|
default_character: Some(default_jump_hop_character()),
|
||||||
character_prompt: snapshot.character_prompt.clone(),
|
character_prompt: snapshot.character_prompt.clone(),
|
||||||
tile_prompt: snapshot.tile_prompt.clone(),
|
tile_prompt: snapshot.tile_prompt.clone(),
|
||||||
end_mood_prompt: snapshot.end_mood_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,
|
profile_id: snapshot.profile_id,
|
||||||
owner_user_id: snapshot.owner_user_id,
|
owner_user_id: snapshot.owner_user_id,
|
||||||
source_session_id: empty_string_to_none(snapshot.source_session_id),
|
source_session_id: empty_string_to_none(snapshot.source_session_id),
|
||||||
|
theme_text: snapshot.work_title.clone(),
|
||||||
work_title: snapshot.work_title,
|
work_title: snapshot.work_title,
|
||||||
work_description: snapshot.work_description,
|
work_description: snapshot.work_description,
|
||||||
theme_tags: snapshot.theme_tags,
|
theme_tags: snapshot.theme_tags,
|
||||||
@@ -159,6 +183,7 @@ fn map_jump_hop_work_snapshot(
|
|||||||
},
|
},
|
||||||
draft,
|
draft,
|
||||||
path: map_jump_hop_path(snapshot.path),
|
path: map_jump_hop_path(snapshot.path),
|
||||||
|
default_character: Some(default_jump_hop_character()),
|
||||||
character_asset,
|
character_asset,
|
||||||
tile_atlas_asset,
|
tile_atlas_asset,
|
||||||
tile_assets: snapshot
|
tile_assets: snapshot
|
||||||
@@ -170,15 +195,18 @@ fn map_jump_hop_work_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse {
|
fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse {
|
||||||
|
let theme_text = snapshot.work_title.clone();
|
||||||
JumpHopDraftResponse {
|
JumpHopDraftResponse {
|
||||||
template_id: snapshot.template_id,
|
template_id: snapshot.template_id,
|
||||||
template_name: snapshot.template_name,
|
template_name: snapshot.template_name,
|
||||||
profile_id: snapshot.profile_id,
|
profile_id: snapshot.profile_id,
|
||||||
|
theme_text,
|
||||||
work_title: snapshot.work_title,
|
work_title: snapshot.work_title,
|
||||||
work_description: snapshot.work_description,
|
work_description: snapshot.work_description,
|
||||||
theme_tags: snapshot.theme_tags,
|
theme_tags: snapshot.theme_tags,
|
||||||
difficulty: parse_difficulty(&snapshot.difficulty),
|
difficulty: parse_difficulty(&snapshot.difficulty),
|
||||||
style_preset: parse_style_preset(&snapshot.style_preset),
|
style_preset: parse_style_preset(&snapshot.style_preset),
|
||||||
|
default_character: Some(default_jump_hop_character()),
|
||||||
character_prompt: snapshot.character_prompt,
|
character_prompt: snapshot.character_prompt,
|
||||||
tile_prompt: snapshot.tile_prompt,
|
tile_prompt: snapshot.tile_prompt,
|
||||||
end_mood_prompt: snapshot.end_mood_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 {
|
fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset {
|
||||||
JumpHopTileAsset {
|
JumpHopTileAsset {
|
||||||
tile_type: parse_tile_type(&snapshot.tile_type),
|
tile_type: parse_tile_type(&snapshot.tile_type),
|
||||||
|
tile_id: snapshot.tile_id,
|
||||||
image_src: snapshot.image_src,
|
image_src: snapshot.image_src,
|
||||||
image_object_key: snapshot.image_object_key,
|
image_object_key: snapshot.image_object_key,
|
||||||
asset_object_id: snapshot.asset_object_id,
|
asset_object_id: snapshot.asset_object_id,
|
||||||
source_atlas_cell: snapshot.source_atlas_cell,
|
source_atlas_cell: snapshot.source_atlas_cell,
|
||||||
|
atlas_row: snapshot.atlas_row,
|
||||||
|
atlas_col: snapshot.atlas_col,
|
||||||
visual_width: snapshot.visual_width,
|
visual_width: snapshot.visual_width,
|
||||||
visual_height: snapshot.visual_height,
|
visual_height: snapshot.visual_height,
|
||||||
top_surface_radius: snapshot.top_surface_radius,
|
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,
|
crate::module_bindings::JumpHopRunStatus::Playing => JumpHopRunStatus::Playing,
|
||||||
},
|
},
|
||||||
current_platform_index: snapshot.current_platform_index,
|
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,
|
score: snapshot.score,
|
||||||
combo: snapshot.combo,
|
combo: snapshot.combo,
|
||||||
path: map_jump_hop_path(snapshot.path),
|
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>) -> u64 {
|
||||||
|
finished_at_ms
|
||||||
|
.unwrap_or(started_at_ms)
|
||||||
|
.saturating_sub(started_at_ms)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_difficulty(value: &str) -> JumpHopDifficulty {
|
fn parse_difficulty(value: &str) -> JumpHopDifficulty {
|
||||||
match value {
|
match value {
|
||||||
"easy" => JumpHopDifficulty::Easy,
|
"easy" => JumpHopDifficulty::Easy,
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
|||||||
},
|
},
|
||||||
creation_types: creation_types
|
creation_types: creation_types
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| module_runtime::CreationEntryTypeSnapshot {
|
.map(|item| normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.subtitle,
|
subtitle: item.subtitle,
|
||||||
@@ -299,7 +299,7 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
|
|||||||
),
|
),
|
||||||
category_sort_order: item.category_sort_order,
|
category_sort_order: item.category_sort_order,
|
||||||
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
|
updated_at_micros: item.updated_at.to_micros_since_unix_epoch(),
|
||||||
})
|
}))
|
||||||
.collect(),
|
.collect(),
|
||||||
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
|
||||||
},
|
},
|
||||||
@@ -332,19 +332,21 @@ fn map_creation_entry_config_snapshot(
|
|||||||
creation_types: snapshot
|
creation_types: snapshot
|
||||||
.creation_types
|
.creation_types
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| module_runtime::CreationEntryTypeSnapshot {
|
.map(|item| {
|
||||||
id: item.id,
|
normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot {
|
||||||
title: item.title,
|
id: item.id,
|
||||||
subtitle: item.subtitle,
|
title: item.title,
|
||||||
badge: item.badge,
|
subtitle: item.subtitle,
|
||||||
image_src: item.image_src,
|
badge: item.badge,
|
||||||
visible: item.visible,
|
image_src: item.image_src,
|
||||||
open: item.open,
|
visible: item.visible,
|
||||||
sort_order: item.sort_order,
|
open: item.open,
|
||||||
category_id: item.category_id,
|
sort_order: item.sort_order,
|
||||||
category_label: item.category_label,
|
category_id: item.category_id,
|
||||||
category_sort_order: item.category_sort_order,
|
category_label: item.category_label,
|
||||||
updated_at_micros: item.updated_at_micros,
|
category_sort_order: item.category_sort_order,
|
||||||
|
updated_at_micros: item.updated_at_micros,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
updated_at_micros: snapshot.updated_at_micros,
|
updated_at_micros: snapshot.updated_at_micros,
|
||||||
@@ -358,6 +360,138 @@ fn creation_entry_text_or_default(value: Option<String>, default_value: &str) ->
|
|||||||
.unwrap_or_else(|| default_value.to_string())
|
.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(
|
pub(crate) fn map_runtime_setting_procedure_result(
|
||||||
result: RuntimeSettingProcedureResult,
|
result: RuntimeSettingProcedureResult,
|
||||||
) -> Result<RuntimeSettingsRecord, SpacetimeClientError> {
|
) -> Result<RuntimeSettingsRecord, SpacetimeClientError> {
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ pub mod get_custom_world_gallery_detail_by_code_procedure;
|
|||||||
pub mod get_custom_world_gallery_detail_procedure;
|
pub mod get_custom_world_gallery_detail_procedure;
|
||||||
pub mod get_custom_world_library_detail_procedure;
|
pub mod get_custom_world_library_detail_procedure;
|
||||||
pub mod get_jump_hop_agent_session_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_run_procedure;
|
||||||
pub mod get_jump_hop_work_profile_procedure;
|
pub mod get_jump_hop_work_profile_procedure;
|
||||||
pub mod get_match_3_d_agent_session_procedure;
|
pub mod get_match_3_d_agent_session_procedure;
|
||||||
@@ -433,6 +434,11 @@ pub mod jump_hop_gallery_view_table;
|
|||||||
pub mod jump_hop_jump_procedure;
|
pub mod jump_hop_jump_procedure;
|
||||||
pub mod jump_hop_jump_result_kind_type;
|
pub mod jump_hop_jump_result_kind_type;
|
||||||
pub mod jump_hop_last_jump_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_path_type;
|
||||||
pub mod jump_hop_platform_type;
|
pub mod jump_hop_platform_type;
|
||||||
pub mod jump_hop_run_get_input_type;
|
pub mod jump_hop_run_get_input_type;
|
||||||
@@ -1404,6 +1410,7 @@ pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gall
|
|||||||
pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail;
|
pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail;
|
||||||
pub use get_custom_world_library_detail_procedure::get_custom_world_library_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_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_run_procedure::get_jump_hop_run;
|
||||||
pub use get_jump_hop_work_profile_procedure::get_jump_hop_work_profile;
|
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;
|
pub use get_match_3_d_agent_session_procedure::get_match_3_d_agent_session;
|
||||||
@@ -1472,6 +1479,11 @@ pub use jump_hop_gallery_view_table::*;
|
|||||||
pub use jump_hop_jump_procedure::jump_hop_jump;
|
pub use jump_hop_jump_procedure::jump_hop_jump;
|
||||||
pub use jump_hop_jump_result_kind_type::JumpHopJumpResultKind;
|
pub use jump_hop_jump_result_kind_type::JumpHopJumpResultKind;
|
||||||
pub use jump_hop_last_jump_type::JumpHopLastJump;
|
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_path_type::JumpHopPath;
|
||||||
pub use jump_hop_platform_type::JumpHopPlatform;
|
pub use jump_hop_platform_type::JumpHopPlatform;
|
||||||
pub use jump_hop_run_get_input_type::JumpHopRunGetInput;
|
pub use jump_hop_run_get_input_type::JumpHopRunGetInput;
|
||||||
@@ -2400,6 +2412,7 @@ pub struct DbUpdate {
|
|||||||
jump_hop_event: __sdk::TableUpdate<JumpHopEventRow>,
|
jump_hop_event: __sdk::TableUpdate<JumpHopEventRow>,
|
||||||
jump_hop_gallery_card_view: __sdk::TableUpdate<JumpHopGalleryCardViewRow>,
|
jump_hop_gallery_card_view: __sdk::TableUpdate<JumpHopGalleryCardViewRow>,
|
||||||
jump_hop_gallery_view: __sdk::TableUpdate<JumpHopGalleryViewRow>,
|
jump_hop_gallery_view: __sdk::TableUpdate<JumpHopGalleryViewRow>,
|
||||||
|
jump_hop_leaderboard_entry: __sdk::TableUpdate<JumpHopLeaderboardEntryRow>,
|
||||||
jump_hop_runtime_run: __sdk::TableUpdate<JumpHopRuntimeRunRow>,
|
jump_hop_runtime_run: __sdk::TableUpdate<JumpHopRuntimeRunRow>,
|
||||||
jump_hop_work_profile: __sdk::TableUpdate<JumpHopWorkProfileRow>,
|
jump_hop_work_profile: __sdk::TableUpdate<JumpHopWorkProfileRow>,
|
||||||
match_3_d_agent_message: __sdk::TableUpdate<Match3DAgentMessageRow>,
|
match_3_d_agent_message: __sdk::TableUpdate<Match3DAgentMessageRow>,
|
||||||
@@ -2614,6 +2627,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
|||||||
"jump_hop_gallery_view" => db_update.jump_hop_gallery_view.append(
|
"jump_hop_gallery_view" => db_update.jump_hop_gallery_view.append(
|
||||||
jump_hop_gallery_view_table::parse_table_update(table_update)?,
|
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" => db_update.jump_hop_runtime_run.append(
|
||||||
jump_hop_runtime_run_table::parse_table_update(table_update)?,
|
jump_hop_runtime_run_table::parse_table_update(table_update)?,
|
||||||
),
|
),
|
||||||
@@ -3043,6 +3059,12 @@ impl __sdk::DbUpdate for DbUpdate {
|
|||||||
diff.jump_hop_event = cache
|
diff.jump_hop_event = cache
|
||||||
.apply_diff_to_table::<JumpHopEventRow>("jump_hop_event", &self.jump_hop_event)
|
.apply_diff_to_table::<JumpHopEventRow>("jump_hop_event", &self.jump_hop_event)
|
||||||
.with_updates_by_pk(|row| &row.event_id);
|
.with_updates_by_pk(|row| &row.event_id);
|
||||||
|
diff.jump_hop_leaderboard_entry = cache
|
||||||
|
.apply_diff_to_table::<JumpHopLeaderboardEntryRow>(
|
||||||
|
"jump_hop_leaderboard_entry",
|
||||||
|
&self.jump_hop_leaderboard_entry,
|
||||||
|
)
|
||||||
|
.with_updates_by_pk(|row| &row.entry_id);
|
||||||
diff.jump_hop_runtime_run = cache
|
diff.jump_hop_runtime_run = cache
|
||||||
.apply_diff_to_table::<JumpHopRuntimeRunRow>(
|
.apply_diff_to_table::<JumpHopRuntimeRunRow>(
|
||||||
"jump_hop_runtime_run",
|
"jump_hop_runtime_run",
|
||||||
@@ -3528,6 +3550,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
|||||||
"jump_hop_gallery_view" => db_update
|
"jump_hop_gallery_view" => db_update
|
||||||
.jump_hop_gallery_view
|
.jump_hop_gallery_view
|
||||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
.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" => db_update
|
||||||
.jump_hop_runtime_run
|
.jump_hop_runtime_run
|
||||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||||
@@ -3871,6 +3896,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
|||||||
"jump_hop_gallery_view" => db_update
|
"jump_hop_gallery_view" => db_update
|
||||||
.jump_hop_gallery_view
|
.jump_hop_gallery_view
|
||||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
.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" => db_update
|
||||||
.jump_hop_runtime_run
|
.jump_hop_runtime_run
|
||||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||||
@@ -4130,6 +4158,7 @@ pub struct AppliedDiff<'r> {
|
|||||||
jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>,
|
jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>,
|
||||||
jump_hop_gallery_card_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryCardViewRow>,
|
jump_hop_gallery_card_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryCardViewRow>,
|
||||||
jump_hop_gallery_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryViewRow>,
|
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_runtime_run: __sdk::TableAppliedDiff<'r, JumpHopRuntimeRunRow>,
|
||||||
jump_hop_work_profile: __sdk::TableAppliedDiff<'r, JumpHopWorkProfileRow>,
|
jump_hop_work_profile: __sdk::TableAppliedDiff<'r, JumpHopWorkProfileRow>,
|
||||||
match_3_d_agent_message: __sdk::TableAppliedDiff<'r, Match3DAgentMessageRow>,
|
match_3_d_agent_message: __sdk::TableAppliedDiff<'r, Match3DAgentMessageRow>,
|
||||||
@@ -4422,6 +4451,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
|||||||
&self.jump_hop_gallery_view,
|
&self.jump_hop_gallery_view,
|
||||||
event,
|
event,
|
||||||
);
|
);
|
||||||
|
callbacks.invoke_table_row_callbacks::<JumpHopLeaderboardEntryRow>(
|
||||||
|
"jump_hop_leaderboard_entry",
|
||||||
|
&self.jump_hop_leaderboard_entry,
|
||||||
|
event,
|
||||||
|
);
|
||||||
callbacks.invoke_table_row_callbacks::<JumpHopRuntimeRunRow>(
|
callbacks.invoke_table_row_callbacks::<JumpHopRuntimeRunRow>(
|
||||||
"jump_hop_runtime_run",
|
"jump_hop_runtime_run",
|
||||||
&self.jump_hop_runtime_run,
|
&self.jump_hop_runtime_run,
|
||||||
@@ -5444,6 +5478,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
|||||||
jump_hop_event_table::register_table(client_cache);
|
jump_hop_event_table::register_table(client_cache);
|
||||||
jump_hop_gallery_card_view_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_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_runtime_run_table::register_table(client_cache);
|
||||||
jump_hop_work_profile_table::register_table(client_cache);
|
jump_hop_work_profile_table::register_table(client_cache);
|
||||||
match_3_d_agent_message_table::register_table(client_cache);
|
match_3_d_agent_message_table::register_table(client_cache);
|
||||||
@@ -5556,6 +5591,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
|||||||
"jump_hop_event",
|
"jump_hop_event",
|
||||||
"jump_hop_gallery_card_view",
|
"jump_hop_gallery_card_view",
|
||||||
"jump_hop_gallery_view",
|
"jump_hop_gallery_view",
|
||||||
|
"jump_hop_leaderboard_entry",
|
||||||
"jump_hop_runtime_run",
|
"jump_hop_runtime_run",
|
||||||
"jump_hop_work_profile",
|
"jump_hop_work_profile",
|
||||||
"match_3_d_agent_message",
|
"match_3_d_agent_message",
|
||||||
|
|||||||
@@ -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<JumpHopLeaderboardProcedureResult, __sdk::InternalError>,
|
||||||
|
) + 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<JumpHopLeaderboardProcedureResult, __sdk::InternalError>,
|
||||||
|
) + Send
|
||||||
|
+ 'static,
|
||||||
|
) {
|
||||||
|
self.imp
|
||||||
|
.invoke_procedure_with_callback::<_, JumpHopLeaderboardProcedureResult>(
|
||||||
|
"get_jump_hop_leaderboard",
|
||||||
|
GetJumpHopLeaderboardArgs { input },
|
||||||
|
__callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JumpHopLeaderboardEntryRow, String>,
|
||||||
|
pub profile_id: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, String>,
|
||||||
|
pub player_id: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, String>,
|
||||||
|
pub successful_jump_count: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, u32>,
|
||||||
|
pub duration_ms: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, u64>,
|
||||||
|
pub run_id: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, String>,
|
||||||
|
pub updated_at: __sdk::__query_builder::Col<JumpHopLeaderboardEntryRow, __sdk::Timestamp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<JumpHopLeaderboardEntryRow, String>,
|
||||||
|
pub profile_id: __sdk::__query_builder::IxCol<JumpHopLeaderboardEntryRow, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<JumpHopLeaderboardEntryRow>,
|
||||||
|
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::<JumpHopLeaderboardEntryRow>("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<Item = JumpHopLeaderboardEntryRow> + '_ {
|
||||||
|
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<JumpHopLeaderboardEntryRow, String>,
|
||||||
|
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::<String>("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<JumpHopLeaderboardEntryRow> {
|
||||||
|
self.imp.find(col_val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||||
|
let _table =
|
||||||
|
client_cache.get_or_make_table::<JumpHopLeaderboardEntryRow>("jump_hop_leaderboard_entry");
|
||||||
|
_table.add_unique_constraint::<String>("entry_id", |row| &row.entry_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub(super) fn parse_table_update(
|
||||||
|
raw_updates: __ws::v2::TableUpdate,
|
||||||
|
) -> __sdk::Result<__sdk::TableUpdate<JumpHopLeaderboardEntryRow>> {
|
||||||
|
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||||
|
__sdk::InternalError::failed_parse("TableUpdate<JumpHopLeaderboardEntryRow>", "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<JumpHopLeaderboardEntryRow>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl jump_hop_leaderboard_entryQueryTableAccess for __sdk::QueryTableAccessor {
|
||||||
|
fn jump_hop_leaderboard_entry(
|
||||||
|
&self,
|
||||||
|
) -> __sdk::__query_builder::Table<JumpHopLeaderboardEntryRow> {
|
||||||
|
__sdk::__query_builder::Table::new("jump_hop_leaderboard_entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<JumpHopLeaderboardEntrySnapshot>,
|
||||||
|
pub viewer_best: Option<JumpHopLeaderboardEntrySnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl __sdk::InModule for JumpHopLeaderboardProcedureResult {
|
||||||
|
type Module = super::RemoteModule;
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
|||||||
pub struct JumpHopRunJumpInput {
|
pub struct JumpHopRunJumpInput {
|
||||||
pub run_id: String,
|
pub run_id: String,
|
||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
pub charge_ms: u32,
|
pub drag_distance: f32,
|
||||||
|
pub drag_vector_x: Option<f32>,
|
||||||
|
pub drag_vector_y: Option<f32>,
|
||||||
pub client_event_id: String,
|
pub client_event_id: String,
|
||||||
pub jumped_at_ms: i64,
|
pub jumped_at_ms: i64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub struct JumpHopRunStartInput {
|
|||||||
pub run_id: String,
|
pub run_id: String,
|
||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
|
pub runtime_mode: String,
|
||||||
pub client_event_id: String,
|
pub client_event_id: String,
|
||||||
pub started_at_ms: i64,
|
pub started_at_ms: i64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
|||||||
#[sats(crate = __lib)]
|
#[sats(crate = __lib)]
|
||||||
pub struct JumpHopTileAssetSnapshot {
|
pub struct JumpHopTileAssetSnapshot {
|
||||||
pub tile_type: String,
|
pub tile_type: String,
|
||||||
|
pub tile_id: Option<String>,
|
||||||
pub image_src: String,
|
pub image_src: String,
|
||||||
pub image_object_key: String,
|
pub image_object_key: String,
|
||||||
pub asset_object_id: String,
|
pub asset_object_id: String,
|
||||||
pub source_atlas_cell: String,
|
pub source_atlas_cell: String,
|
||||||
|
pub atlas_row: Option<u32>,
|
||||||
|
pub atlas_col: Option<u32>,
|
||||||
pub visual_width: u32,
|
pub visual_width: u32,
|
||||||
pub visual_height: u32,
|
pub visual_height: u32,
|
||||||
pub top_surface_radius: f32,
|
pub top_surface_radius: f32,
|
||||||
|
|||||||
@@ -801,6 +801,7 @@ mod tests {
|
|||||||
|
|
||||||
const SESSION_ID: &str = "wooden-fish-session-test";
|
const SESSION_ID: &str = "wooden-fish-session-test";
|
||||||
const OWNER_USER_ID: &str = "user-test";
|
const OWNER_USER_ID: &str = "user-test";
|
||||||
|
const AUTHOR_DISPLAY_NAME: &str = "木鱼作者";
|
||||||
const PROFILE_ID: &str = "wooden-fish-profile-test";
|
const PROFILE_ID: &str = "wooden-fish-profile-test";
|
||||||
const NOW_MICROS: i64 = 1_763_456_789_000_000;
|
const NOW_MICROS: i64 = 1_763_456_789_000_000;
|
||||||
|
|
||||||
@@ -814,7 +815,13 @@ mod tests {
|
|||||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||||
|
|
||||||
let (plan, draft) =
|
let (plan, draft) =
|
||||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
build_wooden_fish_action_plan(
|
||||||
|
&session,
|
||||||
|
OWNER_USER_ID,
|
||||||
|
AUTHOR_DISPLAY_NAME,
|
||||||
|
&payload,
|
||||||
|
NOW_MICROS,
|
||||||
|
)
|
||||||
.expect("compile-draft should build plan");
|
.expect("compile-draft should build plan");
|
||||||
|
|
||||||
let WoodenFishActionProcedure::Compile(input) = plan else {
|
let WoodenFishActionProcedure::Compile(input) = plan else {
|
||||||
@@ -863,7 +870,13 @@ mod tests {
|
|||||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||||
|
|
||||||
let error =
|
let error =
|
||||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
match build_wooden_fish_action_plan(
|
||||||
|
&session,
|
||||||
|
OWNER_USER_ID,
|
||||||
|
AUTHOR_DISPLAY_NAME,
|
||||||
|
&payload,
|
||||||
|
NOW_MICROS,
|
||||||
|
) {
|
||||||
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
|
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
@@ -884,7 +897,13 @@ mod tests {
|
|||||||
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
|
||||||
|
|
||||||
let error =
|
let error =
|
||||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
match build_wooden_fish_action_plan(
|
||||||
|
&session,
|
||||||
|
OWNER_USER_ID,
|
||||||
|
AUTHOR_DISPLAY_NAME,
|
||||||
|
&payload,
|
||||||
|
NOW_MICROS,
|
||||||
|
) {
|
||||||
Ok(_) => panic!("compile-draft should not publish without background asset"),
|
Ok(_) => panic!("compile-draft should not publish without background asset"),
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
@@ -905,7 +924,13 @@ mod tests {
|
|||||||
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
|
||||||
|
|
||||||
let error =
|
let error =
|
||||||
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
|
match build_wooden_fish_action_plan(
|
||||||
|
&session,
|
||||||
|
OWNER_USER_ID,
|
||||||
|
AUTHOR_DISPLAY_NAME,
|
||||||
|
&payload,
|
||||||
|
NOW_MICROS,
|
||||||
|
) {
|
||||||
Ok(_) => panic!("compile-draft should not publish without back button asset"),
|
Ok(_) => panic!("compile-draft should not publish without back button asset"),
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
@@ -927,7 +952,13 @@ mod tests {
|
|||||||
payload.back_button_asset = Some(generated_back_button_asset("generated-back"));
|
payload.back_button_asset = Some(generated_back_button_asset("generated-back"));
|
||||||
|
|
||||||
let (plan, _draft) =
|
let (plan, _draft) =
|
||||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
build_wooden_fish_action_plan(
|
||||||
|
&session,
|
||||||
|
OWNER_USER_ID,
|
||||||
|
AUTHOR_DISPLAY_NAME,
|
||||||
|
&payload,
|
||||||
|
NOW_MICROS,
|
||||||
|
)
|
||||||
.expect("regenerate-hit-object should build plan");
|
.expect("regenerate-hit-object should build plan");
|
||||||
|
|
||||||
let WoodenFishActionProcedure::Compile(input) = plan else {
|
let WoodenFishActionProcedure::Compile(input) = plan else {
|
||||||
@@ -988,7 +1019,13 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let (plan, draft) =
|
let (plan, draft) =
|
||||||
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
|
build_wooden_fish_action_plan(
|
||||||
|
&session,
|
||||||
|
OWNER_USER_ID,
|
||||||
|
AUTHOR_DISPLAY_NAME,
|
||||||
|
&payload,
|
||||||
|
NOW_MICROS,
|
||||||
|
)
|
||||||
.expect("update-floating-words should build plan");
|
.expect("update-floating-words should build plan");
|
||||||
|
|
||||||
let WoodenFishActionProcedure::Update(input) = plan else {
|
let WoodenFishActionProcedure::Update(input) = plan else {
|
||||||
|
|||||||
@@ -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(
|
fn create_jump_hop_agent_session_tx(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: JumpHopAgentSessionCreateInput,
|
input: JumpHopAgentSessionCreateInput,
|
||||||
@@ -543,6 +566,12 @@ fn start_jump_hop_run_tx(
|
|||||||
) -> Result<JumpHopRunSnapshot, String> {
|
) -> Result<JumpHopRunSnapshot, String> {
|
||||||
require_non_empty(&input.run_id, "jump_hop run_id")?;
|
require_non_empty(&input.run_id, "jump_hop run_id")?;
|
||||||
let work = find_work(ctx, &input.profile_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::<JumpHopPath>(&work.path_json)?;
|
let path = parse_json::<JumpHopPath>(&work.path_json)?;
|
||||||
let domain_run = start_run(
|
let domain_run = start_run(
|
||||||
input.run_id.clone(),
|
input.run_id.clone(),
|
||||||
@@ -554,7 +583,9 @@ fn start_jump_hop_run_tx(
|
|||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let snapshot = domain_run;
|
let snapshot = domain_run;
|
||||||
upsert_run(ctx, &snapshot, input.started_at_ms);
|
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(
|
insert_event(
|
||||||
ctx,
|
ctx,
|
||||||
input.client_event_id,
|
input.client_event_id,
|
||||||
@@ -582,10 +613,19 @@ fn jump_hop_jump_tx(
|
|||||||
) -> Result<JumpHopRunSnapshot, String> {
|
) -> Result<JumpHopRunSnapshot, String> {
|
||||||
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
||||||
let snapshot = parse_json::<JumpHopRunSnapshot>(&row.snapshot_json)?;
|
let snapshot = parse_json::<JumpHopRunSnapshot>(&row.snapshot_json)?;
|
||||||
let domain_next = apply_jump(&snapshot, input.charge_ms, input.jumped_at_ms as u64)
|
let domain_next = apply_jump(
|
||||||
.map_err(|error| error.to_string())?;
|
&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;
|
let next = domain_next;
|
||||||
replace_run(ctx, &row, &next, input.jumped_at_ms);
|
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(
|
insert_event(
|
||||||
ctx,
|
ctx,
|
||||||
input.client_event_id,
|
input.client_event_id,
|
||||||
@@ -602,6 +642,47 @@ fn jump_hop_jump_tx(
|
|||||||
Ok(next)
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_jump_hop_leaderboard_tx(
|
||||||
|
ctx: &ReducerContext,
|
||||||
|
input: JumpHopLeaderboardGetInput,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
String,
|
||||||
|
Vec<JumpHopLeaderboardEntrySnapshot>,
|
||||||
|
Option<JumpHopLeaderboardEntrySnapshot>,
|
||||||
|
),
|
||||||
|
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::<Vec<_>>();
|
||||||
|
sort_jump_hop_leaderboard_rows(&mut rows);
|
||||||
|
let ranked_rows = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, row)| (index as u32 + 1, row))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok((input.profile_id, items, viewer_best))
|
||||||
|
}
|
||||||
|
|
||||||
fn restart_jump_hop_run_tx(
|
fn restart_jump_hop_run_tx(
|
||||||
ctx: &ReducerContext,
|
ctx: &ReducerContext,
|
||||||
input: JumpHopRunRestartInput,
|
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 {
|
fn is_publish_ready(row: &JumpHopWorkProfileRow) -> bool {
|
||||||
!row.work_title.trim().is_empty()
|
!row.work_title.trim().is_empty()
|
||||||
&& !row.character_asset_json.trim().is_empty()
|
|
||||||
&& !row.tile_atlas_asset_json.trim().is_empty()
|
&& !row.tile_atlas_asset_json.trim().is_empty()
|
||||||
&& !row.tile_assets_json.trim().is_empty()
|
&& !row.tile_assets_json.trim().is_empty()
|
||||||
&& !row.path_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(),
|
theme_text: seed.clone(),
|
||||||
difficulty: JumpHopDifficulty::Standard.as_str().to_string(),
|
difficulty: JumpHopDifficulty::Standard.as_str().to_string(),
|
||||||
style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(),
|
style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(),
|
||||||
character_prompt: format!("{seed}的俯视角主角,透明背景,全身可见"),
|
character_prompt: "内置默认 3D 角色".to_string(),
|
||||||
tile_prompt: format!("{seed}的等距地块图集,包含起点、普通、目标和终点地块"),
|
tile_prompt: format!("{seed}主题的俯视角清爽游戏化立体感平台素材"),
|
||||||
end_mood_prompt: String::new(),
|
end_mood_prompt: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1185,3 +1378,64 @@ fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow {
|
|||||||
updated_at: row.updated_at,
|
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::<Vec<_>>();
|
||||||
|
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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,3 +94,19 @@ pub struct JumpHopEventRow {
|
|||||||
pub(crate) result: String,
|
pub(crate) result: String,
|
||||||
pub(crate) occurred_at: Timestamp,
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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_STARTED: &str = "run-started";
|
||||||
pub const JUMP_HOP_EVENT_RUN_RESTARTED: &str = "run-restarted";
|
pub const JUMP_HOP_EVENT_RUN_RESTARTED: &str = "run-restarted";
|
||||||
pub const JUMP_HOP_EVENT_JUMP: &str = "jump";
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||||
pub struct JumpHopAgentSessionCreateInput {
|
pub struct JumpHopAgentSessionCreateInput {
|
||||||
@@ -96,6 +98,7 @@ pub struct JumpHopRunStartInput {
|
|||||||
pub run_id: String,
|
pub run_id: String,
|
||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
|
pub runtime_mode: String,
|
||||||
pub client_event_id: String,
|
pub client_event_id: String,
|
||||||
pub started_at_ms: i64,
|
pub started_at_ms: i64,
|
||||||
}
|
}
|
||||||
@@ -106,11 +109,13 @@ pub struct JumpHopRunGetInput {
|
|||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
|
||||||
pub struct JumpHopRunJumpInput {
|
pub struct JumpHopRunJumpInput {
|
||||||
pub run_id: String,
|
pub run_id: String,
|
||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
pub charge_ms: u32,
|
pub drag_distance: f32,
|
||||||
|
pub drag_vector_x: Option<f32>,
|
||||||
|
pub drag_vector_y: Option<f32>,
|
||||||
pub client_event_id: String,
|
pub client_event_id: String,
|
||||||
pub jumped_at_ms: i64,
|
pub jumped_at_ms: i64,
|
||||||
}
|
}
|
||||||
@@ -152,6 +157,31 @@ pub struct JumpHopRunProcedureResult {
|
|||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<JumpHopLeaderboardEntrySnapshot>,
|
||||||
|
pub viewer_best: Option<JumpHopLeaderboardEntrySnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct JumpHopCreatorConfigSnapshot {
|
pub struct JumpHopCreatorConfigSnapshot {
|
||||||
@@ -181,10 +211,16 @@ pub struct JumpHopCharacterAssetSnapshot {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct JumpHopTileAssetSnapshot {
|
pub struct JumpHopTileAssetSnapshot {
|
||||||
pub tile_type: String,
|
pub tile_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tile_id: Option<String>,
|
||||||
pub image_src: String,
|
pub image_src: String,
|
||||||
pub image_object_key: String,
|
pub image_object_key: String,
|
||||||
pub asset_object_id: String,
|
pub asset_object_id: String,
|
||||||
pub source_atlas_cell: String,
|
pub source_atlas_cell: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub atlas_row: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub atlas_col: Option<u32>,
|
||||||
pub visual_width: u32,
|
pub visual_width: u32,
|
||||||
pub visual_height: u32,
|
pub visual_height: u32,
|
||||||
pub top_surface_radius: f32,
|
pub top_surface_radius: f32,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ use crate::bark_battle::tables::{
|
|||||||
};
|
};
|
||||||
use crate::big_fish::big_fish_runtime_run;
|
use crate::big_fish::big_fish_runtime_run;
|
||||||
use crate::jump_hop::tables::{
|
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::{
|
use crate::match3d::tables::{
|
||||||
match_3_d_work_profile, match3d_agent_message, match3d_agent_session, match3d_runtime_run,
|
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_work_profile,
|
||||||
jump_hop_runtime_run,
|
jump_hop_runtime_run,
|
||||||
jump_hop_event,
|
jump_hop_event,
|
||||||
|
jump_hop_leaderboard_entry,
|
||||||
wooden_fish_agent_session,
|
wooden_fish_agent_session,
|
||||||
wooden_fish_work_profile,
|
wooden_fish_work_profile,
|
||||||
wooden_fish_runtime_run,
|
wooden_fish_runtime_run,
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
|||||||
migrate_bark_battle_entry_to_open_default(ctx, now);
|
migrate_bark_battle_entry_to_open_default(ctx, now);
|
||||||
migrate_baby_object_match_entry_from_old_coming_soon_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_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) {
|
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) {
|
||||||
@@ -388,6 +389,35 @@ fn migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx: &ReducerContext,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_jump_hop_entry_from_old_puzzle_default(ctx: &ReducerContext, now: Timestamp) {
|
||||||
|
let id = "jump-hop".to_string();
|
||||||
|
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 中文注释:只纠偏跳一跳重设计前的系统默认入口,避免覆盖后台手动配置。
|
||||||
|
let still_old_puzzle_default = row.title == "跳一跳"
|
||||||
|
&& row.subtitle == "俯视角跳跃闯关"
|
||||||
|
&& row.badge == "可创建"
|
||||||
|
&& row.image_src == "/creation-type-references/puzzle.webp"
|
||||||
|
&& row.visible
|
||||||
|
&& row.open
|
||||||
|
&& row.sort_order == 45;
|
||||||
|
if !still_old_puzzle_default {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.db
|
||||||
|
.creation_entry_type_config()
|
||||||
|
.id()
|
||||||
|
.update(CreationEntryTypeConfig {
|
||||||
|
subtitle: "主题驱动平台跳跃".to_string(),
|
||||||
|
image_src: "/creation-type-references/jump-hop.webp".to_string(),
|
||||||
|
updated_at: now,
|
||||||
|
..row
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
|
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeConfig> {
|
||||||
module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch())
|
module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
60
src/components/jump-hop-creation/JumpHopWorkspace.test.tsx
Normal file
60
src/components/jump-hop-creation/JumpHopWorkspace.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||||
|
import { JumpHopWorkspace } from './JumpHopWorkspace';
|
||||||
|
|
||||||
|
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
|
||||||
|
jumpHopClient: {
|
||||||
|
createSession: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(jumpHopClient.createSession).mockReset();
|
||||||
|
vi.mocked(jumpHopClient.createSession).mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
sessionId: 'jump-hop-session-test',
|
||||||
|
ownerUserId: 'user-test',
|
||||||
|
status: 'draft',
|
||||||
|
draft: null,
|
||||||
|
createdAt: '2026-05-27T00:00:00Z',
|
||||||
|
updatedAt: '2026-05-27T00:00:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳工作台只保留主题输入并自动派生提交 payload', async () => {
|
||||||
|
const onSubmitted = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<JumpHopWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('主题')).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText('作品标题')).toBeNull();
|
||||||
|
expect(screen.queryByLabelText('作品简介')).toBeNull();
|
||||||
|
expect(screen.queryByLabelText('角色提示词')).toBeNull();
|
||||||
|
expect(screen.queryByLabelText('地块提示词')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('主题'), {
|
||||||
|
target: { value: '竹林茶馆' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||||
|
expect(jumpHopClient.createSession).toHaveBeenCalledWith({
|
||||||
|
templateId: 'jump-hop',
|
||||||
|
themeText: '竹林茶馆',
|
||||||
|
workTitle: '竹林茶馆跳一跳',
|
||||||
|
workDescription: '竹林茶馆主题的俯视角平台跳跃作品',
|
||||||
|
themeTags: ['竹林茶馆', '跳一跳', '休闲'],
|
||||||
|
difficulty: 'standard',
|
||||||
|
stylePreset: 'minimal-blocks',
|
||||||
|
characterPrompt: '内置默认 3D 角色',
|
||||||
|
tilePrompt: '竹林茶馆主题的俯视角清爽游戏化立体感平台素材',
|
||||||
|
endMoodPrompt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,7 @@ import { ArrowLeft, Loader2, Send } from 'lucide-react';
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
JumpHopDifficulty,
|
|
||||||
JumpHopSessionResponse,
|
JumpHopSessionResponse,
|
||||||
JumpHopStylePreset,
|
|
||||||
JumpHopWorkspaceCreateRequest,
|
JumpHopWorkspaceCreateRequest,
|
||||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||||
@@ -20,27 +18,31 @@ type JumpHopWorkspaceProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type JumpHopWorkspaceFormState = {
|
type JumpHopWorkspaceFormState = {
|
||||||
workTitle: string;
|
themeText: string;
|
||||||
workDescription: string;
|
|
||||||
themeTags: string;
|
|
||||||
difficulty: JumpHopDifficulty;
|
|
||||||
stylePreset: JumpHopStylePreset;
|
|
||||||
characterPrompt: string;
|
|
||||||
tilePrompt: string;
|
|
||||||
endMoodPrompt: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
|
const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
|
||||||
workTitle: '',
|
themeText: '',
|
||||||
workDescription: '',
|
|
||||||
themeTags: '',
|
|
||||||
difficulty: 'easy',
|
|
||||||
stylePreset: 'minimal-blocks',
|
|
||||||
characterPrompt: '',
|
|
||||||
tilePrompt: '',
|
|
||||||
endMoodPrompt: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function buildJumpHopWorkspacePayload(
|
||||||
|
formState: JumpHopWorkspaceFormState,
|
||||||
|
): JumpHopWorkspaceCreateRequest {
|
||||||
|
const themeText = formState.themeText.trim();
|
||||||
|
return {
|
||||||
|
templateId: 'jump-hop',
|
||||||
|
themeText,
|
||||||
|
workTitle: `${themeText}跳一跳`,
|
||||||
|
workDescription: `${themeText}主题的俯视角平台跳跃作品`,
|
||||||
|
themeTags: [themeText, '跳一跳', '休闲'],
|
||||||
|
difficulty: 'standard',
|
||||||
|
stylePreset: 'minimal-blocks',
|
||||||
|
characterPrompt: '内置默认 3D 角色',
|
||||||
|
tilePrompt: `${themeText}主题的俯视角清爽游戏化立体感平台素材`,
|
||||||
|
endMoodPrompt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function JumpHopWorkspace({
|
export function JumpHopWorkspace({
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
error = null,
|
error = null,
|
||||||
@@ -52,14 +54,7 @@ export function JumpHopWorkspace({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const canSubmit = useMemo(
|
const canSubmit = useMemo(
|
||||||
() =>
|
() => Boolean(formState.themeText.trim()),
|
||||||
Boolean(
|
|
||||||
formState.workTitle.trim() &&
|
|
||||||
formState.workDescription.trim() &&
|
|
||||||
formState.themeTags.trim() &&
|
|
||||||
formState.characterPrompt.trim() &&
|
|
||||||
formState.tilePrompt.trim(),
|
|
||||||
),
|
|
||||||
[formState],
|
[formState],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -73,20 +68,7 @@ export function JumpHopWorkspace({
|
|||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: JumpHopWorkspaceCreateRequest = {
|
const payload = buildJumpHopWorkspacePayload(formState);
|
||||||
templateId: 'jump-hop',
|
|
||||||
workTitle: formState.workTitle.trim(),
|
|
||||||
workDescription: formState.workDescription.trim(),
|
|
||||||
themeTags: formState.themeTags
|
|
||||||
.split(/[,,、\s]+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
difficulty: formState.difficulty,
|
|
||||||
stylePreset: formState.stylePreset,
|
|
||||||
characterPrompt: formState.characterPrompt.trim(),
|
|
||||||
tilePrompt: formState.tilePrompt.trim(),
|
|
||||||
endMoodPrompt: formState.endMoodPrompt.trim() || null,
|
|
||||||
};
|
|
||||||
const response = await jumpHopClient.createSession(payload);
|
const response = await jumpHopClient.createSession(payload);
|
||||||
onSubmitted(response, payload);
|
onSubmitted(response, payload);
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
@@ -111,143 +93,22 @@ export function JumpHopWorkspace({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3">
|
||||||
<label className="block sm:col-span-2">
|
<label className="block sm:col-span-2">
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
作品标题
|
主题
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
value={formState.workTitle}
|
value={formState.themeText}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setFormState((current) => ({
|
setFormState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
workTitle: event.target.value,
|
themeText: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block sm:col-span-2">
|
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
||||||
作品简介
|
|
||||||
</span>
|
|
||||||
<textarea
|
|
||||||
value={formState.workDescription}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
workDescription: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block sm:col-span-2">
|
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
||||||
主题标签
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
value={formState.themeTags}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
themeTags: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
||||||
难度
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={formState.difficulty}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
difficulty: event.target.value as JumpHopDifficulty,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
|
||||||
>
|
|
||||||
<option value="easy">easy</option>
|
|
||||||
<option value="standard">standard</option>
|
|
||||||
<option value="advanced">advanced</option>
|
|
||||||
<option value="challenge">challenge</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
||||||
风格
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={formState.stylePreset}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
stylePreset: event.target.value as JumpHopStylePreset,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
|
||||||
>
|
|
||||||
<option value="minimal-blocks">minimal-blocks</option>
|
|
||||||
<option value="paper-toy">paper-toy</option>
|
|
||||||
<option value="neon-glass">neon-glass</option>
|
|
||||||
<option value="forest-stone">forest-stone</option>
|
|
||||||
<option value="future-metal">future-metal</option>
|
|
||||||
<option value="custom">custom</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="block sm:col-span-2">
|
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
||||||
角色提示词
|
|
||||||
</span>
|
|
||||||
<textarea
|
|
||||||
value={formState.characterPrompt}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
characterPrompt: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block sm:col-span-2">
|
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
||||||
地块提示词
|
|
||||||
</span>
|
|
||||||
<textarea
|
|
||||||
value={formState.tilePrompt}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
tilePrompt: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows={3}
|
|
||||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block sm:col-span-2">
|
|
||||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
||||||
终点氛围
|
|
||||||
</span>
|
|
||||||
<textarea
|
|
||||||
value={formState.endMoodPrompt}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({
|
|
||||||
...current,
|
|
||||||
endMoodPrompt: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
rows={2}
|
|
||||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{localError || error ? (
|
{localError || error ? (
|
||||||
|
|||||||
180
src/components/jump-hop-result/JumpHopResultView.test.tsx
Normal file
180
src/components/jump-hop-result/JumpHopResultView.test.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { JumpHopWorkProfileResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
|
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
||||||
|
import { JumpHopResultView } from './JumpHopResultView';
|
||||||
|
|
||||||
|
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
|
||||||
|
useJumpHopLeaderboard: 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(
|
||||||
|
<JumpHopResultView
|
||||||
|
profile={buildProfile()}
|
||||||
|
onBack={() => {}}
|
||||||
|
onEdit={() => {}}
|
||||||
|
onStartTestRun={() => {}}
|
||||||
|
onPublish={() => {}}
|
||||||
|
onRegenerateTiles={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopResultView
|
||||||
|
profile={buildProfile()}
|
||||||
|
onBack={() => {}}
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,18 +2,22 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
RefreshCcw,
|
|
||||||
Send,
|
Send,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { type CSSProperties, useMemo, useState } from 'react';
|
import { type CSSProperties, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
JumpHopDraftResponse,
|
JumpHopDraftResponse,
|
||||||
JumpHopPath,
|
JumpHopPath,
|
||||||
JumpHopPlatform,
|
JumpHopTileAsset,
|
||||||
JumpHopWorkProfileResponse,
|
JumpHopWorkProfileResponse,
|
||||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
} 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';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
|
||||||
type JumpHopResultViewProps = {
|
type JumpHopResultViewProps = {
|
||||||
@@ -34,7 +38,6 @@ type JumpHopResultViewProps = {
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onStartTestRun: () => void;
|
onStartTestRun: () => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
onRegenerateCharacter: () => void;
|
|
||||||
onRegenerateTiles: () => void;
|
onRegenerateTiles: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,43 +47,6 @@ function isJumpHopWorkProfile(
|
|||||||
return 'summary' in profile;
|
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<string, string> = {
|
const tileToneByType: Record<string, string> = {
|
||||||
accent: '#c4b5fd',
|
accent: '#c4b5fd',
|
||||||
bonus: '#fde68a',
|
bonus: '#fde68a',
|
||||||
@@ -90,155 +56,191 @@ const tileToneByType: Record<string, string> = {
|
|||||||
target: '#fecdd3',
|
target: '#fecdd3',
|
||||||
};
|
};
|
||||||
|
|
||||||
function isFiniteNumber(value: unknown): value is number {
|
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
|
||||||
return typeof value === 'number' && Number.isFinite(value);
|
'/branding/jump-hop-taonier-character.png';
|
||||||
|
|
||||||
|
function JumpHopDefaultCharacterPreview() {
|
||||||
|
return (
|
||||||
|
<div className="relative grid aspect-[1/1] place-items-center overflow-hidden bg-[linear-gradient(180deg,#eff6ff_0%,#fff7ed_100%)]">
|
||||||
|
<div className="absolute inset-x-[18%] bottom-[14%] h-[14%] rounded-full bg-slate-900/12 blur-[2px]" />
|
||||||
|
<img
|
||||||
|
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
className="relative z-10 h-[78%] w-[78%] object-contain drop-shadow-[0_12px_18px_rgba(146,64,14,0.2)]"
|
||||||
|
data-testid="jump-hop-result-character-logo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePathPlatforms(path: JumpHopPath | null | undefined) {
|
function JumpHopTilePoolPreview({
|
||||||
const platforms = path?.platforms ?? [];
|
tileAssets,
|
||||||
if (platforms.length === 0) {
|
tileAtlasAsset,
|
||||||
return [];
|
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 (
|
||||||
|
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
|
||||||
|
{visibleTiles.map((tile, index) => (
|
||||||
|
<div
|
||||||
|
key={tile.tileId ?? `${tile.sourceAtlasCell}-${index}`}
|
||||||
|
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.45rem] border border-white/80 bg-slate-50"
|
||||||
|
>
|
||||||
|
{tile.imageSrc ? (
|
||||||
|
<ResolvedAssetImage
|
||||||
|
src={tile.imageSrc}
|
||||||
|
refreshKey={tile.assetObjectId}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="h-4 w-4 rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
tileToneByType[tile.tileType] ?? tileToneByType.normal,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const coordinatePlatforms = platforms.filter(
|
if (atlasSrc) {
|
||||||
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y),
|
return (
|
||||||
);
|
<ResolvedAssetImage
|
||||||
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
|
src={atlasSrc}
|
||||||
const xValues = shouldUseCoordinates
|
refreshKey={atlasRefreshKey}
|
||||||
? coordinatePlatforms.map((platform) => platform.x)
|
alt=""
|
||||||
: [];
|
className="aspect-[1/1] w-full object-cover"
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
|
||||||
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]"
|
{Array.from({ length: 25 }).map((_, index) => (
|
||||||
style={
|
<span
|
||||||
{
|
key={index}
|
||||||
'--jump-hop-path-accent': tone.accent,
|
className="rounded-[0.45rem] border border-white/80"
|
||||||
'--jump-hop-path-soft': tone.soft,
|
style={{
|
||||||
} as CSSProperties
|
background:
|
||||||
}
|
Object.values(tileToneByType)[index % Object.values(tileToneByType).length],
|
||||||
>
|
}}
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.92),transparent_28%),radial-gradient(circle_at_75%_78%,rgba(125,211,252,0.24),transparent_32%)]" />
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
className="absolute inset-0 h-full w-full"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<polyline
|
|
||||||
points={pathPoints}
|
|
||||||
fill="none"
|
|
||||||
stroke="var(--jump-hop-path-soft)"
|
|
||||||
strokeWidth="11"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
/>
|
||||||
<polyline
|
))}
|
||||||
points={pathPoints}
|
</div>
|
||||||
fill="none"
|
);
|
||||||
stroke="var(--jump-hop-path-accent)"
|
}
|
||||||
strokeWidth="2.6"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{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 (
|
||||||
|
<div className="relative aspect-[1/1] overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgba(255,255,255,0.92),transparent_34%)]" />
|
||||||
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={
|
key={platform.platformId || index}
|
||||||
item.platform.platformId ||
|
className="absolute aspect-[1.16/1] -translate-x-1/2 -translate-y-1/2"
|
||||||
`${item.index}-${item.platform.tileType}`
|
|
||||||
}
|
|
||||||
className="absolute grid max-h-9 max-w-11 min-h-6 min-w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-[0.72rem] border-2 shadow-[0_8px_18px_rgba(15,23,42,0.13)]"
|
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<span
|
<div className="absolute inset-x-[12%] bottom-[-6%] h-[22%] rounded-full bg-slate-900/14 blur-[3px]" />
|
||||||
className="h-2.5 w-2.5 rounded-full"
|
{asset?.imageSrc ? (
|
||||||
style={{
|
<ResolvedAssetImage
|
||||||
background:
|
src={asset.imageSrc}
|
||||||
item.isStart || item.isFinish ? tone.accent : '#ffffff',
|
refreshKey={asset.assetObjectId}
|
||||||
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
|
alt=""
|
||||||
}}
|
className="relative h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
{item.isStart || item.isFinish ? (
|
) : (
|
||||||
<span className="absolute -top-2.5 rounded-full bg-slate-950/78 px-1.5 py-0.5 text-[0.58rem] font-black leading-none text-white">
|
<div
|
||||||
{item.isStart ? '起' : '终'}
|
className="relative h-full w-full rounded-[18%] border-2 border-white/90 shadow-[0_10px_22px_rgba(15,23,42,0.14)]"
|
||||||
</span>
|
style={{
|
||||||
) : null}
|
background:
|
||||||
|
tileToneByType[platform.tileType] ?? tileToneByType.normal,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="absolute left-2 top-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-strong)] shadow-sm">
|
{platforms.length === 0 ? (
|
||||||
{tone.label}
|
<div className="absolute inset-0 grid place-items-center text-sm font-bold text-[var(--platform-text-soft)]">
|
||||||
|
路径
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JumpHopResultLeaderboard({
|
||||||
|
profileId,
|
||||||
|
}: {
|
||||||
|
profileId?: string | null;
|
||||||
|
}) {
|
||||||
|
const { leaderboard, isLoading, error } = useJumpHopLeaderboard(profileId);
|
||||||
|
const items = leaderboard?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
|
排行榜
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-2 right-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-base)] shadow-sm">
|
<div className="mt-3 grid gap-2">
|
||||||
{platforms.length}
|
{items.slice(0, 5).map((entry) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.rank}-${entry.playerId}`}
|
||||||
|
className="grid grid-cols-[1.8rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-[0.75rem] bg-white/70 px-2 py-2 text-xs font-bold text-[var(--platform-text-base)]"
|
||||||
|
>
|
||||||
|
<span className="text-[var(--platform-text-soft)]">
|
||||||
|
{entry.rank}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{entry.playerId}</span>
|
||||||
|
<span>{entry.successfulJumpCount} 跳</span>
|
||||||
|
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-[0.75rem] bg-white/60 px-2 py-2 text-xs font-bold text-[var(--platform-text-soft)]">
|
||||||
|
{error ?? '暂无成绩'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -252,7 +254,6 @@ export function JumpHopResultView({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onStartTestRun,
|
onStartTestRun,
|
||||||
onPublish,
|
onPublish,
|
||||||
onRegenerateCharacter,
|
|
||||||
onRegenerateTiles,
|
onRegenerateTiles,
|
||||||
}: JumpHopResultViewProps) {
|
}: JumpHopResultViewProps) {
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
@@ -264,12 +265,13 @@ export function JumpHopResultView({
|
|||||||
path: NonNullable<JumpHopDraftResponse['path']>;
|
path: NonNullable<JumpHopDraftResponse['path']>;
|
||||||
};
|
};
|
||||||
const path = isWorkProfile ? profile.path : safeDraft.path;
|
const path = isWorkProfile ? profile.path : safeDraft.path;
|
||||||
const characterAsset = isWorkProfile
|
|
||||||
? profile.characterAsset
|
|
||||||
: safeDraft.characterAsset;
|
|
||||||
const tileAtlasAsset = isWorkProfile
|
const tileAtlasAsset = isWorkProfile
|
||||||
? profile.tileAtlasAsset
|
? profile.tileAtlasAsset
|
||||||
: safeDraft.tileAtlasAsset;
|
: safeDraft.tileAtlasAsset;
|
||||||
|
const tileAssets = isWorkProfile ? profile.tileAssets : safeDraft.tileAssets;
|
||||||
|
const profileId = isWorkProfile
|
||||||
|
? profile.summary.profileId
|
||||||
|
: safeDraft.profileId;
|
||||||
const titleSource = isWorkProfile
|
const titleSource = isWorkProfile
|
||||||
? profile.summary.workTitle
|
? profile.summary.workTitle
|
||||||
: profile.workTitle;
|
: profile.workTitle;
|
||||||
@@ -278,15 +280,12 @@ export function JumpHopResultView({
|
|||||||
: profile.workDescription;
|
: profile.workDescription;
|
||||||
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
|
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
|
||||||
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
|
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
|
||||||
const pathPlatforms = normalizePathPlatforms(path);
|
|
||||||
const canRenderPathMiniMap = pathPlatforms.length > 0;
|
|
||||||
const hasAssets = Boolean(
|
const hasAssets = Boolean(
|
||||||
profile.characterImageSrc?.trim() ||
|
profile.tileAtlasImageSrc?.trim() ||
|
||||||
profile.tileAtlasImageSrc?.trim() ||
|
|
||||||
profile.pathPreviewImageSrc?.trim() ||
|
profile.pathPreviewImageSrc?.trim() ||
|
||||||
characterAsset?.imageSrc?.trim() ||
|
|
||||||
tileAtlasAsset?.imageSrc?.trim() ||
|
tileAtlasAsset?.imageSrc?.trim() ||
|
||||||
canRenderPathMiniMap,
|
tileAssets.length > 0 ||
|
||||||
|
path?.platforms.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
@@ -310,15 +309,6 @@ export function JumpHopResultView({
|
|||||||
返回
|
返回
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRegenerateCharacter}
|
|
||||||
disabled={isBusy}
|
|
||||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<RefreshCcw className="h-4 w-4" />
|
|
||||||
角色
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRegenerateTiles}
|
onClick={onRegenerateTiles}
|
||||||
@@ -343,69 +333,25 @@ export function JumpHopResultView({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||||
{profile.characterImageSrc || characterAsset?.imageSrc ? (
|
<JumpHopDefaultCharacterPreview />
|
||||||
<ResolvedAssetImage
|
|
||||||
src={
|
|
||||||
('characterImageSrc' in profile
|
|
||||||
? profile.characterImageSrc
|
|
||||||
: null) ??
|
|
||||||
characterAsset?.imageSrc ??
|
|
||||||
''
|
|
||||||
}
|
|
||||||
alt="角色图"
|
|
||||||
className="aspect-[1/1] w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
|
||||||
角色
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||||
{profile.tileAtlasImageSrc || tileAtlasAsset?.imageSrc ? (
|
<JumpHopTilePoolPreview
|
||||||
<ResolvedAssetImage
|
tileAssets={tileAssets}
|
||||||
src={
|
tileAtlasAsset={tileAtlasAsset}
|
||||||
('tileAtlasImageSrc' in profile
|
tileAtlasFallbackSrc={
|
||||||
? profile.tileAtlasImageSrc
|
('tileAtlasImageSrc' in profile
|
||||||
: null) ??
|
? profile.tileAtlasImageSrc
|
||||||
tileAtlasAsset?.imageSrc ??
|
: null) ??
|
||||||
''
|
null
|
||||||
}
|
}
|
||||||
alt="地块图"
|
/>
|
||||||
className="aspect-[1/1] w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
|
||||||
地块
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||||
{path && canRenderPathMiniMap ? (
|
<JumpHopFirstPlatformsPreview
|
||||||
<JumpHopPathMiniMap path={path} />
|
path={path}
|
||||||
) : 'pathPreviewImageSrc' in profile &&
|
tileAssets={tileAssets}
|
||||||
profile.pathPreviewImageSrc ? (
|
/>
|
||||||
<ResolvedAssetImage
|
|
||||||
src={profile.pathPreviewImageSrc}
|
|
||||||
alt="路径预览"
|
|
||||||
className="aspect-[1/1] w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : path ? (
|
|
||||||
<div className="grid aspect-[1/1] place-items-center px-3 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-3xl font-black text-[var(--platform-text-strong)]">
|
|
||||||
{path.platforms.length}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
|
||||||
{path.difficulty}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
|
||||||
路径
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!hasAssets ? (
|
{!hasAssets ? (
|
||||||
@@ -419,6 +365,7 @@ export function JumpHopResultView({
|
|||||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
结果操作
|
结果操作
|
||||||
</div>
|
</div>
|
||||||
|
<JumpHopResultLeaderboard profileId={profileId} />
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
919
src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
Normal file
919
src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,919 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||||
|
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
|
||||||
|
resolvedUrl: source?.trim() ?? '',
|
||||||
|
isResolving: false,
|
||||||
|
shouldResolve: Boolean(source?.trim().startsWith('/generated-')),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
|
||||||
|
useJumpHopLeaderboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile()}
|
||||||
|
run={run}
|
||||||
|
onJump={onJump}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile()}
|
||||||
|
run={run}
|
||||||
|
onJump={onJump}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile()}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={onJump}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile()}
|
||||||
|
run={buildRun()}
|
||||||
|
runtimeRequestOptions={runtimeRequestOptions}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={onJump}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={buildRun()}
|
||||||
|
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={initialRun}
|
||||||
|
onJump={onJump}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={nextRun}
|
||||||
|
onJump={onJump}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={initialRun}
|
||||||
|
onJump={onJump}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<JumpHopRuntimeShell
|
||||||
|
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||||
|
run={nextRun}
|
||||||
|
onJump={onJump}
|
||||||
|
onRestart={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveMiniGameGenerationProgressTickState,
|
||||||
|
} from './PlatformEntryFlowShellImpl';
|
||||||
|
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||||
|
|
||||||
|
describe('resolveMiniGameGenerationProgressTickState', () => {
|
||||||
|
test('returns jump hop and wooden fish generation states for progress ticking', () => {
|
||||||
|
const jumpHopState = createMiniGameDraftGenerationState('jump-hop');
|
||||||
|
const woodenFishState = createMiniGameDraftGenerationState('wooden-fish');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveMiniGameGenerationProgressTickState('jump-hop-generating', {
|
||||||
|
'jump-hop': jumpHopState,
|
||||||
|
}),
|
||||||
|
).toBe(jumpHopState);
|
||||||
|
expect(
|
||||||
|
resolveMiniGameGenerationProgressTickState('wooden-fish-generating', {
|
||||||
|
'wooden-fish': woodenFishState,
|
||||||
|
}),
|
||||||
|
).toBe(woodenFishState);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when the stage does not need generation ticking', () => {
|
||||||
|
expect(
|
||||||
|
resolveMiniGameGenerationProgressTickState('platform', {
|
||||||
|
'jump-hop': createMiniGameDraftGenerationState('jump-hop'),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,7 +38,10 @@ import type {
|
|||||||
BabyObjectMatchDraft,
|
BabyObjectMatchDraft,
|
||||||
CreateBabyObjectMatchDraftRequest,
|
CreateBabyObjectMatchDraftRequest,
|
||||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
import type {
|
||||||
|
JumpHopJumpRequest,
|
||||||
|
JumpHopWorkSummaryResponse,
|
||||||
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import type {
|
import type {
|
||||||
CreateMatch3DSessionRequest,
|
CreateMatch3DSessionRequest,
|
||||||
ExecuteMatch3DActionRequest,
|
ExecuteMatch3DActionRequest,
|
||||||
@@ -107,6 +110,7 @@ import type {
|
|||||||
VisualNovelWorkDetail,
|
VisualNovelWorkDetail,
|
||||||
VisualNovelWorkSummary,
|
VisualNovelWorkSummary,
|
||||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
|
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||||
import {
|
import {
|
||||||
buildPublicWorkStagePath,
|
buildPublicWorkStagePath,
|
||||||
@@ -186,6 +190,7 @@ import {
|
|||||||
jumpHopClient,
|
jumpHopClient,
|
||||||
type JumpHopGalleryCardResponse,
|
type JumpHopGalleryCardResponse,
|
||||||
type JumpHopRunResponse,
|
type JumpHopRunResponse,
|
||||||
|
type JumpHopRuntimeRequestOptions,
|
||||||
type JumpHopSessionResponse,
|
type JumpHopSessionResponse,
|
||||||
type JumpHopSessionSnapshotResponse,
|
type JumpHopSessionSnapshotResponse,
|
||||||
JumpHopWorkProfileResponse,
|
JumpHopWorkProfileResponse,
|
||||||
@@ -343,7 +348,6 @@ import {
|
|||||||
type WoodenFishWorkProfileResponse,
|
type WoodenFishWorkProfileResponse,
|
||||||
type WoodenFishWorkspaceCreateRequest,
|
type WoodenFishWorkspaceCreateRequest,
|
||||||
} from '../../services/wooden-fish/woodenFishClient';
|
} from '../../services/wooden-fish/woodenFishClient';
|
||||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { PublishShareModal } from '../common/PublishShareModal';
|
import { PublishShareModal } from '../common/PublishShareModal';
|
||||||
@@ -430,11 +434,11 @@ import {
|
|||||||
PlatformErrorDialog,
|
PlatformErrorDialog,
|
||||||
type PlatformErrorDialogPayload,
|
type PlatformErrorDialogPayload,
|
||||||
} from './PlatformErrorDialog';
|
} from './PlatformErrorDialog';
|
||||||
|
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||||
import {
|
import {
|
||||||
PlatformTaskCompletionDialog,
|
PlatformTaskCompletionDialog,
|
||||||
type PlatformTaskCompletionDialogPayload,
|
type PlatformTaskCompletionDialogPayload,
|
||||||
} from './PlatformTaskCompletionDialog';
|
} from './PlatformTaskCompletionDialog';
|
||||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
|
||||||
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
||||||
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
|
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
|
||||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||||
@@ -478,6 +482,30 @@ type PuzzleBackgroundCompileTask = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MiniGameGenerationProgressTickStateMap = Partial<
|
||||||
|
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function resolveMiniGameGenerationProgressTickState(
|
||||||
|
selectionStage: SelectionStage,
|
||||||
|
states: MiniGameGenerationProgressTickStateMap,
|
||||||
|
) {
|
||||||
|
const stageKindMap: Partial<
|
||||||
|
Record<SelectionStage, MiniGameDraftGenerationKind>
|
||||||
|
> = {
|
||||||
|
'puzzle-generating': 'puzzle',
|
||||||
|
'big-fish-generating': 'big-fish',
|
||||||
|
'square-hole-generating': 'square-hole',
|
||||||
|
'match3d-generating': 'match3d',
|
||||||
|
'baby-object-match-generating': 'baby-object-match',
|
||||||
|
'jump-hop-generating': 'jump-hop',
|
||||||
|
'wooden-fish-generating': 'wooden-fish',
|
||||||
|
};
|
||||||
|
const kind = stageKindMap[selectionStage];
|
||||||
|
|
||||||
|
return kind ? (states[kind] ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
type PuzzleDetailReturnTarget = {
|
type PuzzleDetailReturnTarget = {
|
||||||
tab: PlatformHomeTab;
|
tab: PlatformHomeTab;
|
||||||
};
|
};
|
||||||
@@ -580,11 +608,11 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
|||||||
'publish_missing_main_chapter',
|
'publish_missing_main_chapter',
|
||||||
'publish_missing_first_act',
|
'publish_missing_first_act',
|
||||||
]);
|
]);
|
||||||
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
|
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS: JumpHopRuntimeRequestOptions =
|
||||||
BACKGROUND_AUTH_REQUEST_OPTIONS;
|
BACKGROUND_AUTH_REQUEST_OPTIONS;
|
||||||
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS =
|
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS: JumpHopRuntimeRequestOptions =
|
||||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||||
async function buildRecommendRuntimeGuestOptions() {
|
async function buildRecommendRuntimeGuestOptions(): Promise<JumpHopRuntimeRequestOptions> {
|
||||||
const { token } = await ensureRuntimeGuestToken();
|
const { token } = await ensureRuntimeGuestToken();
|
||||||
return {
|
return {
|
||||||
...RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
...RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
@@ -599,9 +627,9 @@ function shouldUseRecommendRuntimeGuestAuth(
|
|||||||
async function buildRecommendRuntimeAuthOptions(
|
async function buildRecommendRuntimeAuthOptions(
|
||||||
authUi: { user?: { id?: string } | null } | null | undefined,
|
authUi: { user?: { id?: string } | null } | null | undefined,
|
||||||
embedded?: boolean,
|
embedded?: boolean,
|
||||||
) {
|
): Promise<JumpHopRuntimeRequestOptions> {
|
||||||
if (!embedded) {
|
if (!embedded) {
|
||||||
return {};
|
return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldUseRecommendRuntimeGuestAuth(authUi)) {
|
if (shouldUseRecommendRuntimeGuestAuth(authUi)) {
|
||||||
@@ -2517,6 +2545,7 @@ function buildPendingJumpHopWorks(
|
|||||||
profileId: `jump-hop-profile-${sessionId}`,
|
profileId: `jump-hop-profile-${sessionId}`,
|
||||||
ownerUserId: '',
|
ownerUserId: '',
|
||||||
sourceSessionId: sessionId,
|
sourceSessionId: sessionId,
|
||||||
|
themeText: '跳一跳',
|
||||||
workTitle: '跳一跳草稿',
|
workTitle: '跳一跳草稿',
|
||||||
workDescription: '正在生成跳一跳玩法草稿。',
|
workDescription: '正在生成跳一跳玩法草稿。',
|
||||||
themeTags: [],
|
themeTags: [],
|
||||||
@@ -3233,6 +3262,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const [jumpHopRun, setJumpHopRun] = useState<
|
const [jumpHopRun, setJumpHopRun] = useState<
|
||||||
JumpHopRunResponse['run'] | null
|
JumpHopRunResponse['run'] | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [jumpHopRuntimeRequestOptions, setJumpHopRuntimeRequestOptions] =
|
||||||
|
useState<JumpHopRuntimeRequestOptions | null>(null);
|
||||||
const [jumpHopWork, setJumpHopWork] =
|
const [jumpHopWork, setJumpHopWork] =
|
||||||
useState<JumpHopWorkProfileResponse | null>(null);
|
useState<JumpHopWorkProfileResponse | null>(null);
|
||||||
const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState<
|
const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState<
|
||||||
@@ -4779,14 +4810,18 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeGenerationState =
|
const activeGenerationState = resolveMiniGameGenerationProgressTickState(
|
||||||
selectionStage === 'puzzle-generating'
|
selectionStage,
|
||||||
? puzzleGenerationState
|
{
|
||||||
: selectionStage === 'match3d-generating'
|
puzzle: puzzleGenerationState,
|
||||||
? match3dGenerationState
|
'big-fish': bigFishGenerationState,
|
||||||
: selectionStage === 'baby-object-match-generating'
|
'square-hole': squareHoleGenerationState,
|
||||||
? babyObjectMatchGenerationState
|
match3d: match3dGenerationState,
|
||||||
: null;
|
'baby-object-match': babyObjectMatchGenerationState,
|
||||||
|
'jump-hop': jumpHopGenerationState,
|
||||||
|
'wooden-fish': woodenFishGenerationState,
|
||||||
|
},
|
||||||
|
);
|
||||||
const shouldTickProgress =
|
const shouldTickProgress =
|
||||||
selectionStage === 'visual-novel-generating'
|
selectionStage === 'visual-novel-generating'
|
||||||
? visualNovelGenerationStartedAtMs != null &&
|
? visualNovelGenerationStartedAtMs != null &&
|
||||||
@@ -4808,11 +4843,15 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return () => window.clearInterval(timerId);
|
return () => window.clearInterval(timerId);
|
||||||
}, [
|
}, [
|
||||||
babyObjectMatchGenerationState,
|
babyObjectMatchGenerationState,
|
||||||
|
bigFishGenerationState,
|
||||||
|
jumpHopGenerationState,
|
||||||
match3dGenerationState,
|
match3dGenerationState,
|
||||||
puzzleGenerationState,
|
puzzleGenerationState,
|
||||||
selectionStage,
|
selectionStage,
|
||||||
|
squareHoleGenerationState,
|
||||||
visualNovelGenerationPhase,
|
visualNovelGenerationPhase,
|
||||||
visualNovelGenerationStartedAtMs,
|
visualNovelGenerationStartedAtMs,
|
||||||
|
woodenFishGenerationState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const runProtectedAction = useCallback(
|
const runProtectedAction = useCallback(
|
||||||
@@ -6615,6 +6654,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setJumpHopSession(null);
|
setJumpHopSession(null);
|
||||||
setJumpHopWork(null);
|
setJumpHopWork(null);
|
||||||
setJumpHopRun(null);
|
setJumpHopRun(null);
|
||||||
|
setJumpHopRuntimeRequestOptions(null);
|
||||||
setJumpHopGenerationState(null);
|
setJumpHopGenerationState(null);
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
setShowCreationTypeModal(false);
|
setShowCreationTypeModal(false);
|
||||||
@@ -7629,6 +7669,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setJumpHopRuntimeReturnStage('jump-hop-result');
|
setJumpHopRuntimeReturnStage('jump-hop-result');
|
||||||
setJumpHopGenerationState(null);
|
setJumpHopGenerationState(null);
|
||||||
setJumpHopSession(null);
|
setJumpHopSession(null);
|
||||||
|
setJumpHopRuntimeRequestOptions(null);
|
||||||
setJumpHopError(null);
|
setJumpHopError(null);
|
||||||
returnToCreationFlowSource();
|
returnToCreationFlowSource();
|
||||||
}, [returnToCreationFlowSource]);
|
}, [returnToCreationFlowSource]);
|
||||||
@@ -8642,6 +8683,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
);
|
);
|
||||||
setJumpHopWork(null);
|
setJumpHopWork(null);
|
||||||
setJumpHopRun(null);
|
setJumpHopRun(null);
|
||||||
|
setJumpHopRuntimeRequestOptions(null);
|
||||||
setJumpHopGenerationState(generationState);
|
setJumpHopGenerationState(generationState);
|
||||||
setIsJumpHopBusy(true);
|
setIsJumpHopBusy(true);
|
||||||
setSelectionStage('jump-hop-generating');
|
setSelectionStage('jump-hop-generating');
|
||||||
@@ -8651,6 +8693,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
created.session.sessionId,
|
created.session.sessionId,
|
||||||
{
|
{
|
||||||
actionType: 'compile-draft',
|
actionType: 'compile-draft',
|
||||||
|
themeText:
|
||||||
|
payload?.themeText ?? created.session.draft?.themeText,
|
||||||
workTitle: payload?.workTitle ?? created.session.draft?.workTitle,
|
workTitle: payload?.workTitle ?? created.session.draft?.workTitle,
|
||||||
workDescription:
|
workDescription:
|
||||||
payload?.workDescription ??
|
payload?.workDescription ??
|
||||||
@@ -8742,7 +8786,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}, [compileJumpHopSession, jumpHopSession, setSelectionStage]);
|
}, [compileJumpHopSession, jumpHopSession, setSelectionStage]);
|
||||||
|
|
||||||
const regenerateJumpHopAsset = useCallback(
|
const regenerateJumpHopAsset = useCallback(
|
||||||
async (actionType: 'regenerate-character' | 'regenerate-tiles') => {
|
async (actionType: 'regenerate-tiles') => {
|
||||||
if (!jumpHopSession?.sessionId) {
|
if (!jumpHopSession?.sessionId) {
|
||||||
setSelectionStage('jump-hop-workspace');
|
setSelectionStage('jump-hop-workspace');
|
||||||
return;
|
return;
|
||||||
@@ -8758,6 +8802,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
jumpHopSession.sessionId,
|
jumpHopSession.sessionId,
|
||||||
{
|
{
|
||||||
actionType,
|
actionType,
|
||||||
|
profileId:
|
||||||
|
jumpHopWork?.summary.profileId ?? jumpHopSession.draft?.profileId,
|
||||||
|
themeText: jumpHopSession.draft?.themeText,
|
||||||
workTitle: jumpHopSession.draft?.workTitle,
|
workTitle: jumpHopSession.draft?.workTitle,
|
||||||
workDescription: jumpHopSession.draft?.workDescription,
|
workDescription: jumpHopSession.draft?.workDescription,
|
||||||
themeTags: jumpHopSession.draft?.themeTags,
|
themeTags: jumpHopSession.draft?.themeTags,
|
||||||
@@ -8783,9 +8830,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = resolveRpgCreationErrorMessage(
|
const errorMessage = resolveRpgCreationErrorMessage(
|
||||||
error,
|
error,
|
||||||
actionType === 'regenerate-character'
|
'重新生成跳一跳地块失败。',
|
||||||
? '重新生成跳一跳角色失败。'
|
|
||||||
: '重新生成跳一跳地块失败。',
|
|
||||||
);
|
);
|
||||||
setJumpHopError(errorMessage);
|
setJumpHopError(errorMessage);
|
||||||
setJumpHopGenerationState(
|
setJumpHopGenerationState(
|
||||||
@@ -8858,7 +8903,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setJumpHopError(null);
|
setJumpHopError(null);
|
||||||
setJumpHopRuntimeReturnStage('jump-hop-result');
|
setJumpHopRuntimeReturnStage('jump-hop-result');
|
||||||
try {
|
try {
|
||||||
const response = await jumpHopClient.startRun(profileId);
|
const response = await jumpHopClient.startRun(profileId, {
|
||||||
|
runtimeMode: 'draft',
|
||||||
|
});
|
||||||
setJumpHopRun(response.run);
|
setJumpHopRun(response.run);
|
||||||
setSelectionStage('jump-hop-runtime');
|
setSelectionStage('jump-hop-runtime');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -8889,13 +8936,30 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setJumpHopError(null);
|
setJumpHopError(null);
|
||||||
setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail');
|
setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail');
|
||||||
try {
|
try {
|
||||||
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
|
const runtimeGuestOptions =
|
||||||
authUi,
|
options.embedded || shouldUseRecommendRuntimeGuestAuth(authUi)
|
||||||
options.embedded,
|
? await buildRecommendRuntimeAuthOptions(authUi, true)
|
||||||
|
: RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||||
|
setJumpHopRuntimeRequestOptions(
|
||||||
|
runtimeGuestOptions.runtimeGuestToken?.trim()
|
||||||
|
? {
|
||||||
|
runtimeGuestToken: runtimeGuestOptions.runtimeGuestToken,
|
||||||
|
authImpact: runtimeGuestOptions.authImpact,
|
||||||
|
skipAuth: runtimeGuestOptions.skipAuth,
|
||||||
|
skipRefresh: runtimeGuestOptions.skipRefresh,
|
||||||
|
notifyAuthStateChange:
|
||||||
|
runtimeGuestOptions.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized:
|
||||||
|
runtimeGuestOptions.clearAuthOnUnauthorized,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
const [detail, runResponse] = await Promise.all([
|
const [detail, runResponse] = await Promise.all([
|
||||||
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
|
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
|
||||||
jumpHopClient.startRun(normalizedProfileId, runtimeGuestOptions),
|
jumpHopClient.startRun(normalizedProfileId, {
|
||||||
|
...runtimeGuestOptions,
|
||||||
|
runtimeMode: 'published',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
if (detail?.item) {
|
if (detail?.item) {
|
||||||
setJumpHopWork(detail.item);
|
setJumpHopWork(detail.item);
|
||||||
@@ -8933,7 +8997,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setIsJumpHopBusy(true);
|
setIsJumpHopBusy(true);
|
||||||
setJumpHopError(null);
|
setJumpHopError(null);
|
||||||
try {
|
try {
|
||||||
const response = await jumpHopClient.restartRun(runId);
|
const response = await jumpHopClient.restartRun(
|
||||||
|
runId,
|
||||||
|
jumpHopRuntimeRequestOptions ?? undefined,
|
||||||
|
);
|
||||||
setJumpHopRun(response.run);
|
setJumpHopRun(response.run);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setJumpHopError(
|
setJumpHopError(
|
||||||
@@ -8942,16 +9009,29 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsJumpHopBusy(false);
|
setIsJumpHopBusy(false);
|
||||||
}
|
}
|
||||||
}, [jumpHopRun?.runId, startJumpHopTestRunFromProfile]);
|
}, [
|
||||||
|
jumpHopRun?.runId,
|
||||||
|
jumpHopRuntimeRequestOptions,
|
||||||
|
startJumpHopTestRunFromProfile,
|
||||||
|
]);
|
||||||
|
|
||||||
const submitJumpHopJumpAction = useCallback(
|
const submitJumpHopJumpAction = useCallback(
|
||||||
async (payload: { chargeMs: number }) => {
|
async (
|
||||||
|
payload: Pick<
|
||||||
|
JumpHopJumpRequest,
|
||||||
|
'dragDistance' | 'dragVectorX' | 'dragVectorY'
|
||||||
|
>,
|
||||||
|
) => {
|
||||||
const runId = jumpHopRun?.runId;
|
const runId = jumpHopRun?.runId;
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await jumpHopClient.submitJump(runId, payload);
|
const response = await jumpHopClient.submitJump(
|
||||||
|
runId,
|
||||||
|
payload,
|
||||||
|
jumpHopRuntimeRequestOptions ?? undefined,
|
||||||
|
);
|
||||||
setJumpHopRun(response.run);
|
setJumpHopRun(response.run);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setJumpHopError(
|
setJumpHopError(
|
||||||
@@ -8959,7 +9039,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[jumpHopRun?.runId],
|
[jumpHopRun?.runId, jumpHopRuntimeRequestOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const compileWoodenFishSession = useCallback(
|
const compileWoodenFishSession = useCallback(
|
||||||
@@ -13073,6 +13153,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) {
|
||||||
|
setPublicWorkDetailError(null);
|
||||||
|
void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, {
|
||||||
|
returnStage: 'work-detail',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
if (isBigFishGalleryEntry(selectedPublicWorkDetail)) {
|
if (isBigFishGalleryEntry(selectedPublicWorkDetail)) {
|
||||||
const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail);
|
const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail);
|
||||||
@@ -13107,14 +13195,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) {
|
|
||||||
setPublicWorkDetailError(null);
|
|
||||||
void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, {
|
|
||||||
returnStage: 'work-detail',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) {
|
if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) {
|
||||||
setPublicWorkDetailError(null);
|
setPublicWorkDetailError(null);
|
||||||
void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, {
|
void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, {
|
||||||
@@ -13616,37 +13696,15 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
run={jumpHopRun}
|
run={jumpHopRun}
|
||||||
isBusy={isJumpHopBusy}
|
isBusy={isJumpHopBusy}
|
||||||
error={jumpHopError}
|
error={jumpHopError}
|
||||||
|
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setActiveRecommendRuntimeKind(null);
|
setActiveRecommendRuntimeKind(null);
|
||||||
}}
|
}}
|
||||||
onRestart={() => {
|
onRestart={() => {
|
||||||
if (!jumpHopRun?.runId || isJumpHopBusy) {
|
void restartJumpHopRuntimeRun();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsJumpHopBusy(true);
|
|
||||||
setJumpHopError(null);
|
|
||||||
void jumpHopClient
|
|
||||||
.restartRun(jumpHopRun.runId)
|
|
||||||
.then((response) => {
|
|
||||||
setJumpHopRun(response.run);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setJumpHopError(
|
|
||||||
resolveRpgCreationErrorMessage(error, '重新开始跳一跳失败。'),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsJumpHopBusy(false);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
onJump={async (payload) => {
|
onJump={async (payload) => {
|
||||||
const runId = jumpHopRun?.runId;
|
await submitJumpHopJumpAction(payload);
|
||||||
if (!runId) {
|
|
||||||
throw new Error('跳一跳运行态缺少 runId。');
|
|
||||||
}
|
|
||||||
const response = await jumpHopClient.submitJump(runId, payload);
|
|
||||||
setJumpHopRun(response.run);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -15516,6 +15574,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
)}
|
)}
|
||||||
progress={buildMiniGameDraftGenerationProgress(
|
progress={buildMiniGameDraftGenerationProgress(
|
||||||
bigFishGenerationState,
|
bigFishGenerationState,
|
||||||
|
miniGameGenerationProgressNowMs,
|
||||||
)}
|
)}
|
||||||
isGenerating={isBigFishBusy}
|
isGenerating={isBigFishBusy}
|
||||||
error={bigFishError}
|
error={bigFishError}
|
||||||
@@ -16110,6 +16169,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
)}
|
)}
|
||||||
progress={buildMiniGameDraftGenerationProgress(
|
progress={buildMiniGameDraftGenerationProgress(
|
||||||
squareHoleGenerationState,
|
squareHoleGenerationState,
|
||||||
|
miniGameGenerationProgressNowMs,
|
||||||
)}
|
)}
|
||||||
isGenerating={isSquareHoleBusy}
|
isGenerating={isSquareHoleBusy}
|
||||||
error={squareHoleError}
|
error={squareHoleError}
|
||||||
@@ -16320,6 +16380,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
)}
|
)}
|
||||||
progress={buildMiniGameDraftGenerationProgress(
|
progress={buildMiniGameDraftGenerationProgress(
|
||||||
jumpHopGenerationState,
|
jumpHopGenerationState,
|
||||||
|
miniGameGenerationProgressNowMs,
|
||||||
)}
|
)}
|
||||||
isGenerating={isJumpHopBusy}
|
isGenerating={isJumpHopBusy}
|
||||||
error={jumpHopError}
|
error={jumpHopError}
|
||||||
@@ -16363,9 +16424,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}}
|
}}
|
||||||
onStartTestRun={startJumpHopTestRunFromProfile}
|
onStartTestRun={startJumpHopTestRunFromProfile}
|
||||||
onPublish={publishJumpHopDraft}
|
onPublish={publishJumpHopDraft}
|
||||||
onRegenerateCharacter={() => {
|
|
||||||
void regenerateJumpHopAsset('regenerate-character');
|
|
||||||
}}
|
|
||||||
onRegenerateTiles={() => {
|
onRegenerateTiles={() => {
|
||||||
void regenerateJumpHopAsset('regenerate-tiles');
|
void regenerateJumpHopAsset('regenerate-tiles');
|
||||||
}}
|
}}
|
||||||
@@ -16390,6 +16448,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
profile={jumpHopWork}
|
profile={jumpHopWork}
|
||||||
isBusy={isJumpHopBusy}
|
isBusy={isJumpHopBusy}
|
||||||
error={jumpHopError}
|
error={jumpHopError}
|
||||||
|
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setSelectionStage(jumpHopRuntimeReturnStage);
|
setSelectionStage(jumpHopRuntimeReturnStage);
|
||||||
}}
|
}}
|
||||||
@@ -16448,6 +16507,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
)}
|
)}
|
||||||
progress={buildMiniGameDraftGenerationProgress(
|
progress={buildMiniGameDraftGenerationProgress(
|
||||||
woodenFishGenerationState,
|
woodenFishGenerationState,
|
||||||
|
miniGameGenerationProgressNowMs,
|
||||||
)}
|
)}
|
||||||
isGenerating={isWoodenFishBusy}
|
isGenerating={isWoodenFishBusy}
|
||||||
error={woodenFishError}
|
error={woodenFishError}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import type {
|
|||||||
CustomWorldAgentSessionSnapshot,
|
CustomWorldAgentSessionSnapshot,
|
||||||
CustomWorldWorkSummary,
|
CustomWorldWorkSummary,
|
||||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||||
|
import type {
|
||||||
|
JumpHopRuntimeRunSnapshotResponse,
|
||||||
|
JumpHopWorkProfileResponse,
|
||||||
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import type {
|
import type {
|
||||||
BabyObjectMatchDraft,
|
BabyObjectMatchDraft,
|
||||||
CreateBabyObjectMatchDraftRequest,
|
CreateBabyObjectMatchDraftRequest,
|
||||||
@@ -63,6 +67,7 @@ import {
|
|||||||
submitBigFishInput,
|
submitBigFishInput,
|
||||||
} from '../../services/big-fish-runtime';
|
} from '../../services/big-fish-runtime';
|
||||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||||
|
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||||
import {
|
import {
|
||||||
type CreationEntryConfig,
|
type CreationEntryConfig,
|
||||||
fetchCreationEntryConfig,
|
fetchCreationEntryConfig,
|
||||||
@@ -167,6 +172,7 @@ import {
|
|||||||
} from '../../services/square-hole-works';
|
} from '../../services/square-hole-works';
|
||||||
import { listVisualNovelGallery } from '../../services/visual-novel-runtime';
|
import { listVisualNovelGallery } from '../../services/visual-novel-runtime';
|
||||||
import { listVisualNovelWorks } from '../../services/visual-novel-works';
|
import { listVisualNovelWorks } from '../../services/visual-novel-works';
|
||||||
|
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
|
||||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||||
import {
|
import {
|
||||||
AuthUiContext,
|
AuthUiContext,
|
||||||
@@ -579,6 +585,42 @@ vi.mock('../../services/puzzle-runtime', () => ({
|
|||||||
usePuzzleRuntimeProp: vi.fn(),
|
usePuzzleRuntimeProp: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
|
||||||
|
jumpHopClient: {
|
||||||
|
getGalleryDetail: vi.fn(),
|
||||||
|
getLeaderboard: vi.fn(),
|
||||||
|
getSession: vi.fn(),
|
||||||
|
getWorkDetail: vi.fn(),
|
||||||
|
listGallery: vi.fn(),
|
||||||
|
listWorks: vi.fn(),
|
||||||
|
publishWork: vi.fn(),
|
||||||
|
restartRun: vi.fn(),
|
||||||
|
startRun: vi.fn(),
|
||||||
|
submitJump: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||||
|
woodenFishClient: {
|
||||||
|
checkpointRun: vi.fn(),
|
||||||
|
createSession: vi.fn(),
|
||||||
|
executeAction: vi.fn(),
|
||||||
|
finishRun: vi.fn(),
|
||||||
|
getGalleryDetail: vi.fn(),
|
||||||
|
getSession: vi.fn(),
|
||||||
|
getWorkDetail: vi.fn(),
|
||||||
|
listGallery: vi.fn(async () => ({
|
||||||
|
hasMore: false,
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
})),
|
||||||
|
listWorks: vi.fn(async () => ({ items: [] })),
|
||||||
|
publishWork: vi.fn(),
|
||||||
|
restartRun: vi.fn(),
|
||||||
|
startRun: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
||||||
...rpgEntryLibraryServiceMocks,
|
...rpgEntryLibraryServiceMocks,
|
||||||
}));
|
}));
|
||||||
@@ -2518,6 +2560,12 @@ beforeEach(() => {
|
|||||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||||
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
|
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
|
||||||
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
|
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
|
||||||
|
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
|
||||||
|
hasMore: false,
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
|
||||||
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
|
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
|
||||||
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
|
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
|
||||||
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
|
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
|
||||||
@@ -4557,7 +4605,7 @@ test('match3d result trial passes generated models into first runtime mount', as
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||||
'match3d-profile-draft-1',
|
'match3d-profile-draft-1',
|
||||||
{},
|
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -4650,7 +4698,7 @@ test('match3d result trial passes generated 2D image views into first runtime mo
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||||
'match3d-profile-draft-2d-1',
|
'match3d-profile-draft-2d-1',
|
||||||
{},
|
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -5680,10 +5728,10 @@ test('opening a compiled draft with a missing agent session falls back to draft
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
|
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
|
||||||
expect(
|
expect(
|
||||||
within(fallbackDraftPanel).getByText(
|
screen.getAllByText(
|
||||||
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
|
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
|
||||||
),
|
).length,
|
||||||
).toBeTruthy();
|
).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(window.location.search).toBe('');
|
expect(window.location.search).toBe('');
|
||||||
@@ -5799,6 +5847,213 @@ test('logged out public detail gates puzzle start and remix before real actions'
|
|||||||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('logged out public jump-hop detail starts runtime without requireAuth', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const requireAuth = vi.fn();
|
||||||
|
const publishedJumpHopWork: JumpHopWorkProfileResponse = {
|
||||||
|
summary: {
|
||||||
|
runtimeKind: 'jump-hop',
|
||||||
|
workId: 'jump-hop-work-public-1',
|
||||||
|
profileId: 'jump-hop-profile-public-12345678',
|
||||||
|
ownerUserId: 'user-2',
|
||||||
|
sourceSessionId: 'jump-hop-session-public-1',
|
||||||
|
themeText: '云上方块',
|
||||||
|
workTitle: '云上方块跳一跳',
|
||||||
|
workDescription: '在云层地块之间连续弹跳。',
|
||||||
|
themeTags: ['云层', '跳跃'],
|
||||||
|
difficulty: 'standard',
|
||||||
|
stylePreset: 'paper-toy',
|
||||||
|
coverImageSrc: null,
|
||||||
|
publicationStatus: 'published',
|
||||||
|
playCount: 3,
|
||||||
|
updatedAt: '2026-05-29T10:00:00.000Z',
|
||||||
|
publishedAt: '2026-05-29T10:00:00.000Z',
|
||||||
|
publishReady: true,
|
||||||
|
generationStatus: 'ready',
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
templateId: 'jump-hop',
|
||||||
|
templateName: '跳一跳',
|
||||||
|
profileId: 'jump-hop-profile-public-12345678',
|
||||||
|
themeText: '云上方块',
|
||||||
|
workTitle: '云上方块跳一跳',
|
||||||
|
workDescription: '在云层地块之间连续弹跳。',
|
||||||
|
themeTags: ['云层', '跳跃'],
|
||||||
|
difficulty: 'standard',
|
||||||
|
stylePreset: 'paper-toy',
|
||||||
|
defaultCharacter: {
|
||||||
|
characterId: 'builtin-default',
|
||||||
|
displayName: '默认角色',
|
||||||
|
modelKind: 'builtin-three',
|
||||||
|
bodyColor: '#df7f40',
|
||||||
|
accentColor: '#2563eb',
|
||||||
|
},
|
||||||
|
characterPrompt: '',
|
||||||
|
tilePrompt: '云上方块',
|
||||||
|
endMoodPrompt: null,
|
||||||
|
characterAsset: null,
|
||||||
|
tileAtlasAsset: null,
|
||||||
|
tileAssets: [],
|
||||||
|
path: null,
|
||||||
|
coverComposite: null,
|
||||||
|
generationStatus: 'ready',
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
seed: 'jump-hop-public-seed',
|
||||||
|
difficulty: 'standard',
|
||||||
|
platforms: [
|
||||||
|
{
|
||||||
|
platformId: 'platform-0',
|
||||||
|
tileType: 'start',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
landingRadius: 0.7,
|
||||||
|
perfectRadius: 0.25,
|
||||||
|
scoreValue: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformId: 'platform-1',
|
||||||
|
tileType: 'normal',
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
landingRadius: 0.7,
|
||||||
|
perfectRadius: 0.25,
|
||||||
|
scoreValue: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platformId: 'platform-2',
|
||||||
|
tileType: 'normal',
|
||||||
|
x: -1,
|
||||||
|
y: 2,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
landingRadius: 0.7,
|
||||||
|
perfectRadius: 0.25,
|
||||||
|
scoreValue: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
finishIndex: 2,
|
||||||
|
cameraPreset: 'portrait-top-down',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 1,
|
||||||
|
maxChargeMs: 1800,
|
||||||
|
hitBonus: 0,
|
||||||
|
perfectBonus: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultCharacter: {
|
||||||
|
characterId: 'builtin-default',
|
||||||
|
displayName: '默认角色',
|
||||||
|
modelKind: 'builtin-three',
|
||||||
|
bodyColor: '#df7f40',
|
||||||
|
accentColor: '#2563eb',
|
||||||
|
},
|
||||||
|
characterAsset: {
|
||||||
|
assetId: 'builtin-character',
|
||||||
|
imageSrc: '',
|
||||||
|
imageObjectKey: '',
|
||||||
|
assetObjectId: '',
|
||||||
|
generationProvider: 'builtin',
|
||||||
|
prompt: '',
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
tileAtlasAsset: {
|
||||||
|
assetId: 'tile-atlas-1',
|
||||||
|
imageSrc: '/generated-jump-hop-assets/public/atlas.png',
|
||||||
|
imageObjectKey: 'generated-jump-hop-assets/public/atlas.png',
|
||||||
|
assetObjectId: 'asset-tile-atlas-1',
|
||||||
|
generationProvider: 'gpt-image-2',
|
||||||
|
prompt: '云上方块',
|
||||||
|
width: 1024,
|
||||||
|
height: 1024,
|
||||||
|
},
|
||||||
|
tileAssets: [],
|
||||||
|
};
|
||||||
|
const publishedJumpHopRun: JumpHopRuntimeRunSnapshotResponse = {
|
||||||
|
runId: 'jump-hop-run-public-1',
|
||||||
|
profileId: publishedJumpHopWork.summary.profileId,
|
||||||
|
ownerUserId: '',
|
||||||
|
status: 'playing',
|
||||||
|
currentPlatformIndex: 0,
|
||||||
|
successfulJumpCount: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
score: 0,
|
||||||
|
combo: 0,
|
||||||
|
path: publishedJumpHopWork.path,
|
||||||
|
lastJump: null,
|
||||||
|
startedAtMs: 1_779_999_000_000,
|
||||||
|
finishedAtMs: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.history.replaceState(null, '', '/works/detail?work=JH-12345678');
|
||||||
|
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
publicWorkCode: 'JH-12345678',
|
||||||
|
workId: publishedJumpHopWork.summary.workId,
|
||||||
|
profileId: publishedJumpHopWork.summary.profileId,
|
||||||
|
ownerUserId: publishedJumpHopWork.summary.ownerUserId,
|
||||||
|
authorDisplayName: '跳跃作者',
|
||||||
|
themeText: publishedJumpHopWork.summary.themeText,
|
||||||
|
workTitle: publishedJumpHopWork.summary.workTitle,
|
||||||
|
workDescription: publishedJumpHopWork.summary.workDescription,
|
||||||
|
coverImageSrc: null,
|
||||||
|
themeTags: publishedJumpHopWork.summary.themeTags,
|
||||||
|
difficulty: publishedJumpHopWork.summary.difficulty,
|
||||||
|
stylePreset: publishedJumpHopWork.summary.stylePreset,
|
||||||
|
publicationStatus: publishedJumpHopWork.summary.publicationStatus,
|
||||||
|
playCount: publishedJumpHopWork.summary.playCount,
|
||||||
|
updatedAt: publishedJumpHopWork.summary.updatedAt,
|
||||||
|
publishedAt: publishedJumpHopWork.summary.publishedAt,
|
||||||
|
generationStatus: publishedJumpHopWork.summary.generationStatus,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValue({
|
||||||
|
item: publishedJumpHopWork,
|
||||||
|
});
|
||||||
|
vi.mocked(jumpHopClient.startRun).mockResolvedValue({
|
||||||
|
run: publishedJumpHopRun,
|
||||||
|
});
|
||||||
|
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue({
|
||||||
|
profileId: publishedJumpHopWork.summary.profileId,
|
||||||
|
items: [],
|
||||||
|
viewerBest: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper
|
||||||
|
authValue={createAuthValue({
|
||||||
|
user: null,
|
||||||
|
canAccessProtectedData: false,
|
||||||
|
openLoginModal: () => {},
|
||||||
|
requireAuth,
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('详情')).toBeTruthy();
|
||||||
|
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(jumpHopClient.startRun).toHaveBeenCalledWith(
|
||||||
|
publishedJumpHopWork.summary.profileId,
|
||||||
|
expect.objectContaining({
|
||||||
|
runtimeGuestToken: 'runtime-guest-token',
|
||||||
|
runtimeMode: 'published',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(requireAuth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test('owned public puzzle detail edits original draft instead of remixing', async () => {
|
test('owned public puzzle detail edits original draft instead of remixing', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const ownedPuzzleWork = {
|
const ownedPuzzleWork = {
|
||||||
@@ -7239,8 +7494,8 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
|
|||||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||||
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
expect(createCreativeAgentSession).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText('当前登录状态已失效,请重新登录后继续。'),
|
(await screen.findAllByText('当前登录状态已失效,请重新登录后继续。')).length,
|
||||||
).toBeTruthy();
|
).toBeGreaterThan(0);
|
||||||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -8350,7 +8605,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||||
'match3d-profile-public-1',
|
'match3d-profile-public-1',
|
||||||
{},
|
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -8422,7 +8677,7 @@ test('published Match3D runtime receives persisted generated models', async () =
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||||
'match3d-profile-model-1',
|
'match3d-profile-model-1',
|
||||||
{},
|
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -10697,8 +10952,9 @@ test('creation hub published work card reveals delete action after card action r
|
|||||||
publishedCard.focus();
|
publishedCard.focus();
|
||||||
await user.keyboard('{ArrowLeft}');
|
await user.keyboard('{ArrowLeft}');
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
|
||||||
await user.click(screen.getByRole('button', { name: '删除' }));
|
expect(deleteButtons.length).toBeGreaterThan(0);
|
||||||
|
await user.click(deleteButtons[0]!);
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
|
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
|
||||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
JumpHopGalleryCardResponse,
|
JumpHopGalleryCardResponse,
|
||||||
JumpHopGalleryDetailResponse,
|
JumpHopGalleryDetailResponse,
|
||||||
JumpHopGalleryResponse,
|
JumpHopGalleryResponse,
|
||||||
|
JumpHopLeaderboardResponse,
|
||||||
JumpHopRunResponse,
|
JumpHopRunResponse,
|
||||||
JumpHopRuntimeRunSnapshotResponse,
|
JumpHopRuntimeRunSnapshotResponse,
|
||||||
JumpHopSessionResponse,
|
JumpHopSessionResponse,
|
||||||
@@ -12,8 +13,8 @@ import type {
|
|||||||
JumpHopWorkDetailResponse,
|
JumpHopWorkDetailResponse,
|
||||||
JumpHopWorkMutationResponse,
|
JumpHopWorkMutationResponse,
|
||||||
JumpHopWorkProfileResponse,
|
JumpHopWorkProfileResponse,
|
||||||
JumpHopWorksResponse,
|
|
||||||
JumpHopWorkspaceCreateRequest,
|
JumpHopWorkspaceCreateRequest,
|
||||||
|
JumpHopWorksResponse,
|
||||||
JumpHopWorkSummaryResponse,
|
JumpHopWorkSummaryResponse,
|
||||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +36,16 @@ const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
|||||||
baseDelayMs: 120,
|
baseDelayMs: 120,
|
||||||
maxDelayMs: 360,
|
maxDelayMs: 360,
|
||||||
};
|
};
|
||||||
type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
|
export type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
|
||||||
|
type JumpHopRuntimeMode = 'draft' | 'published';
|
||||||
|
type JumpHopStartRunOptions = JumpHopRuntimeRequestOptions & {
|
||||||
|
runtimeMode?: JumpHopRuntimeMode;
|
||||||
|
};
|
||||||
|
type JumpHopJumpPayload = {
|
||||||
|
dragDistance: number;
|
||||||
|
dragVectorX?: number;
|
||||||
|
dragVectorY?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
JumpHopActionRequest,
|
JumpHopActionRequest,
|
||||||
@@ -44,6 +54,7 @@ export type {
|
|||||||
JumpHopGalleryCardResponse,
|
JumpHopGalleryCardResponse,
|
||||||
JumpHopGalleryDetailResponse,
|
JumpHopGalleryDetailResponse,
|
||||||
JumpHopGalleryResponse,
|
JumpHopGalleryResponse,
|
||||||
|
JumpHopLeaderboardResponse,
|
||||||
JumpHopRunResponse,
|
JumpHopRunResponse,
|
||||||
JumpHopRuntimeRunSnapshotResponse,
|
JumpHopRuntimeRunSnapshotResponse,
|
||||||
JumpHopSessionResponse,
|
JumpHopSessionResponse,
|
||||||
@@ -51,16 +62,10 @@ export type {
|
|||||||
JumpHopWorkDetailResponse,
|
JumpHopWorkDetailResponse,
|
||||||
JumpHopWorkMutationResponse,
|
JumpHopWorkMutationResponse,
|
||||||
JumpHopWorkProfileResponse,
|
JumpHopWorkProfileResponse,
|
||||||
JumpHopWorksResponse,
|
|
||||||
JumpHopWorkspaceCreateRequest,
|
JumpHopWorkspaceCreateRequest,
|
||||||
|
JumpHopWorksResponse,
|
||||||
};
|
};
|
||||||
export type CreateJumpHopSessionRequest = {
|
export type CreateJumpHopSessionRequest = JumpHopWorkspaceCreateRequest;
|
||||||
themeText: string;
|
|
||||||
characterDescription: string;
|
|
||||||
tileStyle: string;
|
|
||||||
difficulty: string;
|
|
||||||
rhythmPreference: string;
|
|
||||||
};
|
|
||||||
export type ExecuteJumpHopActionRequest = JumpHopActionRequest;
|
export type ExecuteJumpHopActionRequest = JumpHopActionRequest;
|
||||||
export type JumpHopSessionSnapshot = JumpHopSessionSnapshotResponse;
|
export type JumpHopSessionSnapshot = JumpHopSessionSnapshotResponse;
|
||||||
|
|
||||||
@@ -104,6 +109,7 @@ function normalizeJumpHopWorkProfile(
|
|||||||
profileId: flattened.profileId,
|
profileId: flattened.profileId,
|
||||||
ownerUserId: flattened.ownerUserId,
|
ownerUserId: flattened.ownerUserId,
|
||||||
sourceSessionId: flattened.sourceSessionId ?? null,
|
sourceSessionId: flattened.sourceSessionId ?? null,
|
||||||
|
themeText: flattened.themeText || flattened.workTitle,
|
||||||
workTitle: flattened.workTitle,
|
workTitle: flattened.workTitle,
|
||||||
workDescription: flattened.workDescription,
|
workDescription: flattened.workDescription,
|
||||||
themeTags: flattened.themeTags,
|
themeTags: flattened.themeTags,
|
||||||
@@ -122,6 +128,7 @@ function normalizeJumpHopWorkProfile(
|
|||||||
summary,
|
summary,
|
||||||
draft: flattened.draft,
|
draft: flattened.draft,
|
||||||
path: flattened.path,
|
path: flattened.path,
|
||||||
|
defaultCharacter: flattened.defaultCharacter ?? flattened.draft?.defaultCharacter,
|
||||||
characterAsset: flattened.characterAsset,
|
characterAsset: flattened.characterAsset,
|
||||||
tileAtlasAsset: flattened.tileAtlasAsset,
|
tileAtlasAsset: flattened.tileAtlasAsset,
|
||||||
tileAssets: flattened.tileAssets,
|
tileAssets: flattened.tileAssets,
|
||||||
@@ -232,9 +239,10 @@ export async function publishJumpHopWork(profileId: string) {
|
|||||||
|
|
||||||
export async function startJumpHopRuntimeRun(
|
export async function startJumpHopRuntimeRun(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
options: JumpHopRuntimeRequestOptions = {},
|
options: JumpHopStartRunOptions = {},
|
||||||
) {
|
) {
|
||||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||||
|
const runtimeMode = options.runtimeMode ?? 'published';
|
||||||
return requestJson<JumpHopRunResponse>(
|
return requestJson<JumpHopRunResponse>(
|
||||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
||||||
{
|
{
|
||||||
@@ -243,7 +251,7 @@ export async function startJumpHopRuntimeRun(
|
|||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
...buildRuntimeGuestHeaders(options),
|
...buildRuntimeGuestHeaders(options),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ profileId }),
|
body: JSON.stringify({ profileId, runtimeMode }),
|
||||||
},
|
},
|
||||||
'启动跳一跳运行态失败',
|
'启动跳一跳运行态失败',
|
||||||
{
|
{
|
||||||
@@ -254,12 +262,14 @@ export async function startJumpHopRuntimeRun(
|
|||||||
|
|
||||||
export async function submitJumpHopJump(
|
export async function submitJumpHopJump(
|
||||||
runId: string,
|
runId: string,
|
||||||
payload: { chargeMs: number },
|
payload: JumpHopJumpPayload,
|
||||||
options: JumpHopRuntimeRequestOptions = {},
|
options: JumpHopRuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
chargeMs: payload.chargeMs,
|
dragDistance: payload.dragDistance,
|
||||||
|
dragVectorX: payload.dragVectorX,
|
||||||
|
dragVectorY: payload.dragVectorY,
|
||||||
clientEventId: `jump-${runId}-${Date.now()}`,
|
clientEventId: `jump-${runId}-${Date.now()}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,6 +288,22 @@ export async function submitJumpHopJump(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getJumpHopLeaderboard(
|
||||||
|
profileId: string,
|
||||||
|
options: JumpHopRuntimeRequestOptions = {},
|
||||||
|
) {
|
||||||
|
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||||
|
return requestJson<JumpHopLeaderboardResponse>(
|
||||||
|
`${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/leaderboard`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: buildRuntimeGuestHeaders(options),
|
||||||
|
},
|
||||||
|
'读取跳一跳排行榜失败',
|
||||||
|
requestOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function restartJumpHopRuntimeRun(
|
export async function restartJumpHopRuntimeRun(
|
||||||
runId: string,
|
runId: string,
|
||||||
options: JumpHopRuntimeRequestOptions = {},
|
options: JumpHopRuntimeRequestOptions = {},
|
||||||
@@ -309,6 +335,7 @@ export const jumpHopClient = {
|
|||||||
listGallery: listJumpHopGallery,
|
listGallery: listJumpHopGallery,
|
||||||
listWorks: listJumpHopWorks,
|
listWorks: listJumpHopWorks,
|
||||||
publishWork: publishJumpHopWork,
|
publishWork: publishJumpHopWork,
|
||||||
|
getLeaderboard: getJumpHopLeaderboard,
|
||||||
restartRun: restartJumpHopRuntimeRun,
|
restartRun: restartJumpHopRuntimeRun,
|
||||||
startRun: startJumpHopRuntimeRun,
|
startRun: startJumpHopRuntimeRun,
|
||||||
submitJump: submitJumpHopJump,
|
submitJump: submitJumpHopJump,
|
||||||
|
|||||||
498
src/services/jump-hop/jumpHopRuntimeModel.test.ts
Normal file
498
src/services/jump-hop/jumpHopRuntimeModel.test.ts
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import { expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
JumpHopPath,
|
||||||
|
JumpHopTileAsset,
|
||||||
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
|
import {
|
||||||
|
buildJumpHopVisiblePlatforms,
|
||||||
|
getJumpHopBackendDragVector,
|
||||||
|
getJumpHopCharacterVisualPosition,
|
||||||
|
getJumpHopJumpFeedbackLabel,
|
||||||
|
getJumpHopLandingAssistVisualPosition,
|
||||||
|
getJumpHopPlatformVisualSize,
|
||||||
|
getJumpHopStatusLabel,
|
||||||
|
resolveJumpHopCharacterCanvasPosition,
|
||||||
|
selectJumpHopTileAsset,
|
||||||
|
} from './jumpHopRuntimeModel';
|
||||||
|
|
||||||
|
test('跳一跳地块池按平台编号从 25 个素材中抽取而不是按类型压扁', () => {
|
||||||
|
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
|
||||||
|
tileType: 'normal',
|
||||||
|
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
|
||||||
|
imageSrc: `asset-${index + 1}`,
|
||||||
|
imageObjectKey: `key-${index + 1}`,
|
||||||
|
assetObjectId: `object-${index + 1}`,
|
||||||
|
sourceAtlasCell: `row-1-col-${index + 1}`,
|
||||||
|
atlasRow: 1,
|
||||||
|
atlasCol: index + 1,
|
||||||
|
visualWidth: 256,
|
||||||
|
visualHeight: 192,
|
||||||
|
topSurfaceRadius: 42,
|
||||||
|
landingRadius: 34,
|
||||||
|
})) satisfies JumpHopTileAsset[];
|
||||||
|
|
||||||
|
const first = selectJumpHopTileAsset(tileAssets, '森林茶馆', 1, 'platform-1');
|
||||||
|
const second = selectJumpHopTileAsset(tileAssets, '森林茶馆', 2, 'platform-2');
|
||||||
|
|
||||||
|
expect(first?.imageSrc).not.toBe(second?.imageSrc);
|
||||||
|
expect(first?.imageSrc).toMatch(/^asset-/);
|
||||||
|
expect(second?.imageSrc).toMatch(/^asset-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材', () => {
|
||||||
|
const path: JumpHopPath = {
|
||||||
|
seed: 'forest-tea',
|
||||||
|
difficulty: 'standard',
|
||||||
|
finishIndex: 999,
|
||||||
|
cameraPreset: 'portrait-isometric-9x16',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 0.004,
|
||||||
|
maxChargeMs: 900,
|
||||||
|
hitBonus: 20,
|
||||||
|
perfectBonus: 60,
|
||||||
|
},
|
||||||
|
platforms: [
|
||||||
|
platform(0, 0, 'start'),
|
||||||
|
platform(1.2, 1.8, 'normal'),
|
||||||
|
platform(-0.3, 3.5, 'target'),
|
||||||
|
platform(0.8, 5.1, 'normal'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
|
||||||
|
tileType: 'normal',
|
||||||
|
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
|
||||||
|
imageSrc: `asset-${index + 1}`,
|
||||||
|
imageObjectKey: `key-${index + 1}`,
|
||||||
|
assetObjectId: `object-${index + 1}`,
|
||||||
|
sourceAtlasCell: `row-1-col-${index + 1}`,
|
||||||
|
visualWidth: 256,
|
||||||
|
visualHeight: 192,
|
||||||
|
topSurfaceRadius: 42,
|
||||||
|
landingRadius: 34,
|
||||||
|
})) satisfies JumpHopTileAsset[];
|
||||||
|
|
||||||
|
const visible = buildJumpHopVisiblePlatforms(path, 1, tileAssets);
|
||||||
|
|
||||||
|
expect(visible).toHaveLength(3);
|
||||||
|
expect(visible[0]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||||
|
expect(visible[1]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||||
|
expect(visible[2]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳三块可见地块按下方中部上方展开且角色落在当前地块上', () => {
|
||||||
|
const path: JumpHopPath = {
|
||||||
|
seed: 'forest-tea',
|
||||||
|
difficulty: 'standard',
|
||||||
|
finishIndex: 999,
|
||||||
|
cameraPreset: 'portrait-isometric-9x16',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 0.004,
|
||||||
|
maxChargeMs: 900,
|
||||||
|
hitBonus: 20,
|
||||||
|
perfectBonus: 60,
|
||||||
|
},
|
||||||
|
platforms: [
|
||||||
|
platform(0, 0, 'start'),
|
||||||
|
platform(0.8, 1.2, 'normal'),
|
||||||
|
platform(-0.2, 2.4, 'target'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||||
|
const character = getJumpHopCharacterVisualPosition(
|
||||||
|
{
|
||||||
|
runId: 'run-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
status: 'playing',
|
||||||
|
currentPlatformIndex: 0,
|
||||||
|
successfulJumpCount: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
score: 0,
|
||||||
|
combo: 0,
|
||||||
|
path,
|
||||||
|
lastJump: null,
|
||||||
|
startedAtMs: 1000,
|
||||||
|
finishedAtMs: null,
|
||||||
|
},
|
||||||
|
visible,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(visible[0]?.screenY).toBeGreaterThanOrEqual(68);
|
||||||
|
expect(visible[0]?.screenY).toBeLessThanOrEqual(80);
|
||||||
|
expect(visible[1]?.screenY).toBeGreaterThanOrEqual(40);
|
||||||
|
expect(visible[1]?.screenY).toBeLessThan(visible[0]?.screenY ?? 0);
|
||||||
|
expect(visible[2]?.screenY).toBeLessThan(visible[1]?.screenY ?? 0);
|
||||||
|
expect(visible[2]?.screenY).toBeLessThanOrEqual(26);
|
||||||
|
expect(Math.abs((visible[1]?.screenX ?? 0) - (visible[0]?.screenX ?? 0))).toBeGreaterThan(5);
|
||||||
|
expect(Math.abs((visible[2]?.screenX ?? 0) - (visible[1]?.screenX ?? 0))).toBeGreaterThan(5);
|
||||||
|
expect(character?.screenX).toBeCloseTo(visible[0]?.screenX ?? 0, 1);
|
||||||
|
expect(character?.screenY).toBeCloseTo((visible[0]?.screenY ?? 0) - 3, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳可见地块按深度保留不同视觉尺寸', () => {
|
||||||
|
const path: JumpHopPath = {
|
||||||
|
seed: 'forest-tea',
|
||||||
|
difficulty: 'standard',
|
||||||
|
finishIndex: 999,
|
||||||
|
cameraPreset: 'portrait-isometric-9x16',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 0.004,
|
||||||
|
maxChargeMs: 900,
|
||||||
|
hitBonus: 20,
|
||||||
|
perfectBonus: 60,
|
||||||
|
},
|
||||||
|
platforms: [
|
||||||
|
platform(0, 0, 'start'),
|
||||||
|
platform(0.8, 1.2, 'normal'),
|
||||||
|
platform(-0.2, 2.4, 'target'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||||
|
const currentSize = getJumpHopPlatformVisualSize(
|
||||||
|
visible[0]!.platform,
|
||||||
|
visible[0]!.scale,
|
||||||
|
);
|
||||||
|
const targetSize = getJumpHopPlatformVisualSize(
|
||||||
|
visible[1]!.platform,
|
||||||
|
visible[1]!.scale,
|
||||||
|
);
|
||||||
|
const previewSize = getJumpHopPlatformVisualSize(
|
||||||
|
visible[2]!.platform,
|
||||||
|
visible[2]!.scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(currentSize.width).toBeGreaterThan(targetSize.width);
|
||||||
|
expect(targetSize.width).toBeGreaterThan(previewSize.width);
|
||||||
|
expect(currentSize.height).toBeGreaterThan(targetSize.height);
|
||||||
|
expect(targetSize.height).toBeGreaterThan(previewSize.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起始地块', () => {
|
||||||
|
const path: JumpHopPath = {
|
||||||
|
seed: 'forest-tea',
|
||||||
|
difficulty: 'standard',
|
||||||
|
finishIndex: 999,
|
||||||
|
cameraPreset: 'portrait-isometric-9x16',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 0.004,
|
||||||
|
maxChargeMs: 900,
|
||||||
|
hitBonus: 20,
|
||||||
|
perfectBonus: 60,
|
||||||
|
},
|
||||||
|
platforms: [
|
||||||
|
platform(0, 0, 'start'),
|
||||||
|
platform(0.8, 1.2, 'normal'),
|
||||||
|
platform(-0.2, 2.4, 'target'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||||
|
const character = getJumpHopCharacterVisualPosition(
|
||||||
|
{
|
||||||
|
runId: 'run-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
status: 'playing',
|
||||||
|
currentPlatformIndex: 0,
|
||||||
|
successfulJumpCount: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
score: 0,
|
||||||
|
combo: 0,
|
||||||
|
path,
|
||||||
|
lastJump: null,
|
||||||
|
startedAtMs: 1000,
|
||||||
|
finishedAtMs: null,
|
||||||
|
},
|
||||||
|
visible,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvasPosition = resolveJumpHopCharacterCanvasPosition(character, {
|
||||||
|
width: 320,
|
||||||
|
height: 568,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(canvasPosition?.x).toBeGreaterThan(140);
|
||||||
|
expect(canvasPosition?.x).toBeLessThan(180);
|
||||||
|
expect(canvasPosition?.y).toBeGreaterThan(380);
|
||||||
|
expect(canvasPosition?.y).toBeLessThan(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍', () => {
|
||||||
|
const size = getJumpHopPlatformVisualSize(platform(0, 0, 'start'), 1.08);
|
||||||
|
|
||||||
|
expect(size.width).toBeCloseTo(125.28, 2);
|
||||||
|
expect(size.height).toBeCloseTo(103.68, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离投影', () => {
|
||||||
|
const path: JumpHopPath = {
|
||||||
|
seed: 'forest-tea',
|
||||||
|
difficulty: 'standard',
|
||||||
|
finishIndex: 999,
|
||||||
|
cameraPreset: 'portrait-isometric-9x16',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 0.004,
|
||||||
|
maxChargeMs: 900,
|
||||||
|
hitBonus: 20,
|
||||||
|
perfectBonus: 60,
|
||||||
|
},
|
||||||
|
platforms: [
|
||||||
|
platform(0, 0, 'start'),
|
||||||
|
platform(0.8, 1.2, 'normal'),
|
||||||
|
platform(-0.2, 2.4, 'target'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const run = {
|
||||||
|
runId: 'run-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
status: 'playing',
|
||||||
|
currentPlatformIndex: 0,
|
||||||
|
successfulJumpCount: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
score: 0,
|
||||||
|
combo: 0,
|
||||||
|
path,
|
||||||
|
lastJump: null,
|
||||||
|
startedAtMs: 1000,
|
||||||
|
finishedAtMs: null,
|
||||||
|
} as const;
|
||||||
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||||
|
const character = getJumpHopCharacterVisualPosition(run, visible);
|
||||||
|
const current = visible[0]!;
|
||||||
|
const target = visible[1]!;
|
||||||
|
const stageSize = { width: 320, height: 568 };
|
||||||
|
const currentCanvasPosition = {
|
||||||
|
x: (current.screenX / 100) * stageSize.width,
|
||||||
|
y: (current.screenY / 100) * stageSize.height,
|
||||||
|
};
|
||||||
|
const targetCanvasPosition = {
|
||||||
|
x: (target.screenX / 100) * stageSize.width,
|
||||||
|
y: (target.screenY / 100) * stageSize.height,
|
||||||
|
};
|
||||||
|
const targetWorldDistance = Math.hypot(
|
||||||
|
target.platform.x - current.platform.x,
|
||||||
|
target.platform.y - current.platform.y,
|
||||||
|
);
|
||||||
|
const fullDragDistance =
|
||||||
|
targetWorldDistance / path.scoring.chargeToDistanceRatio;
|
||||||
|
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||||
|
const dragVectorY = -(targetCanvasPosition.y - currentCanvasPosition.y);
|
||||||
|
|
||||||
|
const fullAssist = getJumpHopLandingAssistVisualPosition(
|
||||||
|
run,
|
||||||
|
visible,
|
||||||
|
character,
|
||||||
|
stageSize,
|
||||||
|
fullDragDistance,
|
||||||
|
dragVectorX,
|
||||||
|
dragVectorY,
|
||||||
|
);
|
||||||
|
const halfAssist = getJumpHopLandingAssistVisualPosition(
|
||||||
|
run,
|
||||||
|
visible,
|
||||||
|
character,
|
||||||
|
stageSize,
|
||||||
|
fullDragDistance / 2,
|
||||||
|
dragVectorX,
|
||||||
|
dragVectorY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 1);
|
||||||
|
expect(fullAssist?.screenY).toBeCloseTo(target.screenY, 1);
|
||||||
|
expect(halfAssist?.screenX).toBeCloseTo(
|
||||||
|
current.screenX + (target.screenX - current.screenX) / 2,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(halfAssist?.screenY).toBeCloseTo(
|
||||||
|
current.screenY + (target.screenY - current.screenY) / 2,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方目标地块', () => {
|
||||||
|
const path: JumpHopPath = {
|
||||||
|
seed: 'forest-tea',
|
||||||
|
difficulty: 'standard',
|
||||||
|
finishIndex: 999,
|
||||||
|
cameraPreset: 'portrait-isometric-9x16',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 0.004,
|
||||||
|
maxChargeMs: 900,
|
||||||
|
hitBonus: 20,
|
||||||
|
perfectBonus: 60,
|
||||||
|
},
|
||||||
|
platforms: [
|
||||||
|
platform(0, 0, 'start'),
|
||||||
|
platform(0.8, 1.2, 'normal'),
|
||||||
|
platform(-0.2, 2.4, 'target'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const run = {
|
||||||
|
runId: 'run-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
status: 'playing',
|
||||||
|
currentPlatformIndex: 0,
|
||||||
|
successfulJumpCount: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
score: 0,
|
||||||
|
combo: 0,
|
||||||
|
path,
|
||||||
|
lastJump: null,
|
||||||
|
startedAtMs: 1000,
|
||||||
|
finishedAtMs: null,
|
||||||
|
} as const;
|
||||||
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||||
|
const character = getJumpHopCharacterVisualPosition(run, visible);
|
||||||
|
const current = visible[0]!;
|
||||||
|
const target = visible[1]!;
|
||||||
|
const stageSize = { width: 320, height: 568 };
|
||||||
|
const currentCanvasPosition = {
|
||||||
|
x: (current.screenX / 100) * stageSize.width,
|
||||||
|
y: (current.screenY / 100) * stageSize.height,
|
||||||
|
};
|
||||||
|
const targetCanvasPosition = {
|
||||||
|
x: (target.screenX / 100) * stageSize.width,
|
||||||
|
y: (target.screenY / 100) * stageSize.height,
|
||||||
|
};
|
||||||
|
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||||
|
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
|
||||||
|
const targetWorldDistance = Math.hypot(
|
||||||
|
target.platform.x - current.platform.x,
|
||||||
|
target.platform.y - current.platform.y,
|
||||||
|
);
|
||||||
|
const fullDragDistance =
|
||||||
|
targetWorldDistance / path.scoring.chargeToDistanceRatio;
|
||||||
|
|
||||||
|
const assist = getJumpHopLandingAssistVisualPosition(
|
||||||
|
run,
|
||||||
|
visible,
|
||||||
|
character,
|
||||||
|
stageSize,
|
||||||
|
fullDragDistance,
|
||||||
|
dragVectorX,
|
||||||
|
dragVectorY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dragVectorY).toBeGreaterThan(0);
|
||||||
|
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
|
||||||
|
expect(assist?.screenY).toBeCloseTo(target.screenY, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳后端落点向量会把屏幕拖拽换算为世界尺度一致的反向弹射', () => {
|
||||||
|
const path: JumpHopPath = {
|
||||||
|
seed: 'forest-tea',
|
||||||
|
difficulty: 'standard',
|
||||||
|
finishIndex: 999,
|
||||||
|
cameraPreset: 'portrait-isometric-9x16',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 0.004,
|
||||||
|
maxChargeMs: 900,
|
||||||
|
hitBonus: 20,
|
||||||
|
perfectBonus: 60,
|
||||||
|
},
|
||||||
|
platforms: [
|
||||||
|
platform(0, 0, 'start'),
|
||||||
|
platform(0.8, 1.2, 'normal'),
|
||||||
|
platform(-0.2, 2.4, 'target'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const run = {
|
||||||
|
runId: 'run-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
status: 'playing',
|
||||||
|
currentPlatformIndex: 0,
|
||||||
|
successfulJumpCount: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
score: 0,
|
||||||
|
combo: 0,
|
||||||
|
path,
|
||||||
|
lastJump: null,
|
||||||
|
startedAtMs: 1000,
|
||||||
|
finishedAtMs: null,
|
||||||
|
} as const;
|
||||||
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||||
|
const current = visible[0]!;
|
||||||
|
const target = visible[1]!;
|
||||||
|
const stageSize = { width: 320, height: 568 };
|
||||||
|
const currentCanvasPosition = {
|
||||||
|
x: (current.screenX / 100) * stageSize.width,
|
||||||
|
y: (current.screenY / 100) * stageSize.height,
|
||||||
|
};
|
||||||
|
const targetCanvasPosition = {
|
||||||
|
x: (target.screenX / 100) * stageSize.width,
|
||||||
|
y: (target.screenY / 100) * stageSize.height,
|
||||||
|
};
|
||||||
|
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||||
|
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
|
||||||
|
const backendVector = getJumpHopBackendDragVector(
|
||||||
|
run,
|
||||||
|
visible,
|
||||||
|
stageSize,
|
||||||
|
dragVectorX,
|
||||||
|
dragVectorY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(backendVector.dragVectorX).toBeLessThan(0);
|
||||||
|
expect(backendVector.dragVectorY).toBeGreaterThan(0);
|
||||||
|
expect(Math.abs(backendVector.dragVectorY)).toBeLessThan(Math.abs(dragVectorY));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳运行态公开反馈不再展示旧 perfect 和通关语义', () => {
|
||||||
|
expect(getJumpHopStatusLabel('cleared')).toBe('结束');
|
||||||
|
expect(
|
||||||
|
getJumpHopJumpFeedbackLabel({
|
||||||
|
runId: 'run-1',
|
||||||
|
profileId: 'profile-1',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
status: 'playing',
|
||||||
|
currentPlatformIndex: 1,
|
||||||
|
successfulJumpCount: 1,
|
||||||
|
durationMs: 0,
|
||||||
|
score: 1,
|
||||||
|
combo: 0,
|
||||||
|
path: {
|
||||||
|
seed: 'forest-tea',
|
||||||
|
difficulty: 'standard',
|
||||||
|
finishIndex: 999,
|
||||||
|
cameraPreset: 'portrait-isometric-9x16',
|
||||||
|
scoring: {
|
||||||
|
chargeToDistanceRatio: 0.004,
|
||||||
|
maxChargeMs: 900,
|
||||||
|
hitBonus: 20,
|
||||||
|
perfectBonus: 60,
|
||||||
|
},
|
||||||
|
platforms: [platform(0, 0, 'start'), platform(1.2, 1.8, 'normal')],
|
||||||
|
},
|
||||||
|
lastJump: {
|
||||||
|
chargeMs: 300,
|
||||||
|
jumpDistance: 1.2,
|
||||||
|
targetPlatformIndex: 1,
|
||||||
|
landedX: 1.2,
|
||||||
|
landedY: 1.8,
|
||||||
|
result: 'perfect',
|
||||||
|
},
|
||||||
|
startedAtMs: 1000,
|
||||||
|
finishedAtMs: null,
|
||||||
|
}),
|
||||||
|
).toBe('落地');
|
||||||
|
});
|
||||||
|
|
||||||
|
function platform(x: number, y: number, tileType: 'start' | 'normal' | 'target') {
|
||||||
|
return {
|
||||||
|
platformId: `platform-${x}-${y}`,
|
||||||
|
tileType,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
landingRadius: 0.5,
|
||||||
|
perfectRadius: 0.2,
|
||||||
|
scoreValue: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
479
src/services/jump-hop/jumpHopRuntimeModel.ts
Normal file
479
src/services/jump-hop/jumpHopRuntimeModel.ts
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
import type {
|
||||||
|
JumpHopPath,
|
||||||
|
JumpHopPlatform,
|
||||||
|
JumpHopRunStatus,
|
||||||
|
JumpHopRuntimeRunSnapshotResponse,
|
||||||
|
JumpHopTileAsset,
|
||||||
|
JumpHopTileType,
|
||||||
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
|
|
||||||
|
export type JumpHopVisiblePlatform = {
|
||||||
|
platform: JumpHopPlatform;
|
||||||
|
index: number;
|
||||||
|
screenX: number;
|
||||||
|
screenY: number;
|
||||||
|
sceneX: number;
|
||||||
|
sceneY: number;
|
||||||
|
sceneZ: number;
|
||||||
|
scale: number;
|
||||||
|
asset: JumpHopTileAsset | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JumpHopCharacterVisualPosition = {
|
||||||
|
screenX: number;
|
||||||
|
screenY: number;
|
||||||
|
sceneX: number;
|
||||||
|
sceneY: number;
|
||||||
|
sceneZ: number;
|
||||||
|
isMiss: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JumpHopCanvasSize = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JumpHopPlatformVisualSize = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JumpHopLandingAssistVisualPosition = {
|
||||||
|
screenX: number;
|
||||||
|
screenY: number;
|
||||||
|
targetPlatformIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JumpHopBackendDragVector = {
|
||||||
|
dragVectorX: number;
|
||||||
|
dragVectorY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBLE_PLATFORM_COUNT = 3;
|
||||||
|
const JUMP_HOP_STAGE_WORLD_SCALE = 4.2;
|
||||||
|
const JUMP_HOP_STAGE_FORWARD_SCALE = 3;
|
||||||
|
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [78, 50, 22] as const;
|
||||||
|
const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2;
|
||||||
|
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 16 * 0.96;
|
||||||
|
|
||||||
|
const tileToneByType: Record<JumpHopTileType, string> = {
|
||||||
|
accent: '#e0f2fe',
|
||||||
|
bonus: '#fef3c7',
|
||||||
|
finish: '#dcfce7',
|
||||||
|
normal: '#f8fafc',
|
||||||
|
start: '#e0f2fe',
|
||||||
|
target: '#fee2e2',
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashJumpHopString(value: string) {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (const character of value) {
|
||||||
|
hash ^= character.codePointAt(0) ?? 0;
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectJumpHopTileAsset(
|
||||||
|
tileAssets: JumpHopTileAsset[] | null | undefined,
|
||||||
|
seedText: string | null | undefined,
|
||||||
|
platformIndex: number,
|
||||||
|
platformId: string,
|
||||||
|
) {
|
||||||
|
const pool = (tileAssets ?? []).filter(Boolean);
|
||||||
|
if (pool.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSeed = seedText?.trim() || 'jump-hop';
|
||||||
|
const signature = `${normalizedSeed}:${platformIndex}:${platformId}`;
|
||||||
|
const selectedIndex = hashJumpHopString(signature) % pool.length;
|
||||||
|
return pool[selectedIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJumpHopVisiblePlatforms(
|
||||||
|
path: JumpHopPath | null | undefined,
|
||||||
|
currentPlatformIndex: number,
|
||||||
|
tileAssets: JumpHopTileAsset[] | null | undefined,
|
||||||
|
) {
|
||||||
|
const platforms = path?.platforms ?? [];
|
||||||
|
const current = platforms[currentPlatformIndex] ?? platforms[0];
|
||||||
|
if (!current) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, currentPlatformIndex);
|
||||||
|
const end = Math.min(platforms.length, currentPlatformIndex + VISIBLE_PLATFORM_COUNT);
|
||||||
|
const visible = platforms.slice(start, end);
|
||||||
|
const worldScale = 0.96;
|
||||||
|
|
||||||
|
return visible.map((platform, offset): JumpHopVisiblePlatform => {
|
||||||
|
const index = start + offset;
|
||||||
|
const dx = platform.x - current.x;
|
||||||
|
const dy = platform.y - current.y;
|
||||||
|
const depth = index - currentPlatformIndex;
|
||||||
|
const asset = selectJumpHopTileAsset(
|
||||||
|
tileAssets,
|
||||||
|
path?.seed ?? null,
|
||||||
|
index,
|
||||||
|
platform.platformId,
|
||||||
|
);
|
||||||
|
const screenY =
|
||||||
|
depth <= 0
|
||||||
|
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0]
|
||||||
|
: depth === 1
|
||||||
|
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]
|
||||||
|
: JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2];
|
||||||
|
const screenX = clamp(50 + dx * 16 * worldScale, 14, 86);
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
index,
|
||||||
|
screenX,
|
||||||
|
screenY,
|
||||||
|
sceneX: dx * JUMP_HOP_STAGE_WORLD_SCALE,
|
||||||
|
sceneY: 0,
|
||||||
|
sceneZ: dy * JUMP_HOP_STAGE_FORWARD_SCALE,
|
||||||
|
scale: clamp(1.08 - Math.max(0, depth) * 0.12, 0.8, 1.1),
|
||||||
|
asset,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJumpHopPlatformVisualSize(
|
||||||
|
platform: JumpHopPlatform,
|
||||||
|
scale: number,
|
||||||
|
): JumpHopPlatformVisualSize {
|
||||||
|
return {
|
||||||
|
width:
|
||||||
|
clamp(platform.width * 0.96, 58, 118) *
|
||||||
|
scale *
|
||||||
|
JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER,
|
||||||
|
height:
|
||||||
|
clamp(platform.height * 0.78, 48, 92) *
|
||||||
|
scale *
|
||||||
|
JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJumpHopCurrentTargetPlatforms(
|
||||||
|
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||||
|
platforms: JumpHopVisiblePlatform[],
|
||||||
|
) {
|
||||||
|
if (!run) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = run.currentPlatformIndex;
|
||||||
|
const currentPlatform =
|
||||||
|
platforms.find((item) => item.index === currentIndex) ??
|
||||||
|
platforms[0] ??
|
||||||
|
null;
|
||||||
|
const targetPlatform =
|
||||||
|
platforms.find((item) => item.index === currentIndex + 1) ??
|
||||||
|
platforms[1] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (!currentPlatform || !targetPlatform) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPlatform,
|
||||||
|
targetPlatform,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJumpHopCanvasPosition(
|
||||||
|
platform: JumpHopVisiblePlatform,
|
||||||
|
stageSize: JumpHopCanvasSize,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
x: (platform.screenX / 100) * stageSize.width,
|
||||||
|
y: (platform.screenY / 100) * stageSize.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJumpHopScreenWorldScales(
|
||||||
|
currentPlatform: JumpHopVisiblePlatform,
|
||||||
|
targetPlatform: JumpHopVisiblePlatform,
|
||||||
|
stageSize: JumpHopCanvasSize,
|
||||||
|
) {
|
||||||
|
const currentCanvasPosition = getJumpHopCanvasPosition(
|
||||||
|
currentPlatform,
|
||||||
|
stageSize,
|
||||||
|
);
|
||||||
|
const targetCanvasPosition = getJumpHopCanvasPosition(
|
||||||
|
targetPlatform,
|
||||||
|
stageSize,
|
||||||
|
);
|
||||||
|
const targetWorldDeltaX =
|
||||||
|
targetPlatform.platform.x - currentPlatform.platform.x;
|
||||||
|
const targetWorldDeltaY =
|
||||||
|
targetPlatform.platform.y - currentPlatform.platform.y;
|
||||||
|
const targetScreenDeltaX = targetCanvasPosition.x - currentCanvasPosition.x;
|
||||||
|
const targetScreenDeltaY = targetCanvasPosition.y - currentCanvasPosition.y;
|
||||||
|
const targetWorldDistance = Math.hypot(targetWorldDeltaX, targetWorldDeltaY);
|
||||||
|
const targetScreenDistance = Math.hypot(
|
||||||
|
targetScreenDeltaX,
|
||||||
|
targetScreenDeltaY,
|
||||||
|
);
|
||||||
|
const fallbackPixelsPerWorldUnit =
|
||||||
|
targetWorldDistance > 0.0001 && targetScreenDistance > 0.0001
|
||||||
|
? targetScreenDistance / targetWorldDistance
|
||||||
|
: stageSize.height * 0.18;
|
||||||
|
const xPixelsPerWorldUnit =
|
||||||
|
Math.abs(targetWorldDeltaX) > 0.0001 &&
|
||||||
|
Math.abs(targetScreenDeltaX) > 0.0001
|
||||||
|
? Math.abs(targetScreenDeltaX / targetWorldDeltaX)
|
||||||
|
: Math.max(stageSize.width * (JUMP_HOP_SCREEN_X_WORLD_PERCENT / 100), 1);
|
||||||
|
const yPixelsPerWorldUnit =
|
||||||
|
Math.abs(targetWorldDeltaY) > 0.0001 &&
|
||||||
|
Math.abs(targetScreenDeltaY) > 0.0001
|
||||||
|
? Math.abs(targetScreenDeltaY / targetWorldDeltaY)
|
||||||
|
: fallbackPixelsPerWorldUnit;
|
||||||
|
const signedXScreenPerWorld =
|
||||||
|
Math.abs(targetWorldDeltaX) > 0.0001 &&
|
||||||
|
Math.abs(targetScreenDeltaX) > 0.0001
|
||||||
|
? targetScreenDeltaX / targetWorldDeltaX
|
||||||
|
: xPixelsPerWorldUnit;
|
||||||
|
const signedYScreenPerWorld =
|
||||||
|
Math.abs(targetWorldDeltaY) > 0.0001 &&
|
||||||
|
Math.abs(targetScreenDeltaY) > 0.0001
|
||||||
|
? targetScreenDeltaY / targetWorldDeltaY
|
||||||
|
: -yPixelsPerWorldUnit;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentCanvasPosition,
|
||||||
|
targetPlatform,
|
||||||
|
xPixelsPerWorldUnit,
|
||||||
|
yPixelsPerWorldUnit: Math.max(yPixelsPerWorldUnit, 1),
|
||||||
|
signedXScreenPerWorld,
|
||||||
|
signedYScreenPerWorld,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJumpHopBackendDragVector(
|
||||||
|
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||||
|
platforms: JumpHopVisiblePlatform[],
|
||||||
|
stageSize: JumpHopCanvasSize,
|
||||||
|
dragVectorX: number,
|
||||||
|
dragVectorY: number,
|
||||||
|
): JumpHopBackendDragVector {
|
||||||
|
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
|
||||||
|
if (!pair || stageSize.width <= 0 || stageSize.height <= 0) {
|
||||||
|
return {
|
||||||
|
dragVectorX,
|
||||||
|
dragVectorY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scales = getJumpHopScreenWorldScales(
|
||||||
|
pair.currentPlatform,
|
||||||
|
pair.targetPlatform,
|
||||||
|
stageSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragVectorX: dragVectorX / scales.xPixelsPerWorldUnit,
|
||||||
|
dragVectorY: dragVectorY / scales.yPixelsPerWorldUnit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJumpHopLandingAssistVisualPosition(
|
||||||
|
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||||
|
platforms: JumpHopVisiblePlatform[],
|
||||||
|
characterPosition: JumpHopCharacterVisualPosition | null,
|
||||||
|
stageSize: JumpHopCanvasSize,
|
||||||
|
dragDistance: number,
|
||||||
|
dragVectorX: number | null,
|
||||||
|
dragVectorY: number | null,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!run ||
|
||||||
|
run.status !== 'playing' ||
|
||||||
|
!characterPosition ||
|
||||||
|
stageSize.width <= 0 ||
|
||||||
|
stageSize.height <= 0 ||
|
||||||
|
dragDistance <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
|
||||||
|
if (!pair) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { currentPlatform, targetPlatform } = pair;
|
||||||
|
|
||||||
|
const dragX = dragVectorX ?? 0;
|
||||||
|
const dragY = dragVectorY ?? 0;
|
||||||
|
const dragLength = Math.hypot(dragX, dragY);
|
||||||
|
if (dragLength < 0.0001) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scales = getJumpHopScreenWorldScales(
|
||||||
|
currentPlatform,
|
||||||
|
targetPlatform,
|
||||||
|
stageSize,
|
||||||
|
);
|
||||||
|
const backendDragVector = getJumpHopBackendDragVector(
|
||||||
|
run,
|
||||||
|
platforms,
|
||||||
|
stageSize,
|
||||||
|
dragX,
|
||||||
|
dragY,
|
||||||
|
);
|
||||||
|
const jumpWorldX = -backendDragVector.dragVectorX;
|
||||||
|
const jumpWorldY = backendDragVector.dragVectorY;
|
||||||
|
const jumpWorldLength = Math.hypot(jumpWorldX, jumpWorldY);
|
||||||
|
if (jumpWorldLength < 0.0001) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDragDistance =
|
||||||
|
run.path.scoring.maxChargeMs > 0 ? run.path.scoring.maxChargeMs : 180;
|
||||||
|
const chargeToDistanceRatio =
|
||||||
|
run.path.scoring.chargeToDistanceRatio > 0
|
||||||
|
? run.path.scoring.chargeToDistanceRatio
|
||||||
|
: 0.008;
|
||||||
|
const projectedWorldDistance =
|
||||||
|
clamp(dragDistance, 0, maxDragDistance) * chargeToDistanceRatio;
|
||||||
|
const landedWorldDeltaX =
|
||||||
|
(jumpWorldX / jumpWorldLength) * projectedWorldDistance;
|
||||||
|
const landedWorldDeltaY =
|
||||||
|
(jumpWorldY / jumpWorldLength) * projectedWorldDistance;
|
||||||
|
const landedPixelX =
|
||||||
|
scales.currentCanvasPosition.x +
|
||||||
|
landedWorldDeltaX * scales.signedXScreenPerWorld;
|
||||||
|
const landedPixelY =
|
||||||
|
scales.currentCanvasPosition.y +
|
||||||
|
landedWorldDeltaY * scales.signedYScreenPerWorld;
|
||||||
|
|
||||||
|
return {
|
||||||
|
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
|
||||||
|
screenY: clamp((landedPixelY / stageSize.height) * 100, 10, 92),
|
||||||
|
targetPlatformIndex: targetPlatform.index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveJumpHopCharacterCanvasPosition(
|
||||||
|
characterPosition: JumpHopCharacterVisualPosition | null,
|
||||||
|
size: JumpHopCanvasSize,
|
||||||
|
) {
|
||||||
|
if (!characterPosition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: (characterPosition.screenX / 100) * size.width,
|
||||||
|
y: (characterPosition.screenY / 100) * size.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJumpHopCharacterVisualPosition(
|
||||||
|
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||||
|
platforms: JumpHopVisiblePlatform[],
|
||||||
|
) {
|
||||||
|
if (!run) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const landedPlatform = platforms.find(
|
||||||
|
(item) => item.index === run.currentPlatformIndex,
|
||||||
|
);
|
||||||
|
if (landedPlatform) {
|
||||||
|
return {
|
||||||
|
screenX: landedPlatform.screenX,
|
||||||
|
screenY: landedPlatform.screenY - 3,
|
||||||
|
sceneX: landedPlatform.sceneX,
|
||||||
|
sceneY: landedPlatform.sceneY + 0.84,
|
||||||
|
sceneZ: landedPlatform.sceneZ,
|
||||||
|
isMiss: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastJump = run.lastJump;
|
||||||
|
if (lastJump && run.status === 'failed') {
|
||||||
|
const targetPlatform = platforms.find(
|
||||||
|
(item) => item.index === lastJump.targetPlatformIndex,
|
||||||
|
);
|
||||||
|
if (targetPlatform) {
|
||||||
|
return {
|
||||||
|
screenX: targetPlatform.screenX + 8,
|
||||||
|
screenY: targetPlatform.screenY - 2,
|
||||||
|
sceneX: targetPlatform.sceneX + 0.7,
|
||||||
|
sceneY: targetPlatform.sceneY + 0.48,
|
||||||
|
sceneZ: targetPlatform.sceneZ - 0.4,
|
||||||
|
isMiss: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJumpHopRunDurationMs(
|
||||||
|
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||||
|
nowMs: number,
|
||||||
|
) {
|
||||||
|
if (!run) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === 'playing' && run.startedAtMs > 0) {
|
||||||
|
return Math.max(0, nowMs - run.startedAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return run.durationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatJumpHopDurationLabel(durationMs: number) {
|
||||||
|
const safeDuration = Math.max(0, Math.floor(durationMs));
|
||||||
|
const totalSeconds = Math.floor(safeDuration / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0');
|
||||||
|
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJumpHopStatusLabel(
|
||||||
|
status: JumpHopRunStatus | undefined,
|
||||||
|
) {
|
||||||
|
if (status === 'cleared') {
|
||||||
|
return '结束';
|
||||||
|
}
|
||||||
|
if (status === 'failed') {
|
||||||
|
return '失败';
|
||||||
|
}
|
||||||
|
return '进行中';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJumpHopJumpFeedbackLabel(
|
||||||
|
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||||
|
) {
|
||||||
|
const result = run?.lastJump?.result;
|
||||||
|
if (result === 'perfect') {
|
||||||
|
return '落地';
|
||||||
|
}
|
||||||
|
if (result === 'finish') {
|
||||||
|
return '落地';
|
||||||
|
}
|
||||||
|
if (result === 'hit') {
|
||||||
|
return '落地';
|
||||||
|
}
|
||||||
|
if (result === 'miss') {
|
||||||
|
return '落空';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJumpHopTileTone(tileType: JumpHopTileType) {
|
||||||
|
return tileToneByType[tileType];
|
||||||
|
}
|
||||||
86
src/services/jump-hop/useJumpHopLeaderboard.test.tsx
Normal file
86
src/services/jump-hop/useJumpHopLeaderboard.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStoredAccessToken,
|
||||||
|
setStoredAccessToken,
|
||||||
|
} from '../apiClient';
|
||||||
|
import { ensureRuntimeGuestToken } from '../authService';
|
||||||
|
import {
|
||||||
|
jumpHopClient,
|
||||||
|
type JumpHopLeaderboardResponse,
|
||||||
|
} from './jumpHopClient';
|
||||||
|
import { useJumpHopLeaderboard } from './useJumpHopLeaderboard';
|
||||||
|
|
||||||
|
vi.mock('../authService', () => ({
|
||||||
|
ensureRuntimeGuestToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./jumpHopClient', () => ({
|
||||||
|
jumpHopClient: {
|
||||||
|
getLeaderboard: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const leaderboardResponse: JumpHopLeaderboardResponse = {
|
||||||
|
profileId: 'jump-hop-profile-test',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
rank: 1,
|
||||||
|
playerId: 'player-1',
|
||||||
|
successfulJumpCount: 10,
|
||||||
|
durationMs: 3210,
|
||||||
|
updatedAt: '2026-05-27T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
viewerBest: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setStoredAccessToken('', { emit: false });
|
||||||
|
vi.mocked(ensureRuntimeGuestToken).mockResolvedValue({
|
||||||
|
token: 'runtime-guest-token',
|
||||||
|
expiresAt: '2099-01-01T00:10:00Z',
|
||||||
|
subject: 'guest-runtime-test',
|
||||||
|
scope: 'public-play',
|
||||||
|
});
|
||||||
|
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue(
|
||||||
|
leaderboardResponse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳一跳排行榜在已有登录态时使用本地账号请求,不再额外申请 guest token', async () => {
|
||||||
|
setStoredAccessToken('stored-access-token', { emit: false });
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useJumpHopLeaderboard('jump-hop-profile-test'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
expect(getStoredAccessToken()).toBe('stored-access-token');
|
||||||
|
expect(ensureRuntimeGuestToken).not.toHaveBeenCalled();
|
||||||
|
expect(jumpHopClient.getLeaderboard).toHaveBeenCalledWith(
|
||||||
|
'jump-hop-profile-test',
|
||||||
|
expect.objectContaining({
|
||||||
|
authImpact: 'local',
|
||||||
|
skipRefresh: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.leaderboard).toEqual(leaderboardResponse);
|
||||||
|
});
|
||||||
|
test('跳一跳排行榜在匿名模式下会申请 guest token', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useJumpHopLeaderboard('jump-hop-profile-test'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
expect(ensureRuntimeGuestToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(jumpHopClient.getLeaderboard).toHaveBeenCalledWith(
|
||||||
|
'jump-hop-profile-test',
|
||||||
|
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||||
|
);
|
||||||
|
expect(result.current.leaderboard).toEqual(leaderboardResponse);
|
||||||
|
});
|
||||||
85
src/services/jump-hop/useJumpHopLeaderboard.ts
Normal file
85
src/services/jump-hop/useJumpHopLeaderboard.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||||
|
getStoredAccessToken,
|
||||||
|
} from '../apiClient';
|
||||||
|
import { ensureRuntimeGuestToken } from '../authService';
|
||||||
|
import {
|
||||||
|
jumpHopClient,
|
||||||
|
type JumpHopLeaderboardResponse,
|
||||||
|
type JumpHopRuntimeRequestOptions,
|
||||||
|
} from './jumpHopClient';
|
||||||
|
|
||||||
|
type JumpHopLeaderboardState = {
|
||||||
|
leaderboard: JumpHopLeaderboardResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useJumpHopLeaderboard(
|
||||||
|
profileId: string | null | undefined,
|
||||||
|
runtimeRequestOptions?: JumpHopRuntimeRequestOptions,
|
||||||
|
): JumpHopLeaderboardState {
|
||||||
|
const normalizedProfileId = profileId?.trim() ?? '';
|
||||||
|
const [leaderboard, setLeaderboard] =
|
||||||
|
useState<JumpHopLeaderboardResponse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useMemo(
|
||||||
|
() => async () => {
|
||||||
|
if (!normalizedProfileId) {
|
||||||
|
setLeaderboard(null);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (runtimeRequestOptions) {
|
||||||
|
const response = await jumpHopClient.getLeaderboard(
|
||||||
|
normalizedProfileId,
|
||||||
|
runtimeRequestOptions,
|
||||||
|
);
|
||||||
|
setLeaderboard(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getStoredAccessToken()) {
|
||||||
|
const response = await jumpHopClient.getLeaderboard(
|
||||||
|
normalizedProfileId,
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||||
|
);
|
||||||
|
setLeaderboard(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeGuest = await ensureRuntimeGuestToken();
|
||||||
|
const response = await jumpHopClient.getLeaderboard(
|
||||||
|
normalizedProfileId,
|
||||||
|
{ runtimeGuestToken: runtimeGuest.token },
|
||||||
|
);
|
||||||
|
setLeaderboard(response);
|
||||||
|
} catch (caughtError) {
|
||||||
|
setError(
|
||||||
|
caughtError instanceof Error
|
||||||
|
? caughtError.message
|
||||||
|
: '读取排行榜失败。',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[normalizedProfileId, runtimeRequestOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
return { leaderboard, isLoading, error, refresh };
|
||||||
|
}
|
||||||
@@ -484,7 +484,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jump hop draft generation exposes character and tile atlas pipeline', () => {
|
test('jump hop draft generation exposes theme and tile atlas pipeline', () => {
|
||||||
const state = createMiniGameDraftGenerationState('jump-hop');
|
const state = createMiniGameDraftGenerationState('jump-hop');
|
||||||
|
|
||||||
const progress = buildMiniGameDraftGenerationProgress(
|
const progress = buildMiniGameDraftGenerationProgress(
|
||||||
@@ -494,23 +494,20 @@ describe('miniGameDraftGenerationProgress', () => {
|
|||||||
|
|
||||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||||
'jump-hop-draft',
|
'jump-hop-draft',
|
||||||
'jump-hop-character',
|
|
||||||
'jump-hop-tile-atlas',
|
'jump-hop-tile-atlas',
|
||||||
'jump-hop-slice-tiles',
|
'jump-hop-slice-tiles',
|
||||||
'jump-hop-write-draft',
|
'jump-hop-write-draft',
|
||||||
]);
|
]);
|
||||||
expect(progress?.phaseId).toBe('jump-hop-character');
|
expect(progress?.phaseId).toBe('jump-hop-tile-atlas');
|
||||||
expect(progress?.phaseLabel).toBe('生成角色形象');
|
expect(progress?.phaseLabel).toBe('生成 5x5 地块图集');
|
||||||
expect(progress?.estimatedRemainingMs).toBe(265_000);
|
expect(progress?.estimatedRemainingMs).toBe(265_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('jump hop generation anchors expose theme, character and tile style', () => {
|
test('jump hop generation anchors expose theme and tile atlas', () => {
|
||||||
const entries = buildJumpHopGenerationAnchorEntries(null, {
|
const entries = buildJumpHopGenerationAnchorEntries(null, {
|
||||||
themeText: '云端糖果塔',
|
themeText: '云端糖果塔',
|
||||||
characterDescription: '披着星星披风的小旅人',
|
templateId: 'jump-hop',
|
||||||
tileStyle: '纸模玩具',
|
tilePrompt: '云端糖果塔主题的俯视角清爽游戏化立体感平台素材',
|
||||||
difficulty: '标准',
|
|
||||||
rhythmPreference: '轻快',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(entries).toEqual([
|
expect(entries).toEqual([
|
||||||
@@ -519,15 +516,10 @@ describe('miniGameDraftGenerationProgress', () => {
|
|||||||
label: '主题',
|
label: '主题',
|
||||||
value: '云端糖果塔',
|
value: '云端糖果塔',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'jump-hop-character',
|
|
||||||
label: '角色',
|
|
||||||
value: '披着星星披风的小旅人',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'jump-hop-tile-style',
|
id: 'jump-hop-tile-style',
|
||||||
label: '地块',
|
label: '地块图集',
|
||||||
value: '纸模玩具',
|
value: '云端糖果塔主题的俯视角清爽游戏化立体感平台素材',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export type MiniGameDraftGenerationPhase =
|
|||||||
| 'baby-object-images'
|
| 'baby-object-images'
|
||||||
| 'baby-object-ready'
|
| 'baby-object-ready'
|
||||||
| 'jump-hop-draft'
|
| 'jump-hop-draft'
|
||||||
| 'jump-hop-character'
|
|
||||||
| 'jump-hop-tile-atlas'
|
| 'jump-hop-tile-atlas'
|
||||||
| 'jump-hop-slice-tiles'
|
| 'jump-hop-slice-tiles'
|
||||||
| 'jump-hop-write-draft'
|
| 'jump-hop-write-draft'
|
||||||
@@ -391,32 +390,26 @@ const JUMP_HOP_STEPS = [
|
|||||||
{
|
{
|
||||||
id: 'jump-hop-draft',
|
id: 'jump-hop-draft',
|
||||||
label: '整理玩法草稿',
|
label: '整理玩法草稿',
|
||||||
detail: '建立主题、难度和路径基础数据。',
|
detail: '保存主题并派生作品信息和默认角色配置。',
|
||||||
weight: 10,
|
weight: 12,
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'jump-hop-character',
|
|
||||||
label: '生成角色形象',
|
|
||||||
detail: '生成可进入运行态的俯视角角色图。',
|
|
||||||
weight: 34,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'jump-hop-tile-atlas',
|
id: 'jump-hop-tile-atlas',
|
||||||
label: '生成地块图集',
|
label: '生成 5x5 地块图集',
|
||||||
detail: '生成起点、普通、目标和终点地块图集。',
|
detail: '调用 image2 生成 25 个主题地块素材。',
|
||||||
weight: 34,
|
weight: 54,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'jump-hop-slice-tiles',
|
id: 'jump-hop-slice-tiles',
|
||||||
label: '切分地块素材',
|
label: '切分 25 个地块',
|
||||||
detail: '切分透明地块 PNG 并校验落点半径。',
|
detail: '按 5 行 5 列切分透明地块 PNG。',
|
||||||
weight: 14,
|
weight: 24,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'jump-hop-write-draft',
|
id: 'jump-hop-write-draft',
|
||||||
label: '写入正式草稿',
|
label: '写入正式草稿',
|
||||||
detail: '保存角色、地块、路径和封面合成结果。',
|
detail: '保存地块池、无限路径缓冲和运行态配置。',
|
||||||
weight: 8,
|
weight: 10,
|
||||||
},
|
},
|
||||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||||
|
|
||||||
@@ -637,7 +630,7 @@ function resolveJumpHopPhaseByElapsedMs(
|
|||||||
return 'jump-hop-tile-atlas';
|
return 'jump-hop-tile-atlas';
|
||||||
}
|
}
|
||||||
if (elapsedMs >= 12_000) {
|
if (elapsedMs >= 12_000) {
|
||||||
return 'jump-hop-character';
|
return 'jump-hop-tile-atlas';
|
||||||
}
|
}
|
||||||
return 'jump-hop-draft';
|
return 'jump-hop-draft';
|
||||||
}
|
}
|
||||||
@@ -1086,21 +1079,12 @@ export function buildJumpHopGenerationAnchorEntries(
|
|||||||
draft?.workTitle?.trim() ||
|
draft?.workTitle?.trim() ||
|
||||||
'',
|
'',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'jump-hop-character',
|
|
||||||
label: '角色',
|
|
||||||
value:
|
|
||||||
formPayload?.characterDescription?.trim() ||
|
|
||||||
config?.characterDescription?.trim() ||
|
|
||||||
draft?.characterPrompt?.trim() ||
|
|
||||||
'',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'jump-hop-tile-style',
|
key: 'jump-hop-tile-style',
|
||||||
label: '地块',
|
label: '地块图集',
|
||||||
value:
|
value:
|
||||||
formPayload?.tileStyle?.trim() ||
|
formPayload?.tilePrompt?.trim() ||
|
||||||
config?.tileStyle?.trim() ||
|
config?.tilePrompt?.trim() ||
|
||||||
draft?.stylePreset?.trim() ||
|
draft?.stylePreset?.trim() ||
|
||||||
'',
|
'',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user