Merge remote-tracking branch 'origin/master' into dev-jenken

# Conflicts:
#	.hermes/shared-memory/pitfalls.md
#	server-rs/crates/api-server/src/modules/jump_hop.rs
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/jump-hop/jumpHopClient.test.ts
This commit is contained in:
2026-06-05 23:59:40 +08:00
67 changed files with 8713 additions and 2537 deletions

View File

@@ -40,6 +40,14 @@
- 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实联调时按 `provider=aliyun-oss``operation` 过滤日志,确认只出现对象定位和状态字段,不出现签名材料。 - 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实联调时按 `provider=aliyun-oss``operation` 过滤日志,确认只出现对象定位和状态字段,不出现签名材料。
- 关联文档:`server-rs/crates/platform-oss/README.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - 关联文档:`server-rs/crates/platform-oss/README.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-06-05 跳一跳返回按钮改为独立主题资产
- 背景:跳一跳运行态曾把左上角返回按钮视觉锚点写进背景 image2 prompt导致返回按钮像静态背景元素不能替代真实可点击按钮。
- 决策:跳一跳背景 prompt 禁止生成任何 UI 或左上角图标;返回按钮由 `backButtonAsset` 单独生成 1:1 纯绿 key 图,后端去绿后作为透明 PNG 持久化到作品 profile运行态左上角真实按钮优先渲染该资产。顶部得分 HUD 复用拼图模板结构,包含陶泥儿 IP logo、标题牌和下挂数字卡。
- 影响范围:`packages/shared/src/contracts/jumpHop.ts``shared-contracts``spacetime-module` / `spacetime-client` bindings、`api-server` 跳一跳生成链路、`JumpHopRuntimeShell`、玩法链路文档和后端数据契约文档。
- 验证方式:`npm run spacetime:generate``cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml``npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx``npm run check:spacetime-schema`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 2026-06-03 创作入口关闭不下架已发布作品 ## 2026-06-03 创作入口关闭不下架已发布作品
- 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。 - 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。
@@ -1164,13 +1172,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 陶泥儿主视觉配色回收为暖白/陶土橙

View File

@@ -1668,14 +1668,53 @@
- 验证:`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`
## 跳一跳宝可梦主题地块图集 safety rejection 只做专项改写
- 现象:跳一跳草稿使用“宝可梦 / Pokemon / 皮卡丘 / 精灵球”等主题时,背景底图和返回按钮可能已生成成功,但地块图集的 VectorEngine 请求返回 `Your request was rejected by the safety system`,日志里 `failure_context="跳一跳地块图集生成失败"``status=429``code="invalid_prompt"`
- 原因25 个落点图集 prompt 会把这些词放进“主题物体图集”语境,容易被上游理解为要求生成具体宝可梦角色或标志道具,触发安全拦截;这不是普通平台造型词、抠图或超时问题。
- 处理:仅在跳一跳图片生成 prompt 文本命中宝可梦相关词时做生成侧替换,把 `宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon` 改为“原创幻想萌宠冒险道具”,把 `精灵球` 改为“彩色冒险能量球”,把 `皮卡丘 / Pikachu` 改为“黄色闪电萌宠符号”;不要把所有主题都加全局 IP 禁止约束,用户草稿标题和主题展示也不改。
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml` 应覆盖宝可梦词专项替换;真实联调时同一草稿重试后,地块图集请求的 prompt 不再包含宝可梦相关词。
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``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,...`,终端日志会被数百万字符淹没。
@@ -1782,6 +1821,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 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
@@ -1805,3 +1860,35 @@
- 处理:推荐页和平台壳层的公开作品 key 规则必须复用 `buildPlatformPublicGalleryCardKey(...)`,覆盖同一批 `sourceType`,至少包括 `big-fish``puzzle``jump-hop``wooden-fish``match3d``square-hole``visual-novel``bark-battle``edutainment:<templateId>`;新增玩法公开推荐流时先补这个共享 helper。 - 处理:推荐页和平台壳层的公开作品 key 规则必须复用 `buildPlatformPublicGalleryCardKey(...)`,覆盖同一批 `sourceType`,至少包括 `big-fish``puzzle``jump-hop``wooden-fish``match3d``square-hole``visual-novel``bark-battle``edutainment:<templateId>`;新增玩法公开推荐流时先补这个共享 helper。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend meta matches active"` 应覆盖跳一跳和敲木鱼的当前运行内容、标题和作者一致。 - 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend meta matches active"` 应覆盖跳一跳和敲木鱼的当前运行内容、标题和作者一致。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 跳一跳飞行动画不要直接用最新 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。相机推进期间角色自身也不能保留 `left/top` transition否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回;角色推进期只允许 transform / opacity transition。正式胜负、成功跳跃次数、时长和排行榜仍以后端 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`、推进态角色 transition 不包含 `left/top`
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/services/jump-hop/jumpHopRuntimeModel.ts``server-rs/crates/module-jump-hop/src/application.rs`
## 跳一跳相机推进不要让地块图片回退到原型方块
- 现象:角色落到下一块后,相机推进时旧地块图片突然消失,或新预览地块先露出浅色原型方块,随后真实 image2 切片才出现。
- 原因:旧地块进入 exiting 状态时如果 React key 从 `platformId` 变成 `platformId-exiting`,图片组件会重新挂载并丢失已加载状态;同时 `JumpHopTileImage` 曾在真实图片 URL 已存在但 `onLoad` 尚未触发时显示 fallback 原型地块。
- 处理exiting 地块继续使用稳定 `platformId` key让旧图片组件在推进期复用有真实 `resolvedUrl` 且未错误时直接保留真实 `<img>`,只在无 URL 或加载失败时显示 fallback当前 3 块之外的后续地块通过隐藏预加载图片提前解析签名 URL 和浏览器缓存。
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖真实 tile URL 不露出 `.jump-hop-runtime__fallback-tile`,并存在 `jump-hop-tile-preload-image`
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`
## 跳一跳地块抠图不要用绿幕或近白底识别
- 现象:跳一跳生成草地、花、雪地、白石或云朵地块时,透明化会把绿色 / 白色主体局部扣掉,运行态看到平台缺口、变薄或主体消失。
- 原因:通用图集默认按绿幕和近白底做透明化,适合 UI / 普通物品,但跳一跳地块天然高频包含绿色和白色;如果继续用 `#00FF00` 绿幕或近白背景识别,素材本体会落入背景分数。旧逻辑还会清理非边缘连通的高置信 key 色块,遇到主体内部撞色时也可能误伤。
- 处理:跳一跳地块图集 prompt 固定要求单一纯洋红 `#FF00FF` key 背景;切片前后透明化调用 `GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen()`,只扣洋红 key关闭近白扣除并且不清理非边缘连通 key 色像素。通用绿幕函数保持默认绿幕 / 近白兼容,避免影响拼图、抓大鹅和敲木鱼。
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet -- --nocapture` 覆盖洋红 key 保留绿色、白色和非边缘连通 key 色主体;`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳洋红 prompt 与绿 / 白地块切片。
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs``server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs``server-rs/crates/api-server/src/jump_hop.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`

View File

@@ -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/unified-creation/workspaces/JumpHopCreationWorkspace.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` 通过。

View File

@@ -407,15 +407,23 @@ 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`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` - 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
- 说明:运行记录持久化 `runtime_mode`,取值为 `draft` / `published`;草稿试玩只允许作品所有者启动,不累计公开游玩次数,也不写入公开排行榜。
### `jump_hop_work_profile` ### `jump_hop_work_profile`
- Rust 结构体:`JumpHopWorkProfileRow` - Rust 结构体:`JumpHopWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` - 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
- 说明:作品投影持久化独立 `theme_text`,用于生成主题和公开卡片主题展示;历史行为空时按 `work_title` 兜底。`back_button_asset_json` 保存 image2 单独生成并去绿后的 1:1 左上角返回按钮资产快照;旧迁移数据按 `None` 兼容,运行态缺失该字段时使用同尺寸 CSS 主题按钮兜底。
### SpacetimeDB view`jump_hop_gallery_card_view` ### SpacetimeDB view`jump_hop_gallery_card_view`

View File

@@ -44,7 +44,7 @@
单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec``slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。 单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec``slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。
通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets``n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。 通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets``n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、默认绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求;高风险撞色玩法可显式使用专用 key 色、关闭近白扣除并限制为边缘连通背景扣除`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块,也不再展示“当前拼图信息”“当前敲木鱼信息”“当前世界信息”等玩法设定信息模块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,和当前步骤卡保持更大的垂直间距;预计等待左边缘、已耗时右边缘必须分别与当前步骤卡左右边缘对齐,避免右侧裁切或横向漂移。生成页顶部返回栏和状态标识不参与内容滚动,滚动只发生在进度内容区。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。 当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块,也不再展示“当前拼图信息”“当前敲木鱼信息”“当前世界信息”等玩法设定信息模块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,和当前步骤卡保持更大的垂直间距;预计等待左边缘、已耗时右边缘必须分别与当前步骤卡左右边缘对齐,避免右侧裁切或横向漂移。生成页顶部返回栏和状态标识不参与内容滚动,滚动只发生在进度内容区。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
@@ -136,23 +136,33 @@ 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`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key不启用近白底扣除也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制地块造型提示词要求以主题物体本身外轮廓为准允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装所有地块素材必须保持统一正面30度视角相机位于物体正前方略高位置、镜头向下约30度必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面主体正面或侧壁可见面积必须接近或大于顶面面积顶面只能作为辅助可见面水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标主题物体本身必须是唯一可落脚体只能用自身切面、边缘厚度、花瓣层或果皮边表现承重禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集物体本身作为跳跃落点”的口径不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改
4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图 4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感两侧允许更强立体层次和行进感背景只作为底图禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版左上角返回按钮不允许画进背景而是单独生成 `backButtonAsset` 透明 PNGOSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile
5. 显式重生成角色或地块时,只重生成对应资产槽位 5. 后端按从上到下、从左到右均匀切分为 `tile-01``tile-25` 的透明 PNG每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位
6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;
7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。
运行态规则真相必须沉到 `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 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数不展示计时器或右上角重开按钮生成背景和游戏舞台必须覆盖整个运行态视口HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作
运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图图片读取继续走平台资产换签没有背景时才回退到内置渐变DOM 平台层直接使用 `tileAssets[]` 的生成切片图片显示地块,图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存;每个地块下方的统一软椭圆阴影来自运行态 DOM 的 `.jump-hop-runtime__platform-shadow`,不是 image2 地块切片的必需内容,调整阴影优先改运行态 CSS有真实地块图片 URL 时不得在加载空档显示 fallback 原型地块下一屏预览地块必须在进入相机视野前隐藏预加载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。相机推进期间角色自身必须禁用 `left/top` transition只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `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。

View File

@@ -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;
@@ -60,6 +61,7 @@ export interface JumpHopActionRequest {
tileAtlasAsset?: JumpHopCharacterAsset | null; tileAtlasAsset?: JumpHopCharacterAsset | null;
tileAssets?: JumpHopTileAsset[] | null; tileAssets?: JumpHopTileAsset[] | null;
coverComposite?: string | null; coverComposite?: string | null;
backButtonAsset?: JumpHopCharacterAsset | null;
} }
export interface JumpHopCharacterAsset { export interface JumpHopCharacterAsset {
@@ -73,12 +75,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 +139,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;
@@ -139,6 +154,7 @@ export interface JumpHopDraftResponse {
tileAssets: JumpHopTileAsset[]; tileAssets: JumpHopTileAsset[];
path: JumpHopPath | null; path: JumpHopPath | null;
coverComposite: string | null; coverComposite: string | null;
backButtonAsset?: JumpHopCharacterAsset | null;
generationStatus: JumpHopGenerationStatus; generationStatus: JumpHopGenerationStatus;
} }
@@ -167,6 +183,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,9 +202,11 @@ 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[];
backButtonAsset?: JumpHopCharacterAsset | null;
} }
export interface JumpHopWorksResponse { export interface JumpHopWorksResponse {
@@ -208,6 +227,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 +257,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 +273,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 +290,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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -270,6 +270,29 @@ mod tests {
); );
} }
#[test]
fn test_creation_entry_config_response_updates_jump_hop_metadata() {
let config = test_creation_entry_config_response();
let jump_hop = config
.creation_types
.iter()
.find(|item| item.id == "jump-hop")
.expect("test creation entry config should include jump-hop");
assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}");
assert!(jump_hop.visible);
assert!(jump_hop.open);
assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(
jump_hop.subtitle,
"\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}"
);
assert_eq!(
jump_hop.image_src,
"/creation-type-references/jump-hop.webp"
);
}
#[test] #[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();

View File

@@ -1,4 +1,4 @@
use axum::http::StatusCode; use axum::http::StatusCode;
use platform_image::generated_asset_sheets as generated_asset_sheets_impl; use platform_image::generated_asset_sheets as generated_asset_sheets_impl;
use crate::{ use crate::{
@@ -8,9 +8,12 @@ use crate::{
#[allow(unused_imports)] #[allow(unused_imports)]
pub(crate) use generated_asset_sheets_impl::{ pub(crate) use generated_asset_sheets_impl::{
GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload, GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
crop_generated_asset_sheet_view_edge_matte,
crop_generated_asset_sheet_view_edge_matte_with_options,
}; };
pub(crate) fn build_generated_asset_sheet_prompt( pub(crate) fn build_generated_asset_sheet_prompt(

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,16 @@
use axum::{ use axum::{
Router, middleware, middleware,
routing::{delete, get, post}, routing::{delete, get, post},
Router,
}; };
use crate::{ 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, delete_jump_hop_work, execute_jump_hop_action, create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
get_jump_hop_gallery_detail, get_jump_hop_runtime_work, get_jump_hop_session, get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works,
restart_jump_hop_run, start_jump_hop_run, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
}, },
state::AppState, state::AppState,
}; };
@@ -62,6 +63,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(

View File

@@ -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
} else {
JumpHopJumpResultKind::Perfect
}
} else if landing_error <= target.landing_radius {
if next_index as u32 == run.path.finish_index {
JumpHopJumpResultKind::Finish
} else {
JumpHopJumpResultKind::Hit 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());
}
} }

View File

@@ -404,9 +404,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,

View File

@@ -446,6 +446,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);

View File

@@ -2,13 +2,80 @@ use super::color::{
GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE, GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE,
GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit, GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit,
compute_generated_asset_sheet_green_screen_score, compute_generated_asset_sheet_green_screen_score,
compute_generated_asset_sheet_key_color_score,
compute_generated_asset_sheet_white_screen_score, compute_generated_asset_sheet_white_screen_score,
is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel, is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel,
touches_generated_asset_sheet_background_mask, touches_generated_asset_sheet_background_mask,
}; };
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetKeyColor {
pub red: u8,
pub green: u8,
pub blue: u8,
}
impl GeneratedAssetSheetKeyColor {
pub const GREEN_SCREEN: Self = Self {
red: 0,
green: 255,
blue: 0,
};
pub const MAGENTA_SCREEN: Self = Self {
red: 255,
green: 0,
blue: 255,
};
pub fn is_green_screen(self) -> bool {
self == Self::GREEN_SCREEN
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GeneratedAssetSheetAlphaOptions {
pub key_color: GeneratedAssetSheetKeyColor,
pub remove_near_white_background: bool,
pub remove_disconnected_hard_key_background: bool,
}
impl GeneratedAssetSheetAlphaOptions {
pub const fn green_screen() -> Self {
Self {
key_color: GeneratedAssetSheetKeyColor::GREEN_SCREEN,
remove_near_white_background: true,
remove_disconnected_hard_key_background: true,
}
}
pub const fn jump_hop_magenta_screen() -> Self {
Self {
key_color: GeneratedAssetSheetKeyColor::MAGENTA_SCREEN,
remove_near_white_background: false,
remove_disconnected_hard_key_background: false,
}
}
}
impl Default for GeneratedAssetSheetAlphaOptions {
fn default() -> Self {
Self::green_screen()
}
}
pub fn apply_generated_asset_sheet_green_screen_alpha( pub fn apply_generated_asset_sheet_green_screen_alpha(
source: image::DynamicImage, source: image::DynamicImage,
) -> image::DynamicImage {
apply_generated_asset_sheet_alpha_with_options(
source,
GeneratedAssetSheetAlphaOptions::default(),
)
}
pub fn apply_generated_asset_sheet_alpha_with_options(
source: image::DynamicImage,
options: GeneratedAssetSheetAlphaOptions,
) -> image::DynamicImage { ) -> image::DynamicImage {
let mut image = source.to_rgba8(); let mut image = source.to_rgba8();
let (width, height) = image.dimensions(); let (width, height) = image.dimensions();
@@ -16,6 +83,7 @@ pub fn apply_generated_asset_sheet_green_screen_alpha(
image.as_mut(), image.as_mut(),
width as usize, width as usize,
height as usize, height as usize,
options,
); );
image::DynamicImage::ImageRgba8(image) image::DynamicImage::ImageRgba8(image)
} }
@@ -24,13 +92,14 @@ fn remove_generated_asset_sheet_green_screen_background(
pixels: &mut [u8], pixels: &mut [u8],
width: usize, width: usize,
height: usize, height: usize,
options: GeneratedAssetSheetAlphaOptions,
) -> bool { ) -> bool {
let pixel_count = width.saturating_mul(height); let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
return false; return false;
} }
let mut green_scores = vec![0.0f32; pixel_count]; let mut key_scores = vec![0.0f32; pixel_count];
let mut white_scores = vec![0.0f32; pixel_count]; let mut white_scores = vec![0.0f32; pixel_count];
let mut background_hints = vec![0.0f32; pixel_count]; let mut background_hints = vec![0.0f32; pixel_count];
let mut background_mask = vec![0u8; pixel_count]; let mut background_mask = vec![0u8; pixel_count];
@@ -43,16 +112,19 @@ fn remove_generated_asset_sheet_green_screen_background(
let green = pixels[offset + 1]; let green = pixels[offset + 1];
let blue = pixels[offset + 2]; let blue = pixels[offset + 2];
let alpha = pixels[offset + 3]; let alpha = pixels[offset + 3];
let green_score = let key_score =
compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]); compute_generated_asset_sheet_key_score([red, green, blue, alpha], options.key_color);
let white_score = let white_score = if options.remove_near_white_background {
compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]); compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha])
} else {
0.0
};
let transparency_hint = let transparency_hint =
clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75; clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75;
green_scores[pixel_index] = green_score; key_scores[pixel_index] = key_score;
white_scores[pixel_index] = white_score; white_scores[pixel_index] = white_score;
background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint); background_hints[pixel_index] = key_score.max(white_score).max(transparency_hint);
} }
let seed_background_pixel = let seed_background_pixel =
@@ -62,10 +134,10 @@ fn remove_generated_asset_sheet_green_screen_background(
} }
let alpha = pixels[pixel_index * 4 + 3]; let alpha = pixels[pixel_index * 4 + 3];
let strong_candidate = alpha < 40 let strong_candidate = alpha < 40
|| green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE || key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224 || (alpha < 224
&& green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) && key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE)
|| white_scores[pixel_index] > 0.32; || (options.remove_near_white_background && white_scores[pixel_index] > 0.32);
if !strong_candidate { if !strong_candidate {
return; return;
} }
@@ -113,28 +185,36 @@ fn remove_generated_asset_sheet_green_screen_background(
} }
let next_offset = next_pixel_index * 4; let next_offset = next_pixel_index * 4;
let alpha = pixels[next_offset + 3]; let alpha = pixels[next_offset + 3];
let green_score = green_scores[next_pixel_index]; let key_score = key_scores[next_pixel_index];
let white_score = white_scores[next_pixel_index]; let white_score = white_scores[next_pixel_index];
let hint = background_hints[next_pixel_index]; let hint = background_hints[next_pixel_index];
let reachable_soft_edge = hint > 0.08 let reachable_soft_edge = hint > 0.08
&& alpha < 224 && alpha < 224
&& (green_score > 0.04 || white_score > 0.08 || alpha < 180); && (key_score > 0.04
let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE || (options.remove_near_white_background && white_score > 0.08)
|| (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); || alpha < 180);
if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge { let key_background = key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
|| (alpha < 224 && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE);
if alpha < 40
|| key_background
|| (options.remove_near_white_background && white_score > 0.32)
|| reachable_soft_edge
{
background_mask[next_pixel_index] = 1; background_mask[next_pixel_index] = 1;
queue.push(next_pixel_index); queue.push(next_pixel_index);
} }
} }
} }
if options.remove_disconnected_hard_key_background {
for pixel_index in 0..pixel_count { for pixel_index in 0..pixel_count {
if background_mask[pixel_index] == 0 if background_mask[pixel_index] == 0
&& green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE && key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE
{ {
background_mask[pixel_index] = 1; background_mask[pixel_index] = 1;
} }
} }
}
let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14); let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14);
for _ in 0..soft_green_cleanup_rounds { for _ in 0..soft_green_cleanup_rounds {
@@ -153,10 +233,14 @@ fn remove_generated_asset_sheet_green_screen_background(
pixels[offset + 2], pixels[offset + 2],
pixels[offset + 3], pixels[offset + 3],
]; ];
let green_score = green_scores[pixel_index]; let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index]; let white_score = white_scores[pixel_index];
if !is_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score) if !is_generated_asset_sheet_soft_key_matte_pixel(
{ pixel,
key_score,
white_score,
options,
) {
continue; continue;
} }
if !touches_generated_asset_sheet_background_mask( if !touches_generated_asset_sheet_background_mask(
@@ -188,12 +272,12 @@ fn remove_generated_asset_sheet_green_screen_background(
continue; continue;
} }
let alpha = pixels[pixel_index * 4 + 3]; let alpha = pixels[pixel_index * 4 + 3];
let green_score = green_scores[pixel_index]; let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index]; let white_score = white_scores[pixel_index];
let hint = background_hints[pixel_index]; let hint = background_hints[pixel_index];
let soft_matte_candidate = alpha < 224 let soft_matte_candidate = alpha < 224
|| white_score > 0.10 || (options.remove_near_white_background && white_score > 0.10)
|| green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; || key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE;
if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate { if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate {
continue; continue;
} }
@@ -278,9 +362,9 @@ fn remove_generated_asset_sheet_green_screen_background(
continue; continue;
} }
let green_score = green_scores[pixel_index]; let key_score = key_scores[pixel_index];
let white_score = white_scores[pixel_index]; let white_score = white_scores[pixel_index];
let contamination = green_score.max(white_score).max(if alpha < 220 { let contamination = key_score.max(white_score).max(if alpha < 220 {
((220 - alpha) as f32 / 220.0) * 0.25 ((220 - alpha) as f32 / 220.0) * 0.25
} else { } else {
0.0 0.0
@@ -301,30 +385,47 @@ fn remove_generated_asset_sheet_green_screen_background(
let mut red = pixels[offset] as f32; let mut red = pixels[offset] as f32;
let mut green = pixels[offset + 1] as f32; let mut green = pixels[offset + 1] as f32;
let mut blue = pixels[offset + 2] as f32; let mut blue = pixels[offset + 2] as f32;
let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22)); let blend = if options.key_color.is_green_screen() {
clamp_generated_asset_sheet_unit(contamination.max(0.22))
} else {
// 中文注释:洋红 / 青色等非绿幕 key 的残留更容易表现成彩边,
// 需要比绿幕更强地向主体邻近色收敛,避免 PNG 边缘继续带 key 色。
clamp_generated_asset_sheet_unit((key_score * 1.35).max(contamination).max(0.28))
};
if let Some((sample_red, sample_green, sample_blue)) = sample { if let Some((sample_red, sample_green, sample_blue)) = sample {
red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend); red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend);
green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend); green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend);
blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend); blue = lerp_generated_asset_sheet_channel(blue, sample_blue as f32, blend);
if green_score > 0.04 { if options.key_color.is_green_screen() && key_score > 0.04 {
green = green.min(sample_green as f32 + 18.0); green = green.min(sample_green as f32 + 18.0);
} }
if white_score > 0.1 { if options.remove_near_white_background && white_score > 0.1 {
red = red.min(sample_red as f32 + 26.0); red = red.min(sample_red as f32 + 26.0);
green = green.min(sample_green as f32 + 26.0); green = green.min(sample_green as f32 + 26.0);
blue = blue.min(sample_blue as f32 + 26.0); blue = blue.min(sample_blue as f32 + 26.0);
} }
if !options.key_color.is_green_screen() && key_score > 0.04 {
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[sample_red as f32, sample_green as f32, sample_blue as f32],
key_score,
options.key_color,
);
red = defringed[0];
green = defringed[1];
blue = defringed[2];
}
} else { } else {
if green_score > 0.04 { if options.key_color.is_green_screen() && key_score > 0.04 {
let toned_green = (green - (green - red.max(blue)) * 0.78) let toned_green = (green - (green - red.max(blue)) * 0.78)
.round() .round()
.max(red.max(blue)); .max(red.max(blue));
green = green.min(toned_green).min(red.max(blue) + 18.0); green = green.min(toned_green).min(red.max(blue) + 18.0);
} }
if white_score > 0.12 { if options.remove_near_white_background && white_score > 0.12 {
let spread = red.max(green).max(blue) - red.min(green).min(blue); let spread = red.max(green).max(blue) - red.min(green).min(blue);
if spread < 20.0 { if spread < 20.0 {
let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); let toned_value = ((red + green + blue) / 3.0 * 0.88).round();
@@ -333,10 +434,26 @@ fn remove_generated_asset_sheet_green_screen_background(
blue = blue.min(toned_value); blue = blue.min(toned_value);
} }
} }
if !options.key_color.is_green_screen() && key_score > 0.04 {
let neutral = (red + green + blue) / 3.0;
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[neutral, neutral, neutral],
key_score,
options.key_color,
);
red = defringed[0];
green = defringed[1];
blue = defringed[2];
}
} }
let mut next_alpha = alpha; let mut next_alpha = alpha;
let edge_fade = (green_score * 0.35).max(white_score * 0.28); let edge_fade = if options.key_color.is_green_screen() {
(key_score * 0.35).max(white_score * 0.28)
} else {
(key_score * 0.48).max(white_score * 0.28)
};
if edge_fade > 0.08 { if edge_fade > 0.08 {
next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8;
if next_alpha < 10 { if next_alpha < 10 {
@@ -364,6 +481,66 @@ fn remove_generated_asset_sheet_green_screen_background(
changed changed
} }
pub(super) fn suppress_generated_asset_sheet_key_color_fringe(
color: [f32; 3],
target: [f32; 3],
key_score: f32,
key_color: GeneratedAssetSheetKeyColor,
) -> [f32; 3] {
let strength = clamp_generated_asset_sheet_unit(key_score * 1.18);
let key_channels = [
key_color.red as f32 / 255.0,
key_color.green as f32 / 255.0,
key_color.blue as f32 / 255.0,
];
let mut next = color;
for index in 0..3 {
if key_channels[index] >= 0.66 {
let cap = target[index] + 18.0 + (1.0 - strength) * 28.0;
next[index] = next[index].min(lerp_generated_asset_sheet_channel(
next[index],
cap,
strength,
));
} else if key_channels[index] <= 0.34 {
next[index] =
lerp_generated_asset_sheet_channel(next[index], target[index], strength * 0.72);
}
}
next
}
fn compute_generated_asset_sheet_key_score(
pixel: [u8; 4],
key_color: GeneratedAssetSheetKeyColor,
) -> f32 {
if key_color.is_green_screen() {
return compute_generated_asset_sheet_green_screen_score(pixel);
}
compute_generated_asset_sheet_key_color_score(
pixel,
[key_color.red, key_color.green, key_color.blue],
)
}
fn is_generated_asset_sheet_soft_key_matte_pixel(
pixel: [u8; 4],
key_score: f32,
white_score: f32,
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
if options.key_color.is_green_screen() {
return is_generated_asset_sheet_soft_green_matte_pixel(pixel, key_score, white_score);
}
pixel[3] != 0
&& key_score >= GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE
&& (!options.remove_near_white_background || white_score < 0.34)
}
fn collect_generated_asset_sheet_foreground_neighbor_color( fn collect_generated_asset_sheet_foreground_neighbor_color(
pixels: &[u8], pixels: &[u8],
width: usize, width: usize,

View File

@@ -139,6 +139,24 @@ pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) -
.clamp(0.0, 1.0) .clamp(0.0, 1.0)
} }
pub(super) fn compute_generated_asset_sheet_key_color_score(
pixel: [u8; 4],
key_color: [u8; 3],
) -> f32 {
if pixel[3] == 0 {
return 1.0;
}
let color_distance = (pixel[0] as f32 - key_color[0] as f32).abs()
+ (pixel[1] as f32 - key_color[1] as f32).abs()
+ (pixel[2] as f32 - key_color[2] as f32).abs();
if color_distance >= 180.0 {
return 0.0;
}
clamp_generated_asset_sheet_unit(1.0 - color_distance / 180.0)
}
pub(super) fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 { pub(super) fn compute_generated_asset_sheet_white_screen_score(pixel: [u8; 4]) -> f32 {
if pixel[3] == 0 { if pixel[3] == 0 {
return 1.0; return 1.0;

View File

@@ -5,7 +5,10 @@ pub mod persist;
pub mod prompt; pub mod prompt;
pub mod sheet; pub mod sheet;
pub use alpha::apply_generated_asset_sheet_green_screen_alpha; pub use alpha::{
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetKeyColor,
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
};
pub use error::GeneratedAssetSheetError; pub use error::GeneratedAssetSheetError;
pub use persist::{ pub use persist::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload,
@@ -14,5 +17,6 @@ pub use persist::{
pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt}; pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt};
pub use sheet::{ pub use sheet::{
GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte, GeneratedAssetSheetSliceImage, crop_generated_asset_sheet_view_edge_matte,
slice_generated_asset_sheet, slice_generated_asset_sheet_two_items_per_row, crop_generated_asset_sheet_view_edge_matte_with_options, slice_generated_asset_sheet,
slice_generated_asset_sheet_two_items_per_row,
}; };

View File

@@ -1,10 +1,14 @@
use super::alpha::apply_generated_asset_sheet_green_screen_alpha; use super::alpha::{
GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha,
suppress_generated_asset_sheet_key_color_fringe,
};
use super::color::{ use super::color::{
is_generated_asset_sheet_foreground_pixel, clamp_generated_asset_sheet_unit, compute_generated_asset_sheet_key_color_score,
compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_foreground_pixel,
is_generated_asset_sheet_green_contaminated_edge_pixel, is_generated_asset_sheet_green_contaminated_edge_pixel,
is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination, is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination,
is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel, is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel,
touches_generated_asset_sheet_background_mask, lerp_generated_asset_sheet_channel, touches_generated_asset_sheet_background_mask,
}; };
use super::error::GeneratedAssetSheetError; use super::error::GeneratedAssetSheetError;
use image::{GenericImageView, ImageFormat}; use image::{GenericImageView, ImageFormat};
@@ -130,10 +134,25 @@ pub fn slice_generated_asset_sheet_two_items_per_row(
pub fn crop_generated_asset_sheet_view_edge_matte( pub fn crop_generated_asset_sheet_view_edge_matte(
image: image::DynamicImage, image: image::DynamicImage,
) -> image::DynamicImage {
crop_generated_asset_sheet_view_edge_matte_with_options(
image,
GeneratedAssetSheetAlphaOptions::default(),
)
}
pub fn crop_generated_asset_sheet_view_edge_matte_with_options(
image: image::DynamicImage,
options: GeneratedAssetSheetAlphaOptions,
) -> image::DynamicImage { ) -> image::DynamicImage {
let mut image = image.to_rgba8(); let mut image = image.to_rgba8();
let (width, height) = image.dimensions(); let (width, height) = image.dimensions();
remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize); remove_generated_asset_sheet_view_edge_matte(
image.as_mut(),
width as usize,
height as usize,
options,
);
let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| { let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| {
GeneratedAssetSheetCellBounds { GeneratedAssetSheetCellBounds {
x0: 0, x0: 0,
@@ -359,6 +378,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels: &mut [u8], pixels: &mut [u8],
width: usize, width: usize,
height: usize, height: usize,
options: GeneratedAssetSheetAlphaOptions,
) -> bool { ) -> bool {
let pixel_count = width.saturating_mul(height); let pixel_count = width.saturating_mul(height);
if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) {
@@ -403,7 +423,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2], pixels[offset + 2],
pixels[offset + 3], pixels[offset + 3],
]; ];
if !is_generated_asset_sheet_view_background_pixel(pixel) { if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) {
continue; continue;
} }
background_mask[pixel_index] = 1; background_mask[pixel_index] = 1;
@@ -434,7 +454,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2], pixels[offset + 2],
pixels[offset + 3], pixels[offset + 3],
]; ];
if !is_generated_asset_sheet_view_background_pixel(pixel) { if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) {
continue; continue;
} }
background_mask[next_pixel_index] = 1; background_mask[next_pixel_index] = 1;
@@ -452,12 +472,15 @@ fn remove_generated_asset_sheet_view_edge_matte(
continue; continue;
} }
let offset = pixel_index * 4; let offset = pixel_index * 4;
if !is_generated_asset_sheet_view_background_pixel([ if !is_generated_asset_sheet_view_background_pixel_with_options(
[
pixels[offset], pixels[offset],
pixels[offset + 1], pixels[offset + 1],
pixels[offset + 2], pixels[offset + 2],
pixels[offset + 3], pixels[offset + 3],
]) { ],
options,
) {
continue; continue;
} }
@@ -526,7 +549,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 2], pixels[offset + 2],
pixels[offset + 3], pixels[offset + 3],
]; ];
if !is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) { if !is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options) {
continue; continue;
} }
if !touches_generated_asset_sheet_background_mask( if !touches_generated_asset_sheet_background_mask(
@@ -539,7 +562,7 @@ fn remove_generated_asset_sheet_view_edge_matte(
continue; continue;
} }
if is_generated_asset_sheet_strong_green_contamination(pixel) { if is_generated_asset_sheet_strong_key_contamination(pixel, options) {
pixels[offset] = 0; pixels[offset] = 0;
pixels[offset + 1] = 0; pixels[offset + 1] = 0;
pixels[offset + 2] = 0; pixels[offset + 2] = 0;
@@ -559,17 +582,61 @@ fn remove_generated_asset_sheet_view_edge_matte(
y, y,
&background_mask, &background_mask,
&visible_mask, &visible_mask,
options,
) )
.unwrap_or(( .unwrap_or((
pixels[offset], pixels[offset],
pixels[offset + 1], pixels[offset + 1],
pixels[offset + 2], pixels[offset + 2],
)); ));
let (next_red, next_green, next_blue) = if options.key_color.is_green_screen() {
let next_red = replacement.0.max(pixels[offset]); let next_red = replacement.0.max(pixels[offset]);
let next_blue = replacement.2.max(pixels[offset + 2]); let next_blue = replacement.2.max(pixels[offset + 2]);
let next_green = replacement let next_green = replacement
.1 .1
.min(next_red.max(next_blue).saturating_add(12)); .min(next_red.max(next_blue).saturating_add(12));
(next_red, next_green, next_blue)
} else {
let key_score = compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
);
let blend = clamp_generated_asset_sheet_unit((key_score * 1.25).max(0.36));
let red = lerp_generated_asset_sheet_channel(
pixels[offset] as f32,
replacement.0 as f32,
blend,
);
let green = lerp_generated_asset_sheet_channel(
pixels[offset + 1] as f32,
replacement.1 as f32,
blend,
);
let blue = lerp_generated_asset_sheet_channel(
pixels[offset + 2] as f32,
replacement.2 as f32,
blend,
);
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[
replacement.0 as f32,
replacement.1 as f32,
replacement.2 as f32,
],
key_score,
options.key_color,
);
(
defringed[0].round().clamp(0.0, 255.0) as u8,
defringed[1].round().clamp(0.0, 255.0) as u8,
defringed[2].round().clamp(0.0, 255.0) as u8,
)
};
if next_red != pixels[offset] if next_red != pixels[offset]
|| next_green != pixels[offset + 1] || next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2] || next_blue != pixels[offset + 2]
@@ -605,6 +672,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
y: usize, y: usize,
background_mask: &[u8], background_mask: &[u8],
visible_mask: &[u8], visible_mask: &[u8],
options: GeneratedAssetSheetAlphaOptions,
) -> Option<(u8, u8, u8)> { ) -> Option<(u8, u8, u8)> {
let mut total_weight = 0.0f32; let mut total_weight = 0.0f32;
let mut total_red = 0.0f32; let mut total_red = 0.0f32;
@@ -638,8 +706,9 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
pixels[next_offset + 2], pixels[next_offset + 2],
next_alpha, next_alpha,
]; ];
if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) if is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options)
|| is_generated_asset_sheet_soft_edge_pixel(pixel) || (options.key_color.is_green_screen()
&& is_generated_asset_sheet_soft_edge_pixel(pixel))
{ {
continue; continue;
} }
@@ -670,3 +739,73 @@ fn collect_generated_asset_sheet_visible_neighbor_color(
(total_blue / total_weight).round() as u8, (total_blue / total_weight).round() as u8,
)) ))
} }
fn is_generated_asset_sheet_view_background_pixel_with_options(
pixel: [u8; 4],
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
if options.key_color.is_green_screen() && options.remove_near_white_background {
return is_generated_asset_sheet_view_background_pixel(pixel);
}
if pixel[3] < 16 {
return true;
}
if options.key_color.is_green_screen() && is_generated_asset_sheet_soft_edge_pixel(pixel) {
return true;
}
if !options.key_color.is_green_screen()
&& compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
) > 0.18
{
return true;
}
options.remove_near_white_background
&& compute_generated_asset_sheet_white_screen_score(pixel) > 0.18
}
fn is_generated_asset_sheet_key_contaminated_edge_pixel(
pixel: [u8; 4],
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
if options.key_color.is_green_screen() {
return is_generated_asset_sheet_green_contaminated_edge_pixel(pixel);
}
pixel[3] != 0
&& compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
) > 0.18
}
fn is_generated_asset_sheet_strong_key_contamination(
pixel: [u8; 4],
options: GeneratedAssetSheetAlphaOptions,
) -> bool {
if options.key_color.is_green_screen() {
return is_generated_asset_sheet_strong_green_contamination(pixel);
}
compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
) > 0.62
}

View File

@@ -2,9 +2,11 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use platform_image::DownloadedImage; use platform_image::DownloadedImage;
use platform_image::generated_asset_sheets::{ use platform_image::generated_asset_sheets::{
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetPersistInput,
GeneratedAssetSheetPromptInput, apply_generated_asset_sheet_green_screen_alpha, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput,
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
build_generated_asset_sheet_prompt, crop_generated_asset_sheet_view_edge_matte, build_generated_asset_sheet_prompt, crop_generated_asset_sheet_view_edge_matte,
crop_generated_asset_sheet_view_edge_matte_with_options,
prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet, prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet,
slice_generated_asset_sheet_two_items_per_row, slice_generated_asset_sheet_two_items_per_row,
}; };
@@ -142,6 +144,140 @@ fn generated_asset_sheet_green_screen_alpha_removes_green_background() {
assert_eq!(cleaned.get_pixel(10, 10).0[3], 255); assert_eq!(cleaned.get_pixel(10, 10).0[3], 255);
} }
#[test]
fn generated_asset_sheet_magenta_key_preserves_green_white_and_disconnected_key_subject() {
let mut sheet = RgbaImage::from_pixel(28, 28, Rgba([255, 0, 255, 255]));
for y in 6..22 {
for x in 6..14 {
sheet.put_pixel(x, y, Rgba([64, 188, 74, 255]));
}
}
for y in 6..22 {
for x in 14..22 {
sheet.put_pixel(x, y, Rgba([244, 244, 236, 255]));
}
}
for y in 12..16 {
for x in 12..16 {
sheet.put_pixel(x, y, Rgba([255, 0, 255, 255]));
}
}
let cleaned = apply_generated_asset_sheet_alpha_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
assert_eq!(cleaned.get_pixel(8, 8).0[3], 255);
assert_eq!(cleaned.get_pixel(18, 8).0[3], 255);
assert_eq!(
cleaned.get_pixel(13, 13).0[3],
255,
"非边缘连通的 key 色像素不应被当成背景清掉"
);
}
#[test]
fn generated_asset_sheet_magenta_edge_matte_does_not_remove_white_subject() {
let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([0, 0, 0, 0]));
for y in 2..22 {
for x in 2..22 {
sheet.put_pixel(x, y, Rgba([246, 246, 240, 255]));
}
}
for y in 0..24 {
sheet.put_pixel(0, y, Rgba([255, 0, 255, 255]));
sheet.put_pixel(23, y, Rgba([255, 0, 255, 255]));
}
let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
assert_eq!(cleaned.get_pixel(1, 1).0[3], 255);
assert!(
cleaned
.pixels()
.any(|pixel| pixel.0 == [246, 246, 240, 255])
);
}
#[test]
fn generated_asset_sheet_magenta_alpha_defringes_pink_halo() {
let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([255, 0, 255, 255]));
for y in 7..17 {
for x in 7..17 {
sheet.put_pixel(x, y, Rgba([198, 170, 120, 255]));
}
}
for y in 6..18 {
sheet.put_pixel(6, y, Rgba([226, 26, 218, 220]));
sheet.put_pixel(17, y, Rgba([226, 26, 218, 220]));
}
for x in 6..18 {
sheet.put_pixel(x, 6, Rgba([226, 26, 218, 220]));
sheet.put_pixel(x, 17, Rgba([226, 26, 218, 220]));
}
let cleaned = apply_generated_asset_sheet_alpha_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
let edge = cleaned.get_pixel(6, 12).0;
assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
assert_eq!(cleaned.get_pixel(12, 12).0, [198, 170, 120, 255]);
if edge[3] > 0 {
assert!(
edge[0].saturating_sub(edge[1]) <= 76,
"红色 key 通道残留过强:{edge:?}"
);
assert!(
edge[2].saturating_sub(edge[1]) <= 76,
"蓝色 key 通道残留过强:{edge:?}"
);
}
}
#[test]
fn generated_asset_sheet_magenta_edge_matte_defringes_bottom_shadow() {
let mut sheet = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0]));
for y in 8..18 {
for x in 10..22 {
sheet.put_pixel(x, y, Rgba([202, 176, 126, 255]));
}
}
for y in 18..22 {
for x in 9..23 {
sheet.put_pixel(x, y, Rgba([224, 30, 220, 186]));
}
}
let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
assert!(
cleaned
.pixels()
.any(|pixel| pixel.0 == [202, 176, 126, 255])
);
assert!(
!cleaned.pixels().any(|pixel| {
let [red, green, blue, alpha] = pixel.0;
alpha > 0 && red > 200 && blue > 200 && green < 96
}),
"底部洋红残影应被删除或去彩边"
);
}
#[test] #[test]
fn generated_asset_sheet_view_edge_matte_trims_transparent_border() { fn generated_asset_sheet_view_edge_matte_trims_transparent_border() {
let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0])); let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0]));

View File

@@ -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>,
@@ -112,6 +121,8 @@ pub struct JumpHopActionRequest {
pub tile_assets: Option<Vec<JumpHopTileAsset>>, pub tile_assets: Option<Vec<JumpHopTileAsset>>,
#[serde(default)] #[serde(default)]
pub cover_composite: Option<String>, pub cover_composite: Option<String>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -127,14 +138,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 +220,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)]
@@ -212,6 +242,8 @@ pub struct JumpHopDraftResponse {
pub path: Option<JumpHopPath>, pub path: Option<JumpHopPath>,
#[serde(default)] #[serde(default)]
pub cover_composite: Option<String>, pub cover_composite: Option<String>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
pub generation_status: JumpHopGenerationStatus, pub generation_status: JumpHopGenerationStatus,
} }
@@ -251,6 +283,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,9 +307,13 @@ 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>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -305,6 +342,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 +381,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 +403,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 +438,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 +466,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()],

View File

@@ -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;
@@ -253,7 +253,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)
} }
@@ -262,17 +262,24 @@ impl SpacetimeClient {
payload: JumpHopStartRunRequest, payload: JumpHopStartRunRequest,
owner_user_id: String, owner_user_id: String,
) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> { ) -> Result<JumpHopRuntimeRunSnapshotResponse, SpacetimeClientError> {
let runtime_mode = normalize_jump_hop_runtime_mode(payload.runtime_mode.as_deref());
let profile_id = payload.profile_id; let profile_id = payload.profile_id;
let work_owner_user_id = if runtime_mode == "draft" {
owner_user_id.clone()
} else {
String::new()
};
let work = self let work = self
.get_jump_hop_work_profile(profile_id.clone(), String::new()) .get_jump_hop_work_profile(profile_id.clone(), work_owner_user_id)
.await?; .await?;
validate_jump_hop_runtime_ready(&work)?; 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
@@ -327,7 +334,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),
}; };
@@ -420,13 +429,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 只能启动已发布作品",
)); ));
@@ -436,11 +471,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() {
@@ -461,7 +496,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> {
@@ -499,7 +561,6 @@ enum JumpHopActionProcedure {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum JumpHopDraftMergeScope { enum JumpHopDraftMergeScope {
CompileDraft, CompileDraft,
RegenerateCharacter,
RegenerateTiles, RegenerateTiles,
UpdateWorkMeta, UpdateWorkMeta,
UpdateDifficulty, UpdateDifficulty,
@@ -508,7 +569,6 @@ enum JumpHopDraftMergeScope {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum JumpHopAssetRefresh { enum JumpHopAssetRefresh {
Preserve, Preserve,
Character,
Tiles, Tiles,
} }
@@ -520,12 +580,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());
@@ -538,16 +604,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,
@@ -587,6 +643,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();
} }
@@ -614,10 +677,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()
@@ -646,10 +706,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);
} }
@@ -673,6 +730,14 @@ fn merge_action_into_draft(
{ {
draft.cover_composite = Some(value.to_string()); draft.cover_composite = Some(value.to_string());
} }
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
) {
if let Some(asset) = payload.back_button_asset.clone() {
draft.back_button_asset = Some(asset);
}
}
if draft.work_title.trim().is_empty() { if draft.work_title.trim().is_empty() {
return Err(SpacetimeClientError::validation_failed( return Err(SpacetimeClientError::validation_failed(
"jump-hop work_title 不能为空", "jump-hop work_title 不能为空",
@@ -689,28 +754,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()
@@ -729,7 +785,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()),
@@ -739,6 +795,11 @@ fn build_compile_input(
tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?), tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?),
tile_assets_json: Some(json_string(&tile_assets)?), tile_assets_json: Some(json_string(&tile_assets)?),
cover_composite, cover_composite,
back_button_asset_json: draft
.back_button_asset
.as_ref()
.map(json_string)
.transpose()?,
generation_status: Some("ready".to_string()), generation_status: Some("ready".to_string()),
compiled_at_micros: now_micros, compiled_at_micros: now_micros,
}) })
@@ -809,26 +870,29 @@ 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: "跳一跳主题的正面30度视角主题物体图集物体本身作为跳跃落点".to_string(),
end_mood_prompt: None, end_mood_prompt: None,
character_asset: None, character_asset: None,
tile_atlas_asset: None, tile_atlas_asset: None,
tile_assets: Vec::new(), tile_assets: Vec::new(),
path: None, path: None,
cover_composite: None, cover_composite: None,
back_button_asset: None,
generation_status: JumpHopGenerationStatus::Draft, generation_status: JumpHopGenerationStatus::Draft,
} }
} }
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,
@@ -838,94 +902,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,
@@ -950,6 +926,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)
@@ -981,6 +973,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::*;
@@ -992,8 +994,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) =
@@ -1011,7 +1014,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
@@ -1025,59 +1028,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)
@@ -1091,7 +1054,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
@@ -1105,24 +1068,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());
@@ -1167,22 +1149,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")
); );
} }
/// 构造不携带资产覆盖的 JumpHop action单测按需再覆盖字段。
fn action(action_type: JumpHopActionType) -> JumpHopActionRequest { fn action(action_type: JumpHopActionType) -> JumpHopActionRequest {
JumpHopActionRequest { JumpHopActionRequest {
action_type, action_type,
profile_id: None, profile_id: None,
theme_text: None,
work_title: None, work_title: None,
work_description: None, work_description: None,
theme_tags: None, theme_tags: None,
@@ -1209,9 +1191,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()
} }
} }
@@ -1219,37 +1203,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,
@@ -1257,16 +1213,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,

View File

@@ -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,

View File

@@ -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,15 +62,40 @@ 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 {
let theme_text = if row.theme_text.trim().is_empty() {
row.work_title.clone()
} else {
row.theme_text.clone()
};
JumpHopGalleryCardResponse { JumpHopGalleryCardResponse {
public_work_code: row.public_work_code, public_work_code: row.public_work_code,
work_id: row.work_id, work_id: row.work_id,
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,
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),
@@ -104,15 +130,22 @@ fn map_jump_hop_session_snapshot(
fn map_jump_hop_work_snapshot( fn map_jump_hop_work_snapshot(
snapshot: JumpHopWorkSnapshot, snapshot: JumpHopWorkSnapshot,
) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> { ) -> Result<JumpHopWorkProfileResponse, SpacetimeClientError> {
let theme_text = if snapshot.theme_text.trim().is_empty() {
snapshot.work_title.clone()
} else {
snapshot.theme_text.clone()
};
let draft = JumpHopDraftResponse { let draft = JumpHopDraftResponse {
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: theme_text.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(),
@@ -126,6 +159,7 @@ fn map_jump_hop_work_snapshot(
.collect(), .collect(),
path: Some(map_jump_hop_path(snapshot.path.clone())), path: Some(map_jump_hop_path(snapshot.path.clone())),
cover_composite: snapshot.cover_composite.clone(), cover_composite: snapshot.cover_composite.clone(),
back_button_asset: snapshot.back_button_asset.clone().map(map_character_asset),
generation_status: parse_generation_status(&snapshot.generation_status), generation_status: parse_generation_status(&snapshot.generation_status),
}; };
let character_asset = draft let character_asset = draft
@@ -143,6 +177,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,
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 +194,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
@@ -166,19 +202,27 @@ fn map_jump_hop_work_snapshot(
.into_iter() .into_iter()
.map(map_tile_asset) .map(map_tile_asset)
.collect(), .collect(),
back_button_asset: snapshot.back_button_asset.map(map_character_asset),
}) })
} }
fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse { fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse {
let theme_text = if snapshot.theme_text.trim().is_empty() {
snapshot.work_title.clone()
} else {
snapshot.theme_text.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,
@@ -191,6 +235,7 @@ fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftRe
.collect(), .collect(),
path: snapshot.path.map(map_jump_hop_path), path: snapshot.path.map(map_jump_hop_path),
cover_composite: snapshot.cover_composite, cover_composite: snapshot.cover_composite,
back_button_asset: snapshot.back_button_asset.map(map_character_asset),
generation_status: parse_generation_status(&snapshot.generation_status), generation_status: parse_generation_status(&snapshot.generation_status),
} }
} }
@@ -211,10 +256,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 +311,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 +336,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,

View File

@@ -296,7 +296,9 @@ pub(crate) fn build_creation_entry_config_record_from_rows(
event_banners_json: header.event_banners_json, event_banners_json: header.event_banners_json,
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,
@@ -316,6 +318,8 @@ 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(),
unified_creation_spec_json: item.unified_creation_spec_json, unified_creation_spec_json: item.unified_creation_spec_json,
},
)
}) })
.collect(), .collect(),
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
@@ -353,7 +357,8 @@ 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| {
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,
@@ -368,6 +373,7 @@ fn map_creation_entry_config_snapshot(
updated_at_micros: item.updated_at_micros, updated_at_micros: item.updated_at_micros,
unified_creation_spec_json: item.unified_creation_spec_json, unified_creation_spec_json: item.unified_creation_spec_json,
}) })
})
.collect(), .collect(),
updated_at_micros: snapshot.updated_at_micros, updated_at_micros: snapshot.updated_at_micros,
} }
@@ -380,6 +386,150 @@ 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,
event_banners_json: 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,
unified_creation_spec_json: None,
}
}
#[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(),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners_json: None,
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,
unified_creation_spec_json: None,
}],
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> {

View File

@@ -370,6 +370,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;
@@ -438,6 +439,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;
@@ -1417,6 +1423,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;
@@ -1485,6 +1492,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;
@@ -2416,6 +2428,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>,
@@ -2630,6 +2643,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)?,
), ),
@@ -3059,6 +3075,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",
@@ -3544,6 +3566,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)?),
@@ -3887,6 +3912,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)?),
@@ -4146,6 +4174,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>,
@@ -4438,6 +4467,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,
@@ -5460,6 +5494,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);
@@ -5572,6 +5607,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",

View File

@@ -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,
);
}
}

View File

@@ -25,6 +25,7 @@ pub struct JumpHopDraftCompileInput {
pub tile_atlas_asset_json: Option<String>, pub tile_atlas_asset_json: Option<String>,
pub tile_assets_json: Option<String>, pub tile_assets_json: Option<String>,
pub cover_composite: Option<String>, pub cover_composite: Option<String>,
pub back_button_asset_json: Option<String>,
pub generation_status: Option<String>, pub generation_status: Option<String>,
pub compiled_at_micros: i64, pub compiled_at_micros: i64,
} }

View File

@@ -14,6 +14,7 @@ pub struct JumpHopDraftSnapshot {
pub template_id: String, pub template_id: String,
pub template_name: String, pub template_name: String,
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>,
@@ -27,6 +28,7 @@ pub struct JumpHopDraftSnapshot {
pub tile_assets: Vec<JumpHopTileAssetSnapshot>, pub tile_assets: Vec<JumpHopTileAssetSnapshot>,
pub path: Option<JumpHopPath>, pub path: Option<JumpHopPath>,
pub cover_composite: Option<String>, pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub generation_status: String, pub generation_status: String,
} }

View File

@@ -12,6 +12,7 @@ pub struct JumpHopGalleryCardViewRow {
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,
pub theme_tags: Vec<String>, pub theme_tags: Vec<String>,
@@ -38,6 +39,7 @@ pub struct JumpHopGalleryCardViewRowCols {
pub profile_id: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>, pub profile_id: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub owner_user_id: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>, pub owner_user_id: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub author_display_name: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>, pub author_display_name: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub theme_text: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub work_title: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>, pub work_title: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub work_description: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>, pub work_description: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, String>,
pub theme_tags: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, Vec<String>>, pub theme_tags: __sdk::__query_builder::Col<JumpHopGalleryCardViewRow, Vec<String>>,
@@ -63,6 +65,7 @@ impl __sdk::__query_builder::HasCols for JumpHopGalleryCardViewRow {
table_name, table_name,
"author_display_name", "author_display_name",
), ),
theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"),
work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), work_title: __sdk::__query_builder::Col::new(table_name, "work_title"),
work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), work_description: __sdk::__query_builder::Col::new(table_name, "work_description"),
theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"),

View File

@@ -16,6 +16,7 @@ pub struct JumpHopGalleryViewRow {
pub owner_user_id: String, pub owner_user_id: String,
pub source_session_id: String, pub source_session_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,
pub theme_tags: Vec<String>, pub theme_tags: Vec<String>,
@@ -51,6 +52,7 @@ pub struct JumpHopGalleryViewRowCols {
pub owner_user_id: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>, pub owner_user_id: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub source_session_id: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>, pub source_session_id: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub author_display_name: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>, pub author_display_name: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub theme_text: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub work_title: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>, pub work_title: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub work_description: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>, pub work_description: __sdk::__query_builder::Col<JumpHopGalleryViewRow, String>,
pub theme_tags: __sdk::__query_builder::Col<JumpHopGalleryViewRow, Vec<String>>, pub theme_tags: __sdk::__query_builder::Col<JumpHopGalleryViewRow, Vec<String>>,
@@ -88,6 +90,7 @@ impl __sdk::__query_builder::HasCols for JumpHopGalleryViewRow {
table_name, table_name,
"author_display_name", "author_display_name",
), ),
theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"),
work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), work_title: __sdk::__query_builder::Col::new(table_name, "work_title"),
work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), work_description: __sdk::__query_builder::Col::new(table_name, "work_description"),
theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"),

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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")
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
} }

View File

@@ -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,
} }

View File

@@ -19,6 +19,7 @@ pub struct JumpHopRuntimeRunRow {
pub snapshot_json: String, pub snapshot_json: String,
pub created_at: __sdk::Timestamp, pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp,
pub runtime_mode: Option<String>,
} }
impl __sdk::InModule for JumpHopRuntimeRunRow { impl __sdk::InModule for JumpHopRuntimeRunRow {
@@ -41,6 +42,7 @@ pub struct JumpHopRuntimeRunRowCols {
pub snapshot_json: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, String>, pub snapshot_json: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, String>,
pub created_at: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, __sdk::Timestamp>, pub created_at: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, __sdk::Timestamp>, pub updated_at: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, __sdk::Timestamp>,
pub runtime_mode: __sdk::__query_builder::Col<JumpHopRuntimeRunRow, Option<String>>,
} }
impl __sdk::__query_builder::HasCols for JumpHopRuntimeRunRow { impl __sdk::__query_builder::HasCols for JumpHopRuntimeRunRow {
@@ -62,6 +64,7 @@ impl __sdk::__query_builder::HasCols for JumpHopRuntimeRunRow {
snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"), snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
runtime_mode: __sdk::__query_builder::Col::new(table_name, "runtime_mode"),
} }
} }
} }

View File

@@ -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,

View File

@@ -32,6 +32,8 @@ pub struct JumpHopWorkProfileRow {
pub updated_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp,
pub published_at: Option<__sdk::Timestamp>, pub published_at: Option<__sdk::Timestamp>,
pub visible: bool, pub visible: bool,
pub theme_text: Option<String>,
pub back_button_asset_json: Option<String>,
} }
impl __sdk::InModule for JumpHopWorkProfileRow { impl __sdk::InModule for JumpHopWorkProfileRow {
@@ -67,6 +69,8 @@ pub struct JumpHopWorkProfileRowCols {
pub updated_at: __sdk::__query_builder::Col<JumpHopWorkProfileRow, __sdk::Timestamp>, pub updated_at: __sdk::__query_builder::Col<JumpHopWorkProfileRow, __sdk::Timestamp>,
pub published_at: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<__sdk::Timestamp>>, pub published_at: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<__sdk::Timestamp>>,
pub visible: __sdk::__query_builder::Col<JumpHopWorkProfileRow, bool>, pub visible: __sdk::__query_builder::Col<JumpHopWorkProfileRow, bool>,
pub theme_text: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<String>>,
pub back_button_asset_json: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<String>>,
} }
impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow { impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow {
@@ -107,6 +111,11 @@ impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow {
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"),
visible: __sdk::__query_builder::Col::new(table_name, "visible"), visible: __sdk::__query_builder::Col::new(table_name, "visible"),
theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"),
back_button_asset_json: __sdk::__query_builder::Col::new(
table_name,
"back_button_asset_json",
),
} }
} }
} }

View File

@@ -16,6 +16,7 @@ pub struct JumpHopWorkSnapshot {
pub owner_user_id: String, pub owner_user_id: String,
pub source_session_id: String, pub source_session_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,
pub theme_tags: Vec<String>, pub theme_tags: Vec<String>,
@@ -30,6 +31,7 @@ pub struct JumpHopWorkSnapshot {
pub path: JumpHopPath, pub path: JumpHopPath,
pub cover_image_src: String, pub cover_image_src: String,
pub cover_composite: Option<String>, pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub publication_status: String, pub publication_status: String,
pub publish_ready: bool, pub publish_ready: bool,
pub play_count: u32, pub play_count: u32,

View File

@@ -52,6 +52,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGall
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.theme_text,
work_title: row.work_title, work_title: row.work_title,
work_description: row.work_description, work_description: row.work_description,
theme_tags: row.theme_tags, theme_tags: row.theme_tags,
@@ -74,6 +75,7 @@ pub struct JumpHopGalleryViewRow {
pub owner_user_id: String, pub owner_user_id: String,
pub source_session_id: String, pub source_session_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,
pub theme_tags: Vec<String>, pub theme_tags: Vec<String>,
@@ -103,6 +105,7 @@ pub struct JumpHopGalleryCardViewRow {
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,
pub theme_tags: Vec<String>, pub theme_tags: Vec<String>,
@@ -264,6 +267,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,
@@ -291,6 +317,7 @@ fn create_jump_hop_agent_session_tx(
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: config.theme_text.clone(),
work_title: input.work_title.clone(), work_title: input.work_title.clone(),
work_description: input.work_description.clone(), work_description: input.work_description.clone(),
theme_tags: parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?, theme_tags: parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?,
@@ -304,6 +331,7 @@ fn create_jump_hop_agent_session_tx(
tile_assets: Vec::new(), tile_assets: Vec::new(),
path: None, path: None,
cover_composite: None, cover_composite: None,
back_button_asset: None,
generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(), generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(),
}; };
ctx.db ctx.db
@@ -356,6 +384,7 @@ fn compile_jump_hop_draft_tx(
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: Some(input.profile_id.clone()), profile_id: Some(input.profile_id.clone()),
theme_text: clean_string(&config.theme_text, &input.work_title),
work_title: clean_string(&input.work_title, "跳一跳作品"), work_title: clean_string(&input.work_title, "跳一跳作品"),
work_description: input.work_description.trim().to_string(), work_description: input.work_description.trim().to_string(),
theme_tags: tags.clone(), theme_tags: tags.clone(),
@@ -382,6 +411,11 @@ fn compile_jump_hop_draft_tx(
.unwrap_or_default(), .unwrap_or_default(),
path: Some(path.clone()), path: Some(path.clone()),
cover_composite: input.cover_composite.as_deref().and_then(clean_optional), cover_composite: input.cover_composite.as_deref().and_then(clean_optional),
back_button_asset: input
.back_button_asset_json
.as_deref()
.map(parse_json)
.transpose()?,
generation_status: input generation_status: input
.generation_status .generation_status
.clone() .clone()
@@ -416,12 +450,14 @@ fn compile_jump_hop_draft_tx(
path_json: to_json_string(&path), path_json: to_json_string(&path),
cover_image_src: draft.cover_composite.clone().unwrap_or_default(), cover_image_src: draft.cover_composite.clone().unwrap_or_default(),
cover_composite: draft.cover_composite.clone().unwrap_or_default(), cover_composite: draft.cover_composite.clone().unwrap_or_default(),
back_button_asset_json: draft.back_button_asset.as_ref().map(to_json_string),
generation_status: draft.generation_status.clone(), generation_status: draft.generation_status.clone(),
publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(), publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(),
play_count: 0, play_count: 0,
updated_at: compiled_at, updated_at: compiled_at,
published_at: None, published_at: None,
visible: true, visible: true,
theme_text: Some(draft.theme_text.clone()),
}; };
upsert_work(ctx, row); upsert_work(ctx, row);
replace_session( replace_session(
@@ -612,6 +648,15 @@ 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_DRAFT && work.owner_user_id != input.owner_user_id {
return Err("jump_hop draft runtime 只能由作品所有者启动".to_string());
}
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(),
@@ -622,8 +667,10 @@ 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, runtime_mode);
if runtime_mode == JUMP_HOP_RUNTIME_MODE_PUBLISHED {
increment_work_play_count(ctx, &work, input.started_at_ms); increment_work_play_count(ctx, &work, input.started_at_ms);
}
insert_event( insert_event(
ctx, ctx,
input.client_event_id, input.client_event_id,
@@ -651,10 +698,22 @@ 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(
&snapshot,
input.drag_distance,
input.drag_vector_x,
input.drag_vector_y,
input.jumped_at_ms as u64,
)
.map_err(|error| error.to_string())?; .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
&& normalize_runtime_mode(row.runtime_mode.as_deref().unwrap_or_default())
== JUMP_HOP_RUNTIME_MODE_PUBLISHED
{
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,
@@ -671,6 +730,50 @@ 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 work = find_work(ctx, &input.profile_id)?;
if work.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED {
return Err("jump_hop leaderboard 只开放已发布作品".to_string());
}
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,
@@ -684,7 +787,8 @@ fn restart_jump_hop_run_tx(
) )
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let next = domain_next; let next = domain_next;
upsert_run(ctx, &next, input.restarted_at_ms); let runtime_mode = normalize_runtime_mode(source.runtime_mode.as_deref().unwrap_or_default());
upsert_run(ctx, &next, input.restarted_at_ms, runtime_mode);
insert_event( insert_event(
ctx, ctx,
input.client_action_id, input.client_action_id,
@@ -706,6 +810,7 @@ fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result<JumpHopGalleryV
owner_user_id: work.owner_user_id, owner_user_id: work.owner_user_id,
source_session_id: work.source_session_id, source_session_id: work.source_session_id,
author_display_name: work.author_display_name, author_display_name: work.author_display_name,
theme_text: work.theme_text,
work_title: work.work_title, work_title: work.work_title,
work_description: work.work_description, work_description: work.work_description,
theme_tags: work.theme_tags, theme_tags: work.theme_tags,
@@ -771,12 +876,18 @@ fn build_session_snapshot(
fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result<JumpHopWorkSnapshot, String> { fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result<JumpHopWorkSnapshot, String> {
let path = parse_json(&row.path_json)?; let path = parse_json(&row.path_json)?;
let theme_text = row
.theme_text
.as_deref()
.and_then(clean_optional)
.unwrap_or_else(|| row.work_title.trim().to_string());
Ok(JumpHopWorkSnapshot { Ok(JumpHopWorkSnapshot {
work_id: row.work_id.clone(), work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(), profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(), owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(), source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(), author_display_name: row.author_display_name.clone(),
theme_text,
work_title: row.work_title.clone(), work_title: row.work_title.clone(),
work_description: row.work_description.clone(), work_description: row.work_description.clone(),
theme_tags: parse_tags(&row.theme_tags_json)?, theme_tags: parse_tags(&row.theme_tags_json)?,
@@ -795,6 +906,12 @@ fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result<JumpHopWorkSnapsho
path, path,
cover_image_src: row.cover_image_src.clone(), cover_image_src: row.cover_image_src.clone(),
cover_composite: clean_optional(&row.cover_composite), cover_composite: clean_optional(&row.cover_composite),
back_button_asset: row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.map(|value| parse_json(&value))
.transpose()?,
publication_status: row.publication_status.clone(), publication_status: row.publication_status.clone(),
publish_ready: is_publish_ready(row), publish_ready: is_publish_ready(row),
play_count: row.play_count, play_count: row.play_count,
@@ -821,7 +938,11 @@ fn sync_session_from_work_update(
}; };
let mut config = parse_config(&session.config_json)?; let mut config = parse_config(&session.config_json)?;
config.theme_text = work.work_title.clone(); config.theme_text = work
.theme_text
.as_deref()
.and_then(clean_optional)
.unwrap_or_else(|| work.work_title.trim().to_string());
config.difficulty = work.difficulty.clone(); config.difficulty = work.difficulty.clone();
config.style_preset = work.style_preset.clone(); config.style_preset = work.style_preset.clone();
config.character_prompt = work.character_prompt.clone(); config.character_prompt = work.character_prompt.clone();
@@ -832,6 +953,7 @@ fn sync_session_from_work_update(
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: Some(work.profile_id.clone()), profile_id: Some(work.profile_id.clone()),
theme_text: config.theme_text.clone(),
work_title: work.work_title.clone(), work_title: work.work_title.clone(),
work_description: work.work_description.clone(), work_description: work.work_description.clone(),
theme_tags: parse_tags(&work.theme_tags_json)?, theme_tags: parse_tags(&work.theme_tags_json)?,
@@ -849,6 +971,12 @@ fn sync_session_from_work_update(
tile_assets: parse_json_or_default(&work.tile_assets_json), tile_assets: parse_json_or_default(&work.tile_assets_json),
path: Some(parse_json(&work.path_json)?), path: Some(parse_json(&work.path_json)?),
cover_composite: clean_optional(&work.cover_composite), cover_composite: clean_optional(&work.cover_composite),
back_button_asset: work
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.map(|value| parse_json(&value))
.transpose()?,
generation_status: work.generation_status.clone(), generation_status: work.generation_status.clone(),
}; };
@@ -945,7 +1073,12 @@ fn replace_session(
ctx.db.jump_hop_agent_session().insert(next); ctx.db.jump_hop_agent_session().insert(next);
} }
fn upsert_run(ctx: &ReducerContext, snapshot: &JumpHopRunSnapshot, updated_at_ms: i64) { fn upsert_run(
ctx: &ReducerContext,
snapshot: &JumpHopRunSnapshot,
updated_at_ms: i64,
runtime_mode: &str,
) {
if let Some(old) = ctx if let Some(old) = ctx
.db .db
.jump_hop_runtime_run() .jump_hop_runtime_run()
@@ -955,9 +1088,12 @@ fn upsert_run(ctx: &ReducerContext, snapshot: &JumpHopRunSnapshot, updated_at_ms
ctx.db.jump_hop_runtime_run().delete(old); ctx.db.jump_hop_runtime_run().delete(old);
} }
let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)); let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000));
ctx.db ctx.db.jump_hop_runtime_run().insert(run_row_from_snapshot(
.jump_hop_runtime_run() snapshot,
.insert(run_row_from_snapshot(snapshot, created_at, created_at)); created_at,
created_at,
runtime_mode,
));
} }
fn replace_run( fn replace_run(
@@ -971,6 +1107,7 @@ fn replace_run(
snapshot, snapshot,
old.created_at, old.created_at,
Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)), Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)),
normalize_runtime_mode(old.runtime_mode.as_deref().unwrap_or_default()),
)); ));
} }
@@ -978,6 +1115,7 @@ fn run_row_from_snapshot(
snapshot: &JumpHopRunSnapshot, snapshot: &JumpHopRunSnapshot,
created_at: Timestamp, created_at: Timestamp,
updated_at: Timestamp, updated_at: Timestamp,
runtime_mode: &str,
) -> JumpHopRuntimeRunRow { ) -> JumpHopRuntimeRunRow {
JumpHopRuntimeRunRow { JumpHopRuntimeRunRow {
run_id: snapshot.run_id.clone(), run_id: snapshot.run_id.clone(),
@@ -995,6 +1133,7 @@ fn run_row_from_snapshot(
snapshot_json: to_json_string(snapshot), snapshot_json: to_json_string(snapshot),
created_at, created_at,
updated_at, updated_at,
runtime_mode: Some(normalize_runtime_mode(runtime_mode).to_string()),
} }
} }
@@ -1040,12 +1179,129 @@ 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()
&& row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.is_some()
} }
fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot { fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot {
@@ -1054,8 +1310,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}主题的正面30度视角主题物体图集物体本身作为跳跃落点"),
end_mood_prompt: String::new(), end_mood_prompt: String::new(),
} }
} }
@@ -1235,6 +1491,8 @@ fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow {
updated_at: row.updated_at, updated_at: row.updated_at,
published_at: row.published_at, published_at: row.published_at,
visible: row.visible, visible: row.visible,
theme_text: row.theme_text.clone(),
back_button_asset_json: row.back_button_asset_json.clone(),
} }
} }
@@ -1252,6 +1510,68 @@ fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow {
snapshot_json: row.snapshot_json.clone(), snapshot_json: row.snapshot_json.clone(),
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
runtime_mode: row.runtime_mode.clone(),
}
}
#[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
));
} }
} }

View File

@@ -56,6 +56,12 @@ pub struct JumpHopWorkProfileRow {
// 后台可见性开关;默认显示,隐藏后不进入公开列表。 // 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)] #[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool, pub(crate) visible: bool,
// 跳一跳生成主题独立于作品标题;旧行按 work_title 兜底。
#[default(None::<String>)]
pub(crate) theme_text: Option<String>,
// 跳一跳左上角真实可点击返回按钮的独立透明资产快照;旧行为空时运行态使用样式兜底。
#[default(None::<String>)]
pub(crate) back_button_asset_json: Option<String>,
} }
#[spacetimedb::table( #[spacetimedb::table(
@@ -77,6 +83,9 @@ pub struct JumpHopRuntimeRunRow {
pub(crate) snapshot_json: String, pub(crate) snapshot_json: String,
pub(crate) created_at: Timestamp, pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp, pub(crate) updated_at: Timestamp,
// draft / published用于隔离试玩统计和公开排行榜旧行按 published 兜底。
#[default(None::<String>)]
pub(crate) runtime_mode: Option<String>,
} }
#[spacetimedb::table( #[spacetimedb::table(
@@ -94,3 +103,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,
}

View File

@@ -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 {
@@ -54,6 +56,7 @@ pub struct JumpHopDraftCompileInput {
pub tile_atlas_asset_json: Option<String>, pub tile_atlas_asset_json: Option<String>,
pub tile_assets_json: Option<String>, pub tile_assets_json: Option<String>,
pub cover_composite: Option<String>, pub cover_composite: Option<String>,
pub back_button_asset_json: Option<String>,
pub generation_status: Option<String>, pub generation_status: Option<String>,
pub compiled_at_micros: i64, pub compiled_at_micros: i64,
} }
@@ -102,6 +105,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,
} }
@@ -112,11 +116,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,
} }
@@ -158,6 +164,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 {
@@ -187,10 +218,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,
@@ -203,6 +240,8 @@ pub struct JumpHopDraftSnapshot {
pub template_id: String, pub template_id: String,
pub template_name: String, pub template_name: String,
pub profile_id: Option<String>, pub profile_id: Option<String>,
#[serde(default)]
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>,
@@ -216,6 +255,7 @@ pub struct JumpHopDraftSnapshot {
pub tile_assets: Vec<JumpHopTileAssetSnapshot>, pub tile_assets: Vec<JumpHopTileAssetSnapshot>,
pub path: Option<module_jump_hop::JumpHopPath>, pub path: Option<module_jump_hop::JumpHopPath>,
pub cover_composite: Option<String>, pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub generation_status: String, pub generation_status: String,
} }
@@ -244,6 +284,7 @@ pub struct JumpHopWorkSnapshot {
pub owner_user_id: String, pub owner_user_id: String,
pub source_session_id: String, pub source_session_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,
pub theme_tags: Vec<String>, pub theme_tags: Vec<String>,
@@ -258,6 +299,7 @@ pub struct JumpHopWorkSnapshot {
pub path: module_jump_hop::JumpHopPath, pub path: module_jump_hop::JumpHopPath,
pub cover_image_src: String, pub cover_image_src: String,
pub cover_composite: Option<String>, pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub publication_status: String, pub publication_status: String,
pub publish_ready: bool, pub publish_ready: bool,
pub play_count: u32, pub play_count: u32,

View File

@@ -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,
@@ -1328,6 +1330,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object object
.entry("visible".to_string()) .entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true)); .or_insert_with(|| serde_json::Value::Bool(true));
if table_name == "jump_hop_work_profile" {
// 中文注释:跳一跳主题返回按钮资产晚于首版作品表加入,旧迁移包按未生成按钮兼容。
object
.entry("back_button_asset_json".to_string())
.or_insert(serde_json::Value::Null);
}
} }
} }
if table_name == "match_3_d_work_profile" || table_name == "match3d_work_profile" { if table_name == "match_3_d_work_profile" || table_name == "match3d_work_profile" {

View File

@@ -338,6 +338,7 @@ fn map_custom_world_detail_entry(row: CustomWorldProfileSnapshot) -> PublicWorkD
fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalleryEntry { fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalleryEntry {
let subtitle = jump_hop_difficulty_label(&row.difficulty).to_string(); let subtitle = jump_hop_difficulty_label(&row.difficulty).to_string();
let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros); let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros);
let theme_text = row.theme_text.clone();
PublicWorkGalleryEntry { PublicWorkGalleryEntry {
source_type: "jump-hop".to_string(), source_type: "jump-hop".to_string(),
@@ -352,7 +353,7 @@ fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalle
summary_text: row.work_description, summary_text: row.work_description,
cover_image_src: empty_string_to_option(row.cover_image_src), cover_image_src: empty_string_to_option(row.cover_image_src),
cover_asset_id: None, cover_asset_id: None,
theme_tags: fallback_tags(row.theme_tags, &["跳一跳"]), theme_tags: fallback_tags(row.theme_tags, &[theme_text.as_str(), "跳一跳"]),
play_count: row.play_count, play_count: row.play_count,
remix_count: 0, remix_count: 0,
like_count: 0, like_count: 0,
@@ -363,6 +364,7 @@ fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalle
} }
fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntry { fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntry {
let theme_text = row.theme_text.clone();
let entry = PublicWorkGalleryEntry { let entry = PublicWorkGalleryEntry {
source_type: "jump-hop".to_string(), source_type: "jump-hop".to_string(),
work_id: row.work_id, work_id: row.work_id,
@@ -376,7 +378,7 @@ fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntr
summary_text: row.work_description, summary_text: row.work_description,
cover_image_src: empty_string_to_option(row.cover_image_src), cover_image_src: empty_string_to_option(row.cover_image_src),
cover_asset_id: None, cover_asset_id: None,
theme_tags: fallback_tags(row.theme_tags, &["跳一跳"]), theme_tags: fallback_tags(row.theme_tags, &[theme_text.as_str(), "跳一跳"]),
play_count: row.play_count, play_count: row.play_count,
remix_count: 0, remix_count: 0,
like_count: 0, like_count: 0,
@@ -388,6 +390,7 @@ fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntr
"sourceType": "jump-hop", "sourceType": "jump-hop",
"difficulty": row.difficulty, "difficulty": row.difficulty,
"stylePreset": row.style_preset, "stylePreset": row.style_preset,
"themeText": theme_text,
"tileAssetCount": row.tile_assets.len(), "tileAssetCount": row.tile_assets.len(),
"platformCount": row.path.platforms.len(), "platformCount": row.path.platforms.len(),
"generationStatus": row.generation_status, "generationStatus": row.generation_status,

View File

@@ -296,6 +296,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) {
@@ -447,6 +448,35 @@ fn migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx: &ReducerContext,
}); });
} }
fn migrate_jump_hop_entry_from_old_puzzle_default(ctx: &ReducerContext, now: Timestamp) {
let id = "jump-hop".to_string();
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
return;
};
// 中文注释:只纠偏跳一跳重设计前的系统默认入口,避免覆盖后台手动配置。
let still_old_puzzle_default = row.title == "跳一跳"
&& row.subtitle == "俯视角跳跃闯关"
&& row.badge == "可创建"
&& row.image_src == "/creation-type-references/puzzle.webp"
&& row.visible
&& row.open
&& row.sort_order == 45;
if !still_old_puzzle_default {
return;
}
ctx.db
.creation_entry_type_config()
.id()
.update(CreationEntryTypeConfig {
subtitle: "主题驱动平台跳跃".to_string(),
image_src: "/creation-type-references/jump-hop.webp".to_string(),
updated_at: now,
..row
});
}
fn default_creation_entry_type_configs(now: Timestamp) -> Vec<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()

View File

@@ -525,6 +525,7 @@ test('creation start card maps backend jump-hop draft to template card', () => {
profileId: 'jump-hop-profile-1', profileId: 'jump-hop-profile-1',
ownerUserId: 'user-1', ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-1', sourceSessionId: 'jump-hop-session-1',
themeText: '跳一跳生成草稿',
workTitle: '跳一跳生成草稿', workTitle: '跳一跳生成草稿',
workDescription: '后端仍在生成跳一跳玩法。', workDescription: '后端仍在生成跳一跳玩法。',
themeTags: ['跳一跳'], themeTags: ['跳一跳'],

View File

@@ -1,144 +1,205 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import { beforeEach, expect, test, vi } from 'vitest';
import { expect, test, vi } from 'vitest';
import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { JumpHopWorkProfileResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { JumpHopResultView } from './JumpHopResultView'; import { JumpHopResultView } from './JumpHopResultView';
const draft: JumpHopDraftResponse = { vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
templateId: 'jump-hop', useJumpHopLeaderboard: vi.fn(),
templateName: '跳一跳', }));
profileId: 'profile-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
{
tileType: 'finish',
imageSrc: 'data:image/png;base64,tile-finish',
imageObjectKey: 'jump-hop/tile-finish.png',
assetObjectId: 'asset-tile-finish',
sourceAtlasCell: 'A2',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
{
platformId: 'platform-2',
tileType: 'finish',
x: 16,
y: 18,
width: 60,
height: 42,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 2,
},
],
finishIndex: 1,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready',
};
test('jump hop result view exposes test run and publish actions', async () => { beforeEach(() => {
const user = userEvent.setup(); vi.clearAllMocks();
const onBack = vi.fn(); vi.mocked(useJumpHopLeaderboard).mockReturnValue({
const onEdit = vi.fn(); leaderboard: null,
const onStartTestRun = vi.fn(); isLoading: false,
const onPublish = vi.fn(); error: null,
const onRegenerateCharacter = vi.fn(); refresh: vi.fn(),
const onRegenerateTiles = vi.fn(); });
});
test('跳一跳结果页展示排行榜列表', () => {
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
leaderboard: {
profileId: 'jump-hop-profile-test',
items: [
{
rank: 1,
playerId: 'player-1',
successfulJumpCount: 12,
durationMs: 40123,
updatedAt: '2026-05-27T00:00:00Z',
},
{
rank: 2,
playerId: 'player-2',
successfulJumpCount: 10,
durationMs: 38210,
updatedAt: '2026-05-26T00:00:00Z',
},
],
viewerBest: null,
},
isLoading: false,
error: null,
refresh: vi.fn(),
});
render( render(
<JumpHopResultView <JumpHopResultView
profile={draft} profile={buildProfile({ publicationStatus: 'published' })}
onBack={onBack} onBack={() => {}}
onEdit={onEdit} onEdit={() => {}}
onStartTestRun={onStartTestRun} onStartTestRun={() => {}}
onPublish={onPublish} onPublish={() => {}}
onRegenerateCharacter={onRegenerateCharacter} onRegenerateTiles={() => {}}
onRegenerateTiles={onRegenerateTiles}
/>, />,
); );
expect(screen.getByText('云端跳台')).toBeTruthy(); expect(screen.getByText('排行榜')).toBeTruthy();
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy(); expect(screen.getByText('player-1')).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy(); expect(screen.getByText('12 跳')).toBeTruthy();
expect(screen.getByText('00:40')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' })); expect(screen.getByText('player-2')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '发布' }));
await user.click(screen.getByRole('button', { name: '返回' }));
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: '地块' }));
expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onPublish).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onRegenerateCharacter).toHaveBeenCalledTimes(1);
expect(onRegenerateTiles).toHaveBeenCalledTimes(1);
}); });
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
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',
);
});
test('跳一跳草稿结果页不请求公开排行榜', () => {
render(
<JumpHopResultView
profile={buildProfile({ publicationStatus: 'draft' })}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
expect(screen.queryByText('排行榜')).toBeNull();
});
function buildProfile(
options: {
publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
} = {},
): 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: options.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,
backButtonAsset: 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: [],
backButtonAsset: null,
};
}

View File

@@ -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 normalizePathPlatforms(path: JumpHopPath | null | undefined) { function JumpHopDefaultCharacterPreview() {
const platforms = path?.platforms ?? []; return (
if (platforms.length === 0) { <div className="relative grid aspect-[1/1] place-items-center overflow-hidden bg-[linear-gradient(180deg,#eff6ff_0%,#fff7ed_100%)]">
return []; <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}
const coordinatePlatforms = platforms.filter( alt=""
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y), 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>
); );
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
const xValues = shouldUseCoordinates
? coordinatePlatforms.map((platform) => platform.x)
: [];
const yValues = shouldUseCoordinates
? coordinatePlatforms.map((platform) => platform.y)
: [];
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues);
const minY = Math.min(...yValues);
const maxY = Math.max(...yValues);
const xRange = Math.max(maxX - minX, 1);
const yRange = Math.max(maxY - minY, 1);
const denominator = Math.max(platforms.length - 1, 1);
return platforms.map((platform, index): MiniMapPlatform => {
const sequenceRatio = index / denominator;
const hasCoordinates =
shouldUseCoordinates &&
isFiniteNumber(platform.x) &&
isFiniteNumber(platform.y);
const x = hasCoordinates
? 12 + ((platform.x - minX) / xRange) * 76
: 12 + sequenceRatio * 76;
const y = hasCoordinates
? 14 + ((platform.y - minY) / yRange) * 72
: 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18;
return {
platform,
index,
x,
y,
width: Math.min(Math.max(platform.width || 54, 42), 82),
height: Math.min(Math.max(platform.height || 42, 34), 68),
isStart: index === 0 || platform.tileType === 'start',
isFinish:
index === path?.finishIndex ||
platform.tileType === 'finish' ||
platform.tileType === 'target',
};
});
}
function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) {
const platforms = useMemo(() => normalizePathPlatforms(path), [path]);
const tone =
difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard;
const pathPoints = platforms
.map((platform) => `${platform.x},${platform.y}`)
.join(' ');
if (platforms.length === 0) {
return null;
} }
function JumpHopTilePoolPreview({
tileAssets,
tileAtlasAsset,
tileAtlasFallbackSrc,
}: {
tileAssets: JumpHopTileAsset[];
tileAtlasAsset?: JumpHopDraftResponse['tileAtlasAsset'] | null;
tileAtlasFallbackSrc?: string | null;
}) {
const visibleTiles = tileAssets.slice(0, 25);
const atlasSrc =
tileAtlasAsset?.imageSrc?.trim() || tileAtlasFallbackSrc?.trim() || '';
const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc;
if (visibleTiles.length > 0) {
return ( return (
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
{visibleTiles.map((tile, index) => (
<div <div
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]" key={tile.tileId ?? `${tile.sourceAtlasCell}-${index}`}
style={ className="grid min-h-0 place-items-center overflow-hidden rounded-[0.45rem] border border-white/80 bg-slate-50"
{
'--jump-hop-path-accent': tone.accent,
'--jump-hop-path-soft': tone.soft,
} as CSSProperties
}
> >
<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%)]" /> {tile.imageSrc ? (
<svg <ResolvedAssetImage
viewBox="0 0 100 100" src={tile.imageSrc}
className="absolute inset-0 h-full w-full" refreshKey={tile.assetObjectId}
aria-hidden="true" alt=""
> className="h-full w-full object-contain"
<polyline
points={pathPoints}
fill="none"
stroke="var(--jump-hop-path-soft)"
strokeWidth="11"
strokeLinecap="round"
strokeLinejoin="round"
/> />
<polyline ) : (
points={pathPoints}
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;
return (
<div
key={
item.platform.platformId ||
`${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}
>
<span <span
className="h-2.5 w-2.5 rounded-full" className="h-4 w-4 rounded-full"
style={{ style={{
background: background:
item.isStart || item.isFinish ? tone.accent : '#ffffff', tileToneByType[tile.tileType] ?? tileToneByType.normal,
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
}} }}
/> />
{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 ? '起' : '终'} ))}
</span> </div>
) : null} );
}
if (atlasSrc) {
return (
<ResolvedAssetImage
src={atlasSrc}
refreshKey={atlasRefreshKey}
alt=""
className="aspect-[1/1] w-full object-cover"
/>
);
}
return (
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
{Array.from({ length: 25 }).map((_, index) => (
<span
key={index}
className="rounded-[0.45rem] border border-white/80"
style={{
background:
Object.values(tileToneByType)[index % Object.values(tileToneByType).length],
}}
/>
))}
</div>
);
}
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 (
<div
key={platform.platformId || index}
className="absolute aspect-[1.16/1] -translate-x-1/2 -translate-y-1/2"
style={style}
>
<div className="absolute inset-x-[12%] bottom-[-6%] h-[22%] rounded-full bg-slate-900/14 blur-[3px]" />
{asset?.imageSrc ? (
<ResolvedAssetImage
src={asset.imageSrc}
refreshKey={asset.assetObjectId}
alt=""
className="relative h-full w-full object-contain"
/>
) : (
<div
className="relative h-full w-full rounded-[18%] border-2 border-white/90 shadow-[0_10px_22px_rgba(15,23,42,0.14)]"
style={{
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> </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"> ) : null}
{platforms.length} </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 className="mt-3 grid gap-2">
{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,15 @@ 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 canShowLeaderboard =
isWorkProfile && profile.summary.publicationStatus === 'published';
const titleSource = isWorkProfile const titleSource = isWorkProfile
? profile.summary.workTitle ? profile.summary.workTitle
: profile.workTitle; : profile.workTitle;
@@ -278,15 +282,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 +311,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 +335,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}
tileAtlasFallbackSrc={
('tileAtlasImageSrc' in profile ('tileAtlasImageSrc' in profile
? profile.tileAtlasImageSrc ? profile.tileAtlasImageSrc
: null) ?? : null) ??
tileAtlasAsset?.imageSrc ?? 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 +367,9 @@ 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>
{canShowLeaderboard ? (
<JumpHopResultLeaderboard profileId={profileId} />
) : null}
{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}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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();
});
});

View File

@@ -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,
@@ -109,6 +112,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,
@@ -189,6 +193,7 @@ import {
jumpHopClient, jumpHopClient,
type JumpHopGalleryCardResponse, type JumpHopGalleryCardResponse,
type JumpHopRunResponse, type JumpHopRunResponse,
type JumpHopRuntimeRequestOptions,
type JumpHopSessionResponse, type JumpHopSessionResponse,
type JumpHopSessionSnapshotResponse, type JumpHopSessionSnapshotResponse,
JumpHopWorkProfileResponse, JumpHopWorkProfileResponse,
@@ -352,7 +357,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';
@@ -441,11 +445,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';
@@ -492,6 +496,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;
}; };
@@ -595,11 +623,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,
@@ -614,9 +642,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)) {
@@ -1967,6 +1995,7 @@ function buildJumpHopPendingSession(
templateId: 'jump-hop', templateId: 'jump-hop',
templateName: '跳一跳', templateName: '跳一跳',
profileId: item.profileId, profileId: item.profileId,
themeText: item.themeText || item.workTitle,
workTitle: item.workTitle, workTitle: item.workTitle,
workDescription: item.workDescription, workDescription: item.workDescription,
themeTags: item.themeTags, themeTags: item.themeTags,
@@ -2773,6 +2802,7 @@ function buildPendingJumpHopWorks(
profileId: `jump-hop-profile-${sessionId}`, profileId: `jump-hop-profile-${sessionId}`,
ownerUserId: '', ownerUserId: '',
sourceSessionId: sessionId, sourceSessionId: sessionId,
themeText: '跳一跳',
workTitle: '跳一跳草稿', workTitle: '跳一跳草稿',
workDescription: workDescription:
state.status === 'failed' state.status === 'failed'
@@ -3596,6 +3626,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<
@@ -5375,22 +5407,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 === 'big-fish-generating' 'square-hole': squareHoleGenerationState,
? bigFishGenerationState match3d: match3dGenerationState,
: selectionStage === 'square-hole-generating' 'baby-object-match': babyObjectMatchGenerationState,
? squareHoleGenerationState 'jump-hop': jumpHopGenerationState,
: selectionStage === 'jump-hop-generating' 'wooden-fish': woodenFishGenerationState,
? jumpHopGenerationState },
: selectionStage === 'wooden-fish-generating' );
? woodenFishGenerationState
: selectionStage === 'baby-object-match-generating'
? babyObjectMatchGenerationState
: null;
const shouldTickProgress = const shouldTickProgress =
selectionStage === 'visual-novel-generating' selectionStage === 'visual-novel-generating'
? visualNovelGenerationStartedAtMs != null && ? visualNovelGenerationStartedAtMs != null &&
@@ -7384,6 +7412,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);
@@ -8516,6 +8545,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]);
@@ -9545,6 +9575,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');
@@ -9559,6 +9590,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 ??
@@ -9673,7 +9706,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;
@@ -9689,6 +9722,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,
@@ -9714,9 +9750,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(
@@ -9792,7 +9826,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) {
@@ -9823,13 +9859,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);
@@ -9867,7 +9920,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(
@@ -9876,16 +9932,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(
@@ -9893,7 +9962,7 @@ export function PlatformEntryFlowShellImpl({
); );
} }
}, },
[jumpHopRun?.runId], [jumpHopRun?.runId, jumpHopRuntimeRequestOptions],
); );
const compileWoodenFishSession = useCallback( const compileWoodenFishSession = useCallback(
@@ -14426,6 +14495,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);
@@ -14460,14 +14537,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, {
@@ -14970,37 +15039,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);
}} }}
/> />
); );
@@ -16969,6 +17016,7 @@ export function PlatformEntryFlowShellImpl({
)} )}
progress={buildMiniGameDraftGenerationProgress( progress={buildMiniGameDraftGenerationProgress(
bigFishGenerationState, bigFishGenerationState,
miniGameGenerationProgressNowMs,
)} )}
isGenerating={isBigFishBusy} isGenerating={isBigFishBusy}
error={bigFishError} error={bigFishError}
@@ -17577,6 +17625,7 @@ export function PlatformEntryFlowShellImpl({
)} )}
progress={buildMiniGameDraftGenerationProgress( progress={buildMiniGameDraftGenerationProgress(
squareHoleGenerationState, squareHoleGenerationState,
miniGameGenerationProgressNowMs,
)} )}
isGenerating={isSquareHoleBusy} isGenerating={isSquareHoleBusy}
error={squareHoleError} error={squareHoleError}
@@ -17789,6 +17838,7 @@ export function PlatformEntryFlowShellImpl({
)} )}
progress={buildMiniGameDraftGenerationProgress( progress={buildMiniGameDraftGenerationProgress(
jumpHopGenerationState, jumpHopGenerationState,
miniGameGenerationProgressNowMs,
)} )}
isGenerating={isJumpHopBusy} isGenerating={isJumpHopBusy}
error={jumpHopError} error={jumpHopError}
@@ -17830,9 +17880,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');
}} }}
@@ -17868,6 +17915,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);
}} }}
@@ -17929,6 +17977,7 @@ export function PlatformEntryFlowShellImpl({
)} )}
progress={buildMiniGameDraftGenerationProgress( progress={buildMiniGameDraftGenerationProgress(
woodenFishGenerationState, woodenFishGenerationState,
miniGameGenerationProgressNowMs,
)} )}
isGenerating={isWoodenFishBusy} isGenerating={isWoodenFishBusy}
error={woodenFishError} error={woodenFishError}

View File

@@ -14,14 +14,15 @@ import type {
CustomWorldWorkSummary, CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent'; } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { import type {
BabyObjectMatchDraft, JumpHopRuntimeRunSnapshotResponse,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
JumpHopWorkDetailResponse, JumpHopWorkDetailResponse,
JumpHopWorkProfileResponse, JumpHopWorkProfileResponse,
JumpHopWorkSummaryResponse, JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop'; } from '../../../packages/shared/src/contracts/jumpHop';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -70,6 +71,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,
@@ -89,7 +91,6 @@ import {
regenerateBabyObjectMatchDraftAssets, regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft, saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object'; } from '../../services/edutainment-baby-object';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { match3dCreationClient } from '../../services/match3d-creation'; import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import { import {
@@ -610,6 +611,24 @@ vi.mock('../../services/puzzle-runtime', () => ({
usePuzzleRuntimeProp: vi.fn(), usePuzzleRuntimeProp: vi.fn(),
})); }));
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
deleteWork: vi.fn(),
executeAction: vi.fn(),
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/rpg-entry/rpgEntryLibraryClient', () => ({ vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
...rpgEntryLibraryServiceMocks, ...rpgEntryLibraryServiceMocks,
})); }));
@@ -657,23 +676,6 @@ vi.mock('../../services/edutainment-baby-object', () => ({
saveBabyObjectMatchDraft: vi.fn(), saveBabyObjectMatchDraft: vi.fn(),
})); }));
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
deleteWork: vi.fn(),
executeAction: vi.fn(),
getGalleryDetail: 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', () => ({ vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
woodenFishClient: { woodenFishClient: {
checkpointRun: vi.fn(), checkpointRun: vi.fn(),
@@ -684,9 +686,14 @@ vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
getGalleryDetail: vi.fn(), getGalleryDetail: vi.fn(),
getSession: vi.fn(), getSession: vi.fn(),
getWorkDetail: vi.fn(), getWorkDetail: vi.fn(),
listGallery: vi.fn(), listGallery: vi.fn(async () => ({
listWorks: vi.fn(), hasMore: false,
items: [],
nextCursor: null,
})),
listWorks: vi.fn(async () => ({ items: [] })),
publishWork: vi.fn(), publishWork: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(), startRun: vi.fn(),
}, },
})); }));
@@ -804,23 +811,6 @@ vi.mock('../../services/visual-novel-works', () => ({
updateVisualNovelWork: vi.fn(), updateVisualNovelWork: vi.fn(),
})); }));
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
woodenFishClient: {
checkpointRun: vi.fn(),
createSession: vi.fn(),
deleteWork: vi.fn(),
executeAction: vi.fn(),
finishRun: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
startRun: vi.fn(),
},
}));
vi.mock('../../services/visual-novel-creation', () => ({ vi.mock('../../services/visual-novel-creation', () => ({
compileVisualNovelWorkProfile: vi.fn(), compileVisualNovelWorkProfile: vi.fn(),
createVisualNovelSession: vi.fn(), createVisualNovelSession: vi.fn(),
@@ -1636,6 +1626,7 @@ function buildMockJumpHopWork(
templateId: 'jump-hop', templateId: 'jump-hop',
templateName: '跳一跳', templateName: '跳一跳',
profileId, profileId,
themeText: '云朵跳台',
workTitle: '云端跳台', workTitle: '云端跳台',
workDescription: '一路跳到星星。', workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'], themeTags: ['云朵', '星空'],
@@ -1649,6 +1640,7 @@ function buildMockJumpHopWork(
tileAssets, tileAssets,
path, path,
coverComposite: 'data:image/png;base64,cover', coverComposite: 'data:image/png;base64,cover',
backButtonAsset: null,
generationStatus: 'ready' as const, generationStatus: 'ready' as const,
}; };
@@ -1659,6 +1651,7 @@ function buildMockJumpHopWork(
profileId, profileId,
ownerUserId: 'user-1', ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-1', sourceSessionId: 'jump-hop-session-1',
themeText: draft.themeText,
workTitle: draft.workTitle, workTitle: draft.workTitle,
workDescription: draft.workDescription, workDescription: draft.workDescription,
themeTags: draft.themeTags, themeTags: draft.themeTags,
@@ -1678,6 +1671,7 @@ function buildMockJumpHopWork(
characterAsset, characterAsset,
tileAtlasAsset, tileAtlasAsset,
tileAssets, tileAssets,
backButtonAsset: null,
}; };
} }
@@ -5354,7 +5348,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(
@@ -5447,7 +5441,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(
@@ -6471,10 +6465,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('');
@@ -6590,6 +6584,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 = {
@@ -8050,6 +8251,7 @@ test('direct jump hop result route restores work detail by profile id', async ()
profileId: 'jump-hop-profile-restore-1', profileId: 'jump-hop-profile-restore-1',
ownerUserId: 'user-1', ownerUserId: 'user-1',
sourceSessionId: null, sourceSessionId: null,
themeText: '恢复后的云端跳台',
workTitle: '恢复后的云端跳台', workTitle: '恢复后的云端跳台',
workDescription: '从 profileId 回读完整跳一跳结果。', workDescription: '从 profileId 回读完整跳一跳结果。',
themeTags: ['云朵'], themeTags: ['云朵'],
@@ -9222,7 +9424,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(
@@ -9294,7 +9496,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(

View File

@@ -111,53 +111,11 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
resultStage: 'jump-hop-result', resultStage: 'jump-hop-result',
fields: [ fields: [
{ {
id: 'workTitle', id: 'themeText',
kind: 'text', kind: 'text',
label: '作品标题', label: '题',
required: true, required: true,
}, },
{
id: 'workDescription',
kind: 'text',
label: '作品简介',
required: true,
},
{
id: 'themeTags',
kind: 'text',
label: '主题标签',
required: true,
},
{
id: 'difficulty',
kind: 'select',
label: '难度',
required: true,
},
{
id: 'stylePreset',
kind: 'select',
label: '风格',
required: true,
},
{
id: 'characterPrompt',
kind: 'text',
label: '角色提示词',
required: true,
},
{
id: 'tilePrompt',
kind: 'text',
label: '地块提示词',
required: true,
},
{
id: 'endMoodPrompt',
kind: 'text',
label: '终点氛围',
required: false,
},
], ],
}, },
'wooden-fish': { 'wooden-fish': {

View File

@@ -33,7 +33,7 @@ function createSessionResponse(): JumpHopSessionResponse {
}; };
} }
test('jump hop workspace submits structured payload after required fields are filled', async () => { test('jump hop workspace submits theme payload after required field is filled', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSubmitted = vi.fn(); const onSubmitted = vi.fn();
const sessionResponse = createSessionResponse(); const sessionResponse = createSessionResponse();
@@ -46,14 +46,11 @@ test('jump hop workspace submits structured payload after required fields are fi
const submitButton = screen.getByRole('button', { name: '生成' }); const submitButton = screen.getByRole('button', { name: '生成' });
expect(submitButton).toHaveProperty('disabled', true); expect(submitButton).toHaveProperty('disabled', true);
await user.type(screen.getByLabelText('作品标题'), '云朵跳台'); expect(screen.getByLabelText('题')).toBeTruthy();
await user.type(screen.getByLabelText('作品简介'), '在云端一路跳到星星。'); expect(screen.queryByLabelText('作品标题')).toBeNull();
await user.type(screen.getByLabelText('主题标签'), '云朵 星星'); expect(screen.queryByLabelText('角色提示词')).toBeNull();
await user.selectOptions(screen.getByLabelText('难度'), 'standard');
await user.selectOptions(screen.getByLabelText('风格'), 'paper-toy'); await user.type(screen.getByLabelText('主题'), '云朵跳台');
await user.type(screen.getByLabelText('角色提示词'), '一只纸片小兔');
await user.type(screen.getByLabelText('地块提示词'), '柔软云朵平台');
await user.type(screen.getByLabelText('终点氛围'), '星光门');
expect(submitButton).toHaveProperty('disabled', false); expect(submitButton).toHaveProperty('disabled', false);
await user.click(submitButton); await user.click(submitButton);
@@ -61,21 +58,22 @@ test('jump hop workspace submits structured payload after required fields are fi
await waitFor(() => { await waitFor(() => {
expect(mockCreateSession).toHaveBeenCalledWith({ expect(mockCreateSession).toHaveBeenCalledWith({
templateId: 'jump-hop', templateId: 'jump-hop',
workTitle: '云朵跳台', themeText: '云朵跳台',
workDescription: '在云端一路跳到星星。', workTitle: '云朵跳台跳一跳',
themeTags: ['云朵', '星星'], workDescription: '云朵跳台主题的俯视角跳跃作品',
themeTags: ['云朵跳台', '跳一跳', '休闲'],
difficulty: 'standard', difficulty: 'standard',
stylePreset: 'paper-toy', stylePreset: 'minimal-blocks',
characterPrompt: '一只纸片小兔', characterPrompt: '内置默认 3D 角色',
tilePrompt: '柔软云朵平台', tilePrompt: '云朵跳台主题的正面30度视角主题物体图集物体本身作为跳跃落点',
endMoodPrompt: '星光门', endMoodPrompt: null,
}); });
}); });
expect(onSubmitted).toHaveBeenCalledWith( expect(onSubmitted).toHaveBeenCalledWith(
sessionResponse, sessionResponse,
expect.objectContaining({ expect.objectContaining({
templateId: 'jump-hop', templateId: 'jump-hop',
workTitle: '云朵跳台', themeText: '云朵跳台',
}), }),
); );
}); });

View File

@@ -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';
@@ -22,27 +20,31 @@ type JumpHopCreationWorkspaceProps = {
}; };
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}主题的正面30度视角主题物体图集物体本身作为跳跃落点`,
endMoodPrompt: null,
};
}
export function JumpHopCreationWorkspace({ export function JumpHopCreationWorkspace({
isBusy = false, isBusy = false,
error = null, error = null,
@@ -56,14 +58,7 @@ export function JumpHopCreationWorkspace({
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],
); );
@@ -77,20 +72,7 @@ export function JumpHopCreationWorkspace({
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) {
@@ -124,143 +106,22 @@ export function JumpHopCreationWorkspace({
</div> </div>
) : null} ) : null}
<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 ? (

View File

@@ -39,3 +39,104 @@ test('jump hop delete work uses creation works endpoint', async () => {
'删除跳一跳作品失败', '删除跳一跳作品失败',
); );
}); });
test('jump hop creation keeps image2 generation requests alive long enough', async () => {
await import('./jumpHopClient');
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
expect.objectContaining({
createSessionTimeoutMs: 20 * 60 * 1000,
executeActionTimeoutMs: 20 * 60 * 1000,
}),
);
});
test('jump hop work detail preserves flattened back button asset', async () => {
const backButtonAsset = {
assetId: 'back-button-1',
imageSrc: '/generated-jump-hop-assets/back-button-1.png',
imageObjectKey: 'jump-hop/back-button-1.png',
assetObjectId: 'asset-object-back-button-1',
generationProvider: 'image2',
prompt: '主题返回按钮',
width: 1024,
height: 1024,
};
const characterAsset = {
assetId: 'character-1',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'character-object-1',
generationProvider: 'builtin-three',
prompt: '内置默认角色',
width: 0,
height: 0,
};
const draft = {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
themeText: '森林茶馆',
workTitle: '森林茶馆跳一跳',
workDescription: '森林茶馆主题',
themeTags: ['森林茶馆', '跳一跳'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
defaultCharacter: null,
characterPrompt: '内置默认角色',
tilePrompt: '森林茶馆主题地块',
endMoodPrompt: null,
characterAsset,
tileAtlasAsset: characterAsset,
tileAssets: [],
path: {
seed: 'profile-1',
difficulty: 'standard',
platforms: [],
scoring: {
perfectRadiusRatio: 0.24,
hitRadiusRatio: 0.52,
maxChargeMs: 1200,
minChargeMs: 80,
maxJumpDistance: 5,
},
},
coverComposite: null,
backButtonAsset: null,
generationStatus: 'ready',
};
requestJsonMock.mockResolvedValue({
item: {
runtimeKind: 'jump-hop',
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'owner-1',
sourceSessionId: 'session-1',
themeText: '森林茶馆',
workTitle: '森林茶馆跳一跳',
workDescription: '森林茶馆主题',
themeTags: ['森林茶馆', '跳一跳'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-05T00:00:00Z',
publishedAt: '2026-06-05T00:00:00Z',
publishReady: true,
generationStatus: 'ready',
draft,
path: draft.path,
defaultCharacter: null,
characterAsset,
tileAtlasAsset: characterAsset,
tileAssets: [],
backButtonAsset,
},
});
const { jumpHopClient } = await import('./jumpHopClient');
const response = await jumpHopClient.getWorkDetail('profile-1');
expect(response.item.backButtonAsset).toEqual(backButtonAsset);
});

View File

@@ -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 {
@@ -30,12 +31,23 @@ import {
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions'; const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works'; const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
const JUMP_HOP_RUNTIME_API_BASE = '/api/runtime/jump-hop'; const JUMP_HOP_RUNTIME_API_BASE = '/api/runtime/jump-hop';
// 中文注释跳一跳创作会等待背景图、25 格图集、切片和 OSS 写入,不能沿用共创会话默认 15 秒超时。
const JUMP_HOP_GENERATION_TIMEOUT_MS = 20 * 60 * 1000;
const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = { const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1, maxRetries: 1,
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 +56,7 @@ export type {
JumpHopGalleryCardResponse, JumpHopGalleryCardResponse,
JumpHopGalleryDetailResponse, JumpHopGalleryDetailResponse,
JumpHopGalleryResponse, JumpHopGalleryResponse,
JumpHopLeaderboardResponse,
JumpHopRunResponse, JumpHopRunResponse,
JumpHopRuntimeRunSnapshotResponse, JumpHopRuntimeRunSnapshotResponse,
JumpHopSessionResponse, JumpHopSessionResponse,
@@ -51,16 +64,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;
@@ -82,6 +89,8 @@ const jumpHopCreationClient = createCreationAgentClient<
streamIncomplete: '跳一跳共创消息流式结果不完整', streamIncomplete: '跳一跳共创消息流式结果不完整',
executeAction: '执行跳一跳共创操作失败', executeAction: '执行跳一跳共创操作失败',
}, },
createSessionTimeoutMs: JUMP_HOP_GENERATION_TIMEOUT_MS,
executeActionTimeoutMs: JUMP_HOP_GENERATION_TIMEOUT_MS,
}); });
type FlattenedJumpHopWorkProfileResponse = Omit< type FlattenedJumpHopWorkProfileResponse = Omit<
@@ -104,6 +113,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,9 +132,12 @@ 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,
backButtonAsset:
flattened.backButtonAsset ?? flattened.draft?.backButtonAsset ?? null,
}; };
} }
@@ -240,9 +253,10 @@ export async function deleteJumpHopWork(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`,
{ {
@@ -251,7 +265,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 }),
}, },
'启动跳一跳运行态失败', '启动跳一跳运行态失败',
{ {
@@ -262,12 +276,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()}`,
}; };
@@ -286,6 +302,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 = {},
@@ -318,6 +350,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,

View 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,
};
}

View 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];
}

View 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);
});

View 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 };
}

View File

@@ -489,7 +489,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(
@@ -499,23 +499,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: '云端糖果塔主题的正面30度视角主题物体图集物体本身作为跳跃落点',
difficulty: '标准',
rhythmPreference: '轻快',
}); });
expect(entries).toEqual([ expect(entries).toEqual([
@@ -524,15 +521,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: '云端糖果塔主题的正面30度视角主题物体图集物体本身作为跳跃落点',
}, },
]); ]);
}); });

View File

@@ -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'
@@ -394,32 +393,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>;
@@ -659,7 +652,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';
} }
@@ -1099,21 +1092,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() ||
'', '',
}, },