diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 04be2504..f1bca0e8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -40,6 +40,14 @@ - 验证方式:`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`。 +## 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 创作入口关闭不下架已发布作品 - 背景:`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`。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 -## 2026-05-26 跳一跳地块图集改为专用 2x3 六格切分 +## 2026-05-28 跳一跳重设计为 5x5 地块图集与弹弓拖拽 -- 背景:跳一跳创作在地块生图阶段误用了通用系列素材图集 helper,`item_names.len() > grid_size` 的校验会让 6 个地块类型在 `grid_size = 3` 时直接失败;即使绕过校验,通用 helper 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型。 -- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`。 -- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径。 -- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 +- 背景:旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。 +- 决策:`jump-hop` v1 创作端只保留主题输入;image2 生成一张 `5x5`、共 25 个 2D 地块图标的图集,后端按均匀网格切出 25 个 `JumpHopTileAsset`。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按住后拉蓄力、松手反向弹出,前端提交 `chargeMs + dragVectorX + dragVectorY`,后端裁决落点。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。 +- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。 +- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。 +- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-06-01 跳一跳运行态地块视觉尺寸和命中半径统一放大一倍 + +- 背景:当前跳一跳运行态里地块视觉尺寸偏小,玩家反馈“很难跳上去”,但仅放大前端展示会造成画面和后端裁决脱节。 +- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2;前端平台渲染抽成统一尺寸 helper,保证单测可以直接校验放大结果。 +- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、对应定向测试。 +- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-02 跳一跳起跳距离减半并加入飞行动画缓冲 + +- 背景:用户反馈当前跳跃到目标位置需要拖得太远,且松手后缺少角色翻腾到目标地块的过渡动画,导致跳跃手感偏硬。 +- 决策:`jump-hop` 的 `chargeToDistanceRatio` 统一从 `0.004` 提升到 `0.008`,让同等跳跃距离所需拖动距离减半;前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测落点后若新 run 尚未返回,必须停在预测落点等待,再进入约 `1440ms` 的相机层推进过渡。推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。 +- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。 +- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG + +- 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。 +- 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色沿拖拽方向明显拉长,落地后向反方向回弹两次。`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。 +- 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。 +- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 # 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 05ea74e9..8bafbf5c 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1668,14 +1668,53 @@ - 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。 - 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## 跳一跳地块图集不要套通用系列素材 n 行模型 +## 跳一跳地块图集固定走 5x5 地块池 -- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者即使绕过报错也只生成了 atlas 预览路径,地块切片没有真正落盘。 -- 原因:跳一跳地块只有 6 个固定 tileType,但旧实现把它塞进通用系列素材 helper,并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`。 -- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt,按 `start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind。 -- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`。 +- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者生成完成后只有 atlas 预览路径,地块切片没有真正落盘。 +- 原因:旧模板先后尝试过通用系列素材 helper 和 `2x3` 六格固定 tileType,但当前跳一跳已经重设计为“主题 -> 5x5 地块图集 -> 25 个等权地块池 -> 无限路径”,旧的物品行数 / 固定类型模型都会把创作链路带偏。 +- 处理:跳一跳地块固定生成一张 `5x5` 主题图集,后端按均匀网格切出 25 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind;不要再恢复 `2行*3列`、`start / normal / target / finish / bonus / accent` 六格口径。 +- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 25 个独立 `JumpHopTileAsset`,运行态无限路径从地块池随机取材。 - 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 跳一跳宝可梦主题地块图集 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///image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。 +- 处理:后端切图后必须按 atlas 单元格写入 `tile-01` 到 `tile-25` 的唯一 slot/path;前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。 +- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键。 +- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`。 + +## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影 + +- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。 +- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。 +- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。 +- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。 +- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`。 + +## 跳一跳落点辅助和后端裁决必须统一坐标换算 + +- 现象:落点辅助标识已经压在目标地块图片上,松手后后端仍判定失败,玩家看到的是“明明瞄准了却没落上去”。 +- 原因:前端辅助标识使用屏幕像素坐标绘制,而后端裁决使用世界坐标。屏幕 y 轴向下为正、世界 y 轴向上为正;同时屏幕 x/y 每个世界单位对应的像素比例不同。若前端直接把屏幕像素拖拽向量发给后端,辅助点和后端落点方向会不一致。 +- 处理:前端运行态保留原始屏幕拖拽向量用于画弹弓和辅助点,但提交后端前必须按当前地块到目标地块的屏幕跨度 / 世界跨度把 x、y 分别换算成世界尺度一致的向量;后端继续只负责反向弹射和落点裁决。 +- 验证:前端回归测试要同时覆盖辅助点完整拖拽到目标地块,以及提交给后端的向量已完成世界尺度换算;后端领域测试覆盖屏幕向后下拉时应向世界 y 正方向跳出并命中。 +- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`server-rs/crates/module-jump-hop/src/application.rs`。 + +## 跳一跳创作入口旧文案先查 SpacetimeDB 配置 + +- 现象:`JumpHopWorkspace` 已只剩主题输入,但创作 Tab 的跳一跳模板卡仍显示旧的“俯视角跳跃闯关”或拼图参考图。 +- 原因:创作入口卡片事实源是 SpacetimeDB `creation_entry_type_config` 和 `/api/creation-entry/config`,前端只做展示派生;如果只改工作台、PRD 或前端组件,已有库里的旧入口行不会自动变化。当前 `api-server` 读取入口配置时优先订阅缓存,缓存命中后不会再走 procedure 播种,所以只把迁移写在 `get_creation_entry_config` 里不够。 +- 处理:同步更新 `module-runtime` 默认入口种子,并在 `spacetime-module/src/runtime/creation_entry_config.rs` 加只命中旧系统默认值的迁移;同时在 `spacetime-client` 的入口配置读模型里做同一条旧系统默认行的读路径纠偏。跳一跳当前默认值为 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`。 +- 验证:本地 `GET /api/creation-entry/config` 的 `jump-hop` 项应返回新 subtitle 和新 imageSrc;若仍旧,检查本地 SpacetimeDB 是否已发布当前 `spacetime-module`,以及后台是否手动覆盖过入口配置。若缓存路径和 procedure 路径返回不一致,优先怀疑读模型映射没做纠偏,而不是前端展示层。 + ## image2 dry-run 带参考图时不要直接打印 data URL - 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。 @@ -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"`。 - 关联:`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 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。 @@ -1805,3 +1860,35 @@ - 处理:推荐页和平台壳层的公开作品 key 规则必须复用 `buildPlatformPublicGalleryCardKey(...)`,覆盖同一批 `sourceType`,至少包括 `big-fish`、`puzzle`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`bark-battle` 和 `edutainment:`;新增玩法公开推荐流时先补这个共享 helper。 - 验证:`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`。 + +## 跳一跳飞行动画不要直接用最新 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` 且未错误时直接保留真实 ``,只在无 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`。 diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index 33dea4b7..6bbc45af 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -2,491 +2,193 @@ ## 1. 目标 -新增一个可创作、可试玩、可发布的玩法模板: +`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `5x5` 地块资源图集,切成 25 个 2D 地块素材;运行态使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色,并和这些 2D 地块资产组成无限平台流。 -```text -跳一跳 -``` +首版目标: -本模板参考拼图模板的创作闭环,沿用“创作入口 -> 生成过程页 -> 结果页 -> 试玩 -> 发布”的平台链路,但玩法本体改为俯视角 / 等距视角的跳跃闯关。 - -首版要求: - -1. 初始草稿生成时,角色形象单独调用一次生图; -2. 初始草稿生成时,地块只调用一次生图,输出 3D 视图的 2D 图片图集; -3. 运行态不接真实 3D 网格,不生成 GLB / glTF; -4. 作品可以直接进入试玩和发布。 +1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生; +2. image2 只生成一张 `5x5` 地块图集,后端均匀切成 25 张 PNG; +3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG; +4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块; +5. 操作方式为按住屏幕向后拖动蓄力,松手后角色向拖拽反方向弹出; +6. 只要落点未命中下一个地块,本局立即失败并冻结计时; +7. 成绩记录成功跳跃次数和游戏时长; +8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。 ## 2. 模板定位 -模板 ID: +- 模板 ID:`jump-hop` +- 展示名:`跳一跳` +- 工程域:`jump-hop` +- 创作入口卡:`subtitle = 主题驱动平台跳跃`,`imageSrc = /creation-type-references/jump-hop.webp` +- 运行态:`DOM 平台 / DOM 角色 + Three.js 透明扩展层 + DOM HUD` +- 画面比例:移动端竖屏优先,桌面端居中承载 `9:16` +- 素材策略:2D 地块图集 + 陶泥儿 logo 透明角色 +- 渲染分层:生成地块切片必须由 DOM 平台层直接渲染为图片;角色必须由 DOM 透明 PNG 层渲染并保持最高层级,Three.js 透明画布只作为后续扩展层,不能把地块图片或角色回退为 WebGL 占位材质 + +本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。 + +## 3. 创作工具平台接入声明 + +- 工作台模式:表单输入创作工作台 +- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 +- 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色 +- 系列素材槽位: + - `batchId = jump-hop-tile-atlas` + - `sheetSpec = 5x5 / 1:1 / PNG / 纯绿色绿幕背景 / 后端切图透明化` + - `slotSpecs = tile-01 ... tile-25`,每个 slot 必须对应唯一 OSS path / `assetObjectId` + - 切图规则:按原图宽高均分为 5 行 5 列,从上到下、从左到右切出 25 张 PNG;每格透明化后只保留最大的 alpha 连通主体,再裁边并补透明安全边,避免相邻格越界碎片或方形杂边进入 tile + - 透明化规则:生成时要求绿幕背景,后端上传 OSS 前抠成透明 PNG,并清理与主体分离的小型残片 + - 失败回写:生成失败时 session 保持 failed,可从生成页重试 + - 局部重生成:结果页允许重生成地块图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存 +- API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*` +- 业务真相:后端裁决落点、失败、成功跳跃次数、冻结时长和排行榜 +- 创作工具模式例外:无 +- 验证命令:`npm run check:encoding`、`npm run typecheck`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml` + +## 4. 创作输入 + +主题是唯一必填项。工作台不展示角色提示词、地块提示词、风格卡、难度卡、终点氛围或规则说明。 + +提交后系统自动派生: + +1. 作品标题:主题为空白修剪后的短标题,默认前缀不外露; +2. 作品简介:基于主题生成一句短简介; +3. 标签:`跳一跳`、`休闲` 和主题关键词; +4. 地块提示词:围绕主题生成 25 个风格一致的俯视角清爽游戏化 2D 平台素材,每一块都是符合主题的单独可跳跃平台;实际 image2 prompt 使用“独立可落脚平台素材 / 平台裸素材 / 完整平台”措辞,不再把正向主体描述成图标集或游戏界面资源; +5. 初始平台流参数:固定 v1 标准参数,不让创作者手工调规则。 + +## 5. 地块图集 + +image2 只生成一张 `1:1` 图片,画面为 `5x5` 均匀分布平台裸素材;实际提示词必须先约束“画面只包含 25 个独立跳一跳可落脚平台素材”,并明确不是游戏界面、棋盘、背包、装备栏或图标集页面。 + +图集要求: + +1. 每格只放一个完整地块资源; +2. 资源为纯 2D 平面素材,但要表现为符合主题且有设计感的俯视角清爽游戏化立体感平台,有顶面、主体内部明暗和清晰轮廓;主题元素必须直接成为平台主体,例如“水果”应生成苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台; +3. 25 个地块来自同一主题、同一光向和同一材质体系; +4. 背景为纯绿色绿幕,方便后端透明化; +5. 不包含角色、文字、水印、UI、游戏面板、棋盘、背包、装备栏、按钮、标题、外层边框、网格线、场景背景、落地投影、接触阴影、方形阴影、方形底板、白底、灰底或黑底; +6. 地块不能跨格、贴边或进入相邻格,主体必须居中并保留至少 18% 纯绿色安全留白;每个平台之间只能是纯绿色空白,不画容器框或棋盘格。 + +切片顺序固定为: ```text -jump-hop +tile-01 tile-02 tile-03 tile-04 tile-05 +tile-06 tile-07 tile-08 tile-09 tile-10 +tile-11 tile-12 tile-13 tile-14 tile-15 +tile-16 tile-17 tile-18 tile-19 tile-20 +tile-21 tile-22 tile-23 tile-24 tile-25 ``` -用户展示名: +运行态随机使用这 25 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。 + +## 6. 运行态规则 + +### 6.1 平台流 + +运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 3 个地块可见: + +1. 当前地块; +2. 目标地块; +3. 下一预览地块。 + +服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。前端只展示服务端快照,不自行生成正式路径。 + +### 6.2 操作 + +1. 用户按住当前地块或画面; +2. 向后拖动形成蓄力向量; +3. 松手后角色沿拖拽反方向弹出; +4. 拖拽距离决定力度,拖拽方向决定落点方向; +5. 力度和方向都由前端提交给后端裁决。 + +手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.008`。该值表示同等世界跳跃距离只需要旧版 `0.004` 配置的一半屏幕拖动距离;旧作品运行时若仍携带 `0.004`,开局归一化为 `0.008`。 + +松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画;角色从当前地块弹向预测落点,蓄力阶段角色应沿拖拽方向明显拉长,落地后再向反方向回弹两次。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端返回的最新 run,并进入约 `1440ms` 的相机推进过渡。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块随相机推进自然离开视野,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。 + +### 6.3 判定 + +1. 目标永远是当前地块后的下一个地块; +2. 落点进入下一个地块落地半径,则成功; +3. 落点未进入下一个地块落地半径,则失败; +4. 失败后状态改为 `failed`,计时冻结; +5. v1 没有通关状态、combo、perfect 或生命数。 + +### 6.4 计分与时间 + +- 成功跳跃次数:每成功落到下一个地块后 `+1`; +- 游戏时长:`startedAtMs` 到 `finishedAtMs`,失败时冻结; +- 运行中时长由前端根据服务端 `startedAtMs` 展示; +- 失败后只展示冻结时长。 + +## 7. 排行榜 + +排行榜按作品维度生成。每位玩家只保留 1 条最佳记录。 + +排序规则固定为: ```text -跳一跳 +successfulJumpCount desc -> durationMs asc -> updatedAt asc ``` -体验关键词: - -1. 俯视角; -2. 等距感地块; -3. 单局闯关; -4. 长按蓄力,松手起跳; -5. 轻量休闲。 - -首版采用竖屏优先的移动端体验,桌面端保持居中展示,画面比例以 `9:16` 为主。参考图的核心视觉要点是: - -1. 大面积留白或浅色渐变背景; -2. 角色站在单个地块上; -3. 地块有明显顶面、侧面和投影; -4. 整体是俯视角 / 等距视角,而不是横版平台跳跃; -5. UI 克制,只保留必要控制,不堆说明文案。 - -## 3. 与拼图模板的复用边界 - -可以复用: - -1. 创作入口和模板分流; -2. 生成过程页; -3. 结果页的草稿保存、返回编辑、试玩、发布、分享链路; -4. 作品架展示和草稿恢复口径; -5. 平台统一的发布与公开展示流程。 - -不复用: - -1. 拼图关卡切片逻辑; -2. 拼图拖拽拼块逻辑; -3. 拼图 UI 背景和多关卡编辑结构; -4. 任何方格拼合语义。 - -## 4. 工程接入范围 - -首版需要做到完整玩法闭环,不只做入口占位。 - -新增前端阶段: - -```text -jump-hop-workspace -jump-hop-generating -jump-hop-result -jump-hop-runtime -jump-hop-gallery-detail -``` - -新增前端组件建议: - -1. `src/components/unified-creation/workspaces/JumpHopCreationWorkspace.tsx`; -2. `src/components/jump-hop-result/JumpHopResultView.tsx`; -3. `src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`; -4. `src/services/jump-hop/jumpHopClient.ts`。 - -新增共享契约建议: - -1. `packages/shared/src/contracts/jumpHop.ts`; -2. `server-rs/crates/shared-contracts/src/jump_hop.rs`。 - -新增后端模块建议: - -1. `server-rs/crates/module-jump-hop`:纯领域规则,包含路径生成、蓄力换算、落点判定、通关 / 失败状态机; -2. `server-rs/crates/api-server/src/jump_hop.rs` 和 `src/jump_hop/` 子模块:HTTP handler、生成编排、资产保存和 DTO 映射; -3. `server-rs/crates/spacetime-module/src/jump_hop.rs`:session、work profile、runtime run、公开 view 和 reducer / procedure; -4. `server-rs/crates/spacetime-client/src/jump_hop.rs`:api-server 访问 SpacetimeDB 的 facade; -5. `server-rs/crates/api-server/src/modules/jump_hop.rs`:路由挂载。 - -入口配置事实源必须走 SpacetimeDB `creation_entry_type_config` 默认种子和后台配置接口,不新增前端硬编码入口配置。 - -## 5. 创作输入 - -创作者需要填写以下内容: - -1. 作品主题描述,必填; -2. 角色形象描述,必填; -3. 地块风格卡,必选; -4. 难度,必选; -5. 可选的终点氛围或节奏偏好。 - -推荐的最小输入形态是: - -1. 一句话主题; -2. 角色一句话描述; -3. 风格卡; -4. 难度卡。 - -不在首版开放手工拖拽平台编辑器。平台路径、地块间距和终点位置由系统自动生成,创作者只负责风格与难度选择。 - -### 5.1 地块风格卡 - -建议提供以下风格: - -1. 极简积木; -2. 纸模玩具; -3. 霓虹玻璃; -4. 森林石块; -5. 未来金属; -6. 自定义。 - -### 5.2 难度 - -建议提供以下离散档位: - -1. 轻松; -2. 标准; -3. 进阶; -4. 挑战。 - -难度主要影响: - -1. 平台路径长度; -2. 平台间距; -3. 可落点容差; -4. 完美落点窗口; -5. 终点前的节奏变化。 - -## 6. 生成规则 - -本模板必须把生图责任拆成两条独立链路: - -### 6.1 角色形象只生一次 - -角色形象必须只调用一次生图,输出一张可直接进入运行态的主角色图。 - -角色图要求: - -1. 单人主角; -2. 全身可见; -3. 透明背景; -4. 角色站姿或轻微前倾姿态; -5. 镜头和透视必须匹配俯视角场景; -6. 不要求多视角,不要求多帧动画图集。 - -角色图生成后作为作品级锚点资产使用,结果页、封面合成、试玩和发布都复用同一张图。后续如果只修改标题、标签、难度或路径,不应默认重新生角色。只有用户在结果页明确点击“重生成角色”时,才允许再调用一次角色生图。 - -### 6.2 地块只生一次图集 - -地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。 - -地块图集要求: - -1. 统一使用等距 / 俯视角; -2. 必须表现出顶面、侧面和投影; -3. 必须与角色图保持同一光向; -4. 必须有清晰的立体层次,但仍然是 2D 图片; -5. 六格必须按固定顺序包含以下地块类型: - - 起点地块; - - 普通地块; - - 目标地块; - - 终点地块; - - 奖励地块; - - 视觉强调地块。 - -固定格位为: - -| 格位 | tileType | 语义 | -| --- | --- | --- | -| 第 1 行第 1 列 | `start` | 起点地块 | -| 第 1 行第 2 列 | `normal` | 普通地块 | -| 第 1 行第 3 列 | `target` | 目标地块 | -| 第 2 行第 1 列 | `finish` | 终点地块 | -| 第 2 行第 2 列 | `bonus` | 奖励地块 | -| 第 2 行第 3 列 | `accent` | 视觉强调地块 | - -图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。 - -### 6.3 不新增第三次生成 - -首版不把封面、分享海报、路径预览再拆成第三次图像生成。封面和分享图必须由角色图 + 地块图集在本地或后端轻量合成,不额外增加新的角色生图次数。 - -### 6.4 路径元数据 - -除图片资产外,系统还必须生成跳跃路径元数据: - -1. 平台序列; -2. 平台中心点; -3. 平台宽度; -4. 平台间距; -5. 终点索引; -6. 评分和容差参数。 - -路径由领域规则自动生成,创作者不直接编辑坐标。路径元数据不依赖 LLM 或图片生成。 - -### 6.5 推荐的难度区间 - -| 难度 | 平台数量 | 平台间距 | 节奏 | -| --- | ---: | --- | --- | -| 轻松 | 12 - 14 | 短 | 宽容 | -| 标准 | 16 - 18 | 中 | 稳定 | -| 进阶 | 20 - 24 | 中长 | 紧凑 | -| 挑战 | 26 - 32 | 长 | 高压 | - -平台宽度和容差由系统按难度自动缩放,不要求创作者手工填写。 - -## 7. 契约草案 - -### 7.1 草稿结构 - -`JumpHopDraft` 至少包含: - -1. `templateId = "jump-hop"`; -2. `templateName = "跳一跳"`; -3. `profileId`; -4. `workTitle`; -5. `workDescription`; -6. `themeTags`; -7. `difficulty`; -8. `stylePreset`; -9. `characterPrompt`; -10. `tilePrompt`; -11. `characterAsset`; -12. `tileAtlasAsset`; -13. `tileAssets[]`; -14. `path`; -15. `coverComposite`; -16. `generationStatus`。 - -### 7.2 资产结构 - -`JumpHopCharacterAsset` 至少包含: - -1. `assetId`; -2. `imageSrc`; -3. `imageObjectKey`; -4. `assetObjectId`; -5. `generationProvider`; -6. `prompt`; -7. `width`; -8. `height`。 - -`JumpHopTileAsset` 至少包含: - -1. `tileType`; -2. `imageSrc`; -3. `imageObjectKey`; -4. `assetObjectId`; -5. `sourceAtlasCell`; -6. `visualWidth`; -7. `visualHeight`; -8. `topSurfaceRadius`; -9. `landingRadius`。 - -`tileType` 首版限定: - -```text -start | normal | target | finish | bonus | accent -``` - -### 7.3 路径结构 - -`JumpHopPath` 至少包含: - -1. `seed`; -2. `difficulty`; -3. `platforms[]`; -4. `finishIndex`; -5. `cameraPreset`; -6. `scoring`。 - -`JumpHopPlatform` 至少包含: - -1. `platformId`; -2. `tileType`; -3. `x`; -4. `y`; -5. `width`; -6. `height`; -7. `landingRadius`; -8. `perfectRadius`; -9. `scoreValue`。 - -### 7.4 运行态快照 - -`JumpHopRunSnapshot` 至少包含: - -1. `runId`; -2. `profileId`; -3. `status = playing | failed | cleared`; -4. `currentPlatformIndex`; -5. `score`; -6. `combo`; -7. `lastJump`; -8. `startedAtMs`; -9. `finishedAtMs`。 - -`lastJump` 至少包含: - -1. `chargeMs`; -2. `jumpDistance`; -3. `targetPlatformIndex`; -4. `landedX`; -5. `landedY`; -6. `result = miss | hit | perfect | finish`。 - -## 8. API 草案 - -HTTP 路由建议: - -```text -POST /api/creation/jump-hop/sessions -GET /api/creation/jump-hop/sessions/{sessionId} -POST /api/creation/jump-hop/sessions/{sessionId}/actions -POST /api/creation/jump-hop/works/{profileId}/publish -GET /api/runtime/jump-hop/works/{profileId} -POST /api/runtime/jump-hop/runs -POST /api/runtime/jump-hop/runs/{runId}/jump -POST /api/runtime/jump-hop/runs/{runId}/restart -GET /api/runtime/jump-hop/gallery -GET /api/runtime/jump-hop/gallery/{publicWorkCode} -``` - -动作类型建议: - -```text -compile-draft -regenerate-character -regenerate-tiles -update-work-meta -update-difficulty -``` - -`compile-draft` 是长耗时动作。前端进入生成页后必须持久化 `generationStatus=generating`,刷新后能从作品架恢复生成页。失败前需要复读 session;如果后端已经完成草稿并写回资产,前端按成功收尾。 - -## 9. SpacetimeDB 表和 view - -建议新增表: - -1. `jump_hop_agent_session`; -2. `jump_hop_work_profile`; -3. `jump_hop_runtime_run`; -4. `jump_hop_event`; -5. `jump_hop_leaderboard_entry`,首版可暂不对外展示; -6. `jump_hop_gallery_view`; -7. `jump_hop_gallery_card_view`。 - -表结构新增字段必须按 SpacetimeDB 迁移规则放在结构体末尾并设置明确默认值。新增或调整表、reducer、procedure、view 后必须同步 `migration.rs`、表目录、生成 bindings,并执行 `npm run check:spacetime-schema`。 - -公开列表主路径应优先订阅 `jump_hop_gallery_card_view` 后在 `api-server` 本地 cache 构造列表响应,不要让每个 HTTP 请求都调用 SpacetimeDB procedure 组装全量列表。 - -## 10. 结果页能力 - -结果页必须展示: - -1. 作品标题; -2. 作品简介; -3. 角色形象; -4. 地块图集; -5. 路径预览; -6. 标签; -7. 试玩; -8. 发布; -9. 返回编辑。 - -结果页还必须支持: - -1. 单独重生成角色; -2. 单独重生成地块图集; -3. 单独修改标题和简介; -4. 单独调整标签和难度。 - -结果页不应强制再走一次封面生图。封面只做合成,不新增图像生成调用。 - -## 11. 运行态规则 - -运行态采用 2D 表现,但画面视觉上必须保留参考图那种俯视角 / 等距感。 - -### 11.1 核心玩法 - -1. 玩家长按蓄力; -2. 松手后角色按蓄力长度起跳; -3. 跳跃距离决定是否落到下一个地块; -4. 落在目标区域内判定成功; -5. 落在地块外或越界判定失败; -6. 到达终点地块判定通关。 - -### 11.2 判定规则 - -1. 只做一个当前局面的起跳判定; -2. 不做复杂连招动作树; -3. 不新增生命数、体力、回合数; -4. 不新增计时赛作为首版核心规则; -5. 不把前端动画结果当成最终真相,通关与失败必须能回写运行态状态。 - -### 11.3 角色动画 - -角色不需要多帧生图,运行态只通过位移、缩放、轻微旋转和投影变化表达: - -1. 蓄力时轻微压缩; -2. 起跳时向上抬升; -3. 空中保持可读轮廓; -4. 落地时轻微弹性回弹; -5. 失败时从地块边缘跌落。 - -### 11.4 摄像机与构图 - -1. 相机以当前角色和下一地块为中心; -2. 至少保证下一个落点一直可见; -3. 画面要留出顶部和底部的 UI 安全区; -4. 不要把地块做得太满,保留参考图那种疏朗感。 - -### 11.5 UI - -运行态 UI 只保留必要元素: - -1. 分数; -2. 暂停; -3. 重新开始; -4. 分享; -5. 结算按钮。 - -不默认展示大段规则说明。首进如果需要引导,只能用一次轻量提示,不允许常驻一屏的说明文案。 - -## 12. 视觉规范 - -本模板的视觉目标是“像 3D,但仍是 2D 图片”。 - -必须遵守: - -1. 平台有明确厚度; -2. 侧面可见分层或材质变化; -3. 投影统一且方向一致; -4. 背景干净,颜色克制; -5. 角色尺寸在小屏上依然可读; -6. 地块不能出现过多文字、按钮或装饰信息; -7. 不能把运行态做成重 UI 面板。 - -建议的背景策略: - -1. 以静态浅色渐变或纯色背景为主; -2. 不把背景也做成每次都生成的重资产; -3. 让地块和角色成为画面的第一视觉焦点。 - -## 13. 发布后体验 - -发布后的作品必须支持: - -1. 进入作品架和公开展示; -2. 分享; -3. 试玩; -4. 重新进入结果页编辑。 - -发布后的卡片封面应优先由角色图和地块图合成,不要求单独再生成封面图。 - -首版不新增排行榜、回放和对局对抗。后续如要扩展排行,可另起版本,不要塞进首版模板范围。 - -## 14. 验收 - -1. 创作入口能看到 `跳一跳` 模板; -2. 创作者可以填写主题、角色描述、风格和难度; -3. 提交后只生成一次角色图和一次地块图集; -4. 结果页能看到角色图、地块图集和路径预览; -5. 结果页可单独重生成角色或地块; -6. 试玩进入跳一跳运行态; -7. 长按蓄力、松手起跳、落点判定、失败和通关都可用; -8. 作品可以保存、发布和分享; -9. 前端不直接读取或暴露生图密钥; -10. 发布后的封面不依赖第三次额外生图。 -11. `npm run check:spacetime-schema` 在 schema 变更后通过; -12. `npm run check:encoding` 通过。 +展示字段: + +1. rank; +2. playerId; +3. successfulJumpCount; +4. durationMs; +5. updatedAt。 + +草稿试玩可以展示本地结果,但正式排行榜只消费后端 run 记录。匿名 runtime guest 也按 guest subject 作为 playerId 参与当次作品维度排行。 + +## 8. 结果页 + +结果页展示: + +1. 陶泥儿 logo 透明角色预览; +2. 25 个地块资源池预览; +3. 首屏 3 块平台预览; +4. 试玩; +5. 发布; +6. 返回编辑; +7. 重生成地块。 + +结果页不再展示角色图片生成槽位,也不提供独立角色重生成。 + +## 9. 契约要点 + +公开语义保留: + +1. `themeText`; +2. `tileAtlasAsset`; +3. `tileAssets[]`; +4. `defaultCharacter`; +5. `path.platforms[]` 作为服务端路径缓冲; +6. `currentPlatformIndex`; +7. `successfulJumpCount`; +8. `startedAtMs` / `finishedAtMs` / `durationMs`; +9. `leaderboard`。 + +旧语义处理: + +1. `characterAsset` 仅作为角色描述兼容字段,不再表示生成图片;前端固定使用陶泥儿 logo 透明 PNG; +2. `score` 兼容映射为成功跳跃次数; +3. `combo` 固定为 0,不作为公开玩法语义; +4. `cleared` 状态不再由 v1 产生; +5. 旧 finite path 只作为服务端路径缓冲兼容形态。 + +## 10. 验收 + +1. 创作页只显示主题输入; +2. 生成链路只调用一次地块图集 image2,不再调用角色生图; +3. 地块图集为 `5x5`,后端切出 25 个地块 PNG; +4. 结果页不依赖旧角色图片槽; +5. 运行态为竖屏俯视角,首屏保持 3 个地块可见; +6. 拖拽方向和力度会影响落点; +7. 未落到下一个地块立即失败; +8. 成功跳跃次数累加,失败后计时冻结; +9. 排行榜按成功跳跃次数优先排序; +10. 作品可保存、发布、分享并从公开入口启动。 +11. 运行态地块必须显示 `tileAssets[]` 中的生成切片图片;拖拽蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台图片层或 DOM 角色层。 +12. 同等跳跃距离的拖动距离必须比旧 `0.004` 系数缩短一半,松手后必须先看到角色飞行动画,再看到地块窗口前移。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index e8bf28a5..8fc73ed9 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -407,15 +407,23 @@ npm run check:server-rs-ddd - Rust 结构体:`JumpHopEventRow` - 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` +### `jump_hop_leaderboard_entry` + +- Rust 结构体:`JumpHopLeaderboardEntryRow` +- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` +- 说明:跳一跳作品维度排行榜 read model,每个 `profile_id + player_id` 只保留 1 条最佳记录;排序口径为成功跳跃次数降序、游戏时长升序、更新时间升序,草稿试玩不作为公开排行榜语义。 + ### `jump_hop_runtime_run` - Rust 结构体:`JumpHopRuntimeRunRow` - 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` +- 说明:运行记录持久化 `runtime_mode`,取值为 `draft` / `published`;草稿试玩只允许作品所有者启动,不累计公开游玩次数,也不写入公开排行榜。 ### `jump_hop_work_profile` - Rust 结构体:`JumpHopWorkProfileRow` - 源码:`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` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 07667274..edabc223 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -44,7 +44,7 @@ 单图资产编辑统一通过 `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` 和汪汪声浪生成页都必须遵循这一口径。 @@ -136,23 +136,33 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 -首版定位为俯视角 / 等距视角 2D 休闲跳跃模板,链路对齐拼图的创作闭环: +当前定位为竖屏俯视角 2D 平台跳跃模板,链路对齐平台创作闭环: ```text -创作入口 -> 模板输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态 +创作入口 -> 主题输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态 ``` +创作入口配置事实源仍是 SpacetimeDB `creation_entry_type_config`:默认 `visible=true`、`open=true`、`badge=可创建`、`subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`。旧库中仍停留在 `subtitle=俯视角跳跃闯关` 且 `image_src=/creation-type-references/puzzle.webp` 的系统默认行会在入口配置播种流程中自动迁移;同时 `spacetime-client` 的入口配置读模型也会对同一条旧系统默认行做纠偏,避免订阅缓存长期回放老口径。后台手动改过的跳一跳入口配置不被覆盖。 + 素材生成规则固定为: -1. 初始草稿生成时,角色形象单独调用一次生图; -2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集; -3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG; -4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图; -5. 显式重生成角色或地块时,只重生成对应资产槽位。 +1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生; +2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色; +3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key,不启用近白底扣除,也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制;地块造型提示词要求以主题物体本身外轮廓为准,允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异,只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装;所有地块素材必须保持统一正面30度视角,相机位于物体正前方略高位置、镜头向下约30度,必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面,主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶;避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标;主题物体本身必须是唯一可落脚体,只能用自身切面、边缘厚度、花瓣层或果皮边表现承重,禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板;前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集,物体本身作为跳跃落点”的口径,不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改; +4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段,OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感,两侧允许更强立体层次和行进感;背景只作为底图,禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版;左上角返回按钮不允许画进背景,而是单独生成 `backButtonAsset` 透明 PNG,OSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile; +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。 diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 19fafe66..a5b6d9e9 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -24,7 +24,6 @@ export type JumpHopTileType = export type JumpHopActionType = | 'compile-draft' - | 'regenerate-character' | 'regenerate-tiles' | 'update-work-meta' | 'update-difficulty'; @@ -35,19 +34,21 @@ export type JumpHopJumpResult = 'miss' | 'hit' | 'perfect' | 'finish'; export interface JumpHopWorkspaceCreateRequest { templateId: string; - workTitle: string; - workDescription: string; - themeTags: string[]; - difficulty: JumpHopDifficulty; - stylePreset: JumpHopStylePreset; - characterPrompt: string; - tilePrompt: string; + themeText: string; + workTitle?: string; + workDescription?: string; + themeTags?: string[]; + difficulty?: JumpHopDifficulty; + stylePreset?: JumpHopStylePreset; + characterPrompt?: string; + tilePrompt?: string; endMoodPrompt?: string | null; } export interface JumpHopActionRequest { actionType: JumpHopActionType; profileId?: string | null; + themeText?: string | null; workTitle?: string | null; workDescription?: string | null; themeTags?: string[] | null; @@ -60,6 +61,7 @@ export interface JumpHopActionRequest { tileAtlasAsset?: JumpHopCharacterAsset | null; tileAssets?: JumpHopTileAsset[] | null; coverComposite?: string | null; + backButtonAsset?: JumpHopCharacterAsset | null; } export interface JumpHopCharacterAsset { @@ -73,12 +75,23 @@ export interface JumpHopCharacterAsset { height: number; } +export interface JumpHopDefaultCharacter { + characterId: string; + displayName: string; + modelKind: 'builtin-three'; + bodyColor: string; + accentColor: string; +} + export interface JumpHopTileAsset { tileType: JumpHopTileType; + tileId?: string; imageSrc: string; imageObjectKey: string; assetObjectId: string; sourceAtlasCell: string; + atlasRow?: number; + atlasCol?: number; visualWidth: number; visualHeight: number; topSurfaceRadius: number; @@ -126,11 +139,13 @@ export interface JumpHopDraftResponse { templateId: string; templateName: string; profileId: string | null; + themeText: string; workTitle: string; workDescription: string; themeTags: string[]; difficulty: JumpHopDifficulty; stylePreset: JumpHopStylePreset; + defaultCharacter?: JumpHopDefaultCharacter | null; characterPrompt: string; tilePrompt: string; endMoodPrompt: string | null; @@ -139,6 +154,7 @@ export interface JumpHopDraftResponse { tileAssets: JumpHopTileAsset[]; path: JumpHopPath | null; coverComposite: string | null; + backButtonAsset?: JumpHopCharacterAsset | null; generationStatus: JumpHopGenerationStatus; } @@ -167,6 +183,7 @@ export interface JumpHopWorkSummaryResponse { profileId: string; ownerUserId: string; sourceSessionId: string | null; + themeText: string; workTitle: string; workDescription: string; themeTags: string[]; @@ -185,9 +202,11 @@ export interface JumpHopWorkProfileResponse { summary: JumpHopWorkSummaryResponse; draft: JumpHopDraftResponse; path: JumpHopPath; + defaultCharacter?: JumpHopDefaultCharacter | null; characterAsset: JumpHopCharacterAsset; tileAtlasAsset: JumpHopCharacterAsset; tileAssets: JumpHopTileAsset[]; + backButtonAsset?: JumpHopCharacterAsset | null; } export interface JumpHopWorksResponse { @@ -208,6 +227,7 @@ export interface JumpHopGalleryCardResponse { profileId: string; ownerUserId: string; authorDisplayName: string; + themeText: string; workTitle: string; workDescription: string; coverImageSrc: string | null; @@ -237,6 +257,8 @@ export interface JumpHopRuntimeRunSnapshotResponse { ownerUserId: string; status: JumpHopRunStatus; currentPlatformIndex: number; + successfulJumpCount: number; + durationMs: number; score: number; combo: number; path: JumpHopPath; @@ -251,10 +273,13 @@ export interface JumpHopRunResponse { export interface JumpHopStartRunRequest { profileId: string; + runtimeMode?: 'draft' | 'published'; } export interface JumpHopJumpRequest { - chargeMs: number; + dragDistance: number; + dragVectorX?: number; + dragVectorY?: number; clientEventId: string; } @@ -265,3 +290,17 @@ export interface JumpHopRestartRunRequest { export interface JumpHopJumpResponse { run: JumpHopRuntimeRunSnapshotResponse; } + +export interface JumpHopLeaderboardEntry { + rank: number; + playerId: string; + successfulJumpCount: number; + durationMs: number; + updatedAt: string; +} + +export interface JumpHopLeaderboardResponse { + profileId: string; + items: JumpHopLeaderboardEntry[]; + viewerBest?: JumpHopLeaderboardEntry | null; +} diff --git a/public/branding/jump-hop-taonier-character.png b/public/branding/jump-hop-taonier-character.png new file mode 100644 index 00000000..0dcbaf41 Binary files /dev/null and b/public/branding/jump-hop-taonier-character.png differ diff --git a/public/creation-type-references/jump-hop.webp b/public/creation-type-references/jump-hop.webp new file mode 100644 index 00000000..b4e6c7b2 Binary files /dev/null and b/public/creation-type-references/jump-hop.webp differ diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 70b4d70d..5089422a 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -270,6 +270,29 @@ mod tests { ); } + #[test] + fn test_creation_entry_config_response_updates_jump_hop_metadata() { + let config = test_creation_entry_config_response(); + let jump_hop = config + .creation_types + .iter() + .find(|item| item.id == "jump-hop") + .expect("test creation entry config should include jump-hop"); + + assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}"); + assert!(jump_hop.visible); + assert!(jump_hop.open); + assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}"); + assert_eq!( + jump_hop.subtitle, + "\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}" + ); + assert_eq!( + jump_hop.image_src, + "/creation-type-references/jump-hop.webp" + ); + } + #[test] fn test_creation_entry_config_response_keeps_baby_object_match_visible() { let config = test_creation_entry_config_response(); diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index 7fafb80b..b5df860e 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -1,4 +1,4 @@ -use axum::http::StatusCode; +use axum::http::StatusCode; use platform_image::generated_asset_sheets as generated_asset_sheets_impl; use crate::{ @@ -8,9 +8,12 @@ use crate::{ #[allow(unused_imports)] pub(crate) use generated_asset_sheets_impl::{ - GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, + GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor, + GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, 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( diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index c28015a8..5d905083 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -13,15 +13,15 @@ use serde_json::{Value, json}; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, - JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse, - JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType, - JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse, - JumpHopWorkspaceCreateRequest, + JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse, + JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, + JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use std::{ - collections::BTreeMap, + collections::{BTreeMap, VecDeque}, time::{SystemTime, UNIX_EPOCH}, }; @@ -29,7 +29,8 @@ use crate::{ api_response::json_success_body, auth::{AuthenticatedAccessToken, RuntimePrincipal}, generated_asset_sheets::{ - apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, + GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options, + crop_generated_asset_sheet_view_edge_matte_with_options, }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, @@ -46,8 +47,7 @@ use crate::{ work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; -const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] = - ["start", "normal", "target", "finish", "bonus", "accent"]; +const JUMP_HOP_TILE_ITEM_COUNT: usize = 25; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; @@ -55,8 +55,15 @@ const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; -const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 2; -const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; +const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 5; +const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5; +const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; +const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536"; +const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024; +const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536; +const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024"; +const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024; +const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024; #[derive(Clone, Debug, PartialEq, Eq)] struct JumpHopTileAtlasSlice { @@ -266,6 +273,35 @@ pub async fn get_jump_hop_runtime_work( )) } +pub async fn get_jump_hop_leaderboard( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &profile_id, "profileId")?; + let leaderboard = state + .spacetime_client() + .get_jump_hop_leaderboard(profile_id, principal.subject().to_string()) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_RUNTIME_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopLeaderboardResponse { + profile_id: leaderboard.profile_id, + items: leaderboard.items, + viewer_best: leaderboard.viewer_best, + }, + )) +} + pub async fn start_jump_hop_run( State(state): State, Extension(request_context): Extension, @@ -274,6 +310,10 @@ pub async fn start_jump_hop_run( ) -> Result, Response> { let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; + let is_draft_runtime = payload + .runtime_mode + .as_deref() + .is_some_and(is_jump_hop_draft_runtime_mode); let owner_user_id = principal.subject().to_string(); let principal_kind = principal.kind().as_str(); let run = state @@ -288,23 +328,25 @@ pub async fn start_jump_hop_run( ) })?; - record_work_play_start_after_success( - &state, - &request_context, - build_jump_hop_work_play_tracking_draft( - &principal, - run.profile_id.clone(), - JUMP_HOP_RUNTIME_RUNS_ROUTE, + if !is_draft_runtime { + record_work_play_start_after_success( + &state, + &request_context, + build_jump_hop_work_play_tracking_draft( + &principal, + run.profile_id.clone(), + JUMP_HOP_RUNTIME_RUNS_ROUTE, + ) + .owner_user_id(run.owner_user_id.clone()) + .run_id(run.run_id.clone()) + .profile_id(run.profile_id.clone()) + .extra(json!({ + "runStatus": run.status, + "principalKind": principal_kind, + })), ) - .owner_user_id(run.owner_user_id.clone()) - .run_id(run.run_id.clone()) - .profile_id(run.profile_id.clone()) - .extra(json!({ - "runStatus": run.status, - "principalKind": principal_kind, - })), - ) - .await; + .await; + } Ok(json_success_body( Some(&request_context), @@ -418,16 +460,29 @@ async fn maybe_generate_jump_hop_assets( owner_user_id: &str, payload: &mut JumpHopActionRequest, ) -> Result<(), Response> { - if !matches!(payload.action_type, JumpHopActionType::CompileDraft) { + if !matches!( + payload.action_type, + JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles + ) { return Ok(()); } - if payload.character_asset.is_some() - && payload.tile_atlas_asset.is_some() + let has_complete_tile_assets = payload.tile_atlas_asset.is_some() && payload .tile_assets .as_ref() - .is_some_and(|assets| !assets.is_empty()) - { + .is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT); + let has_real_background = payload + .cover_composite + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value)); + let has_back_button_asset = payload + .back_button_asset + .as_ref() + .is_some_and(is_jump_hop_image_asset_usable); + + if has_complete_tile_assets && has_real_background && has_back_button_asset { return Ok(()); } let profile_id = payload @@ -454,138 +509,345 @@ async fn maybe_generate_jump_hop_assets( jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; - let character_prompt = payload - .character_prompt + let theme_text = payload + .theme_text .as_deref() - .unwrap_or("俯视角可爱主角,透明背景"); - let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集"); + .or(payload.work_title.as_deref()) + .unwrap_or("跳一跳") + .to_string(); + let tile_prompt = payload + .tile_prompt + .clone() + .unwrap_or_else(|| theme_text.clone()); - let character_generated = create_openai_image_generation( - &http_client, - &settings, - character_prompt, - Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"), - "1024*1024", - 1, - &[], - "跳一跳角色资产生成失败", - ) - .await - .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; - let character_image = character_generated - .images - .into_iter() - .next() - .ok_or_else(|| { + if !has_real_background { + let background_prompt = build_jump_hop_background_prompt(theme_text.as_str()); + let background_generated = create_openai_image_generation( + &http_client, + &settings, + background_prompt.as_str(), + Some(build_jump_hop_background_negative_prompt()), + JUMP_HOP_BACKGROUND_IMAGE_SIZE, + 1, + &[], + "跳一跳背景底图生成失败", + ) + .await + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let background_image = background_generated + .images + .into_iter() + .next() + .ok_or_else(|| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "跳一跳背景底图生成成功但未返回图片。", + })), + ) + })?; + let background_asset = persist_jump_hop_generated_image_asset( + state, + owner_user_id, + profile_id.as_str(), + "background", + background_prompt.as_str(), + background_image, + LegacyAssetPrefix::JumpHopAssets, + JUMP_HOP_BACKGROUND_IMAGE_WIDTH, + JUMP_HOP_BACKGROUND_IMAGE_HEIGHT, + request_context, + ) + .await?; + payload.cover_composite = Some(background_asset.image_src); + } + + if !has_back_button_asset { + let back_button_prompt = build_jump_hop_back_button_prompt(theme_text.as_str()); + let back_button_generated = create_openai_image_generation( + &http_client, + &settings, + back_button_prompt.as_str(), + Some(build_jump_hop_back_button_negative_prompt()), + JUMP_HOP_BACK_BUTTON_IMAGE_SIZE, + 1, + &[], + "跳一跳返回按钮图生成失败", + ) + .await + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let back_button_image = + back_button_generated + .images + .into_iter() + .next() + .ok_or_else(|| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "跳一跳返回按钮图生成成功但未返回图片。", + })), + ) + })?; + let back_button_image = + prepare_jump_hop_green_screen_image_for_persist(back_button_image, "跳一跳返回按钮图") + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let back_button_asset = persist_jump_hop_generated_image_asset( + state, + owner_user_id, + profile_id.as_str(), + "back-button", + back_button_prompt.as_str(), + back_button_image, + LegacyAssetPrefix::JumpHopAssets, + JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH, + JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT, + request_context, + ) + .await?; + payload.back_button_asset = Some(back_button_asset); + } + + if !has_complete_tile_assets { + let sheet_prompt = + build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str()); + let tile_generated = create_openai_image_generation( + &http_client, + &settings, + sheet_prompt.as_str(), + Some(build_jump_hop_tile_atlas_negative_prompt()), + "1024*1024", + 1, + &[], + "跳一跳地块图集生成失败", + ) + .await + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| { jump_hop_error_response( request_context, JUMP_HOP_CREATION_PROVIDER, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", - "message": "跳一跳角色资产生成成功但未返回图片。", + "message": "跳一跳地块图集生成成功但未返回图片。", })), ) })?; - let character_asset = persist_jump_hop_generated_image_asset( - state, - owner_user_id, - profile_id.as_str(), - "character", - character_prompt, - character_image, - LegacyAssetPrefix::JumpHopAssets, - 768, - 768, - request_context, - ) - .await?; - - let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt); - let tile_generated = create_openai_image_generation( - &http_client, - &settings, - sheet_prompt.as_str(), - Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"), - "1024*1024", - 1, - &[], - "跳一跳地块图集生成失败", - ) - .await - .map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?; - let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| { - jump_hop_error_response( + let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let tile_atlas_asset = persist_jump_hop_generated_image_asset( + state, + owner_user_id, + profile_id.as_str(), + "tile-atlas", + tile_prompt.as_str(), + tile_image, + LegacyAssetPrefix::JumpHopAssets, + 1024, + 1024, request_context, - JUMP_HOP_CREATION_PROVIDER, - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "跳一跳地块图集生成成功但未返回图片。", - })), ) - })?; - let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| { - jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) - })?; - let tile_atlas_asset = persist_jump_hop_generated_image_asset( - state, - owner_user_id, - profile_id.as_str(), - "tile-atlas", - tile_prompt, - tile_image, - LegacyAssetPrefix::JumpHopAssets, - 1024, - 1024, - request_context, - ) - .await?; - let mut tile_assets = Vec::with_capacity(tile_slices.len()); - for (index, tile_slice) in tile_slices.into_iter().enumerate() { - tile_assets.push( - persist_jump_hop_tile_asset( - state, - owner_user_id, - profile_id.as_str(), - index, - tile_slice, - request_context, - ) - .await?, - ); + .await?; + let mut tile_assets = Vec::with_capacity(tile_slices.len()); + for (index, tile_slice) in tile_slices.into_iter().enumerate() { + tile_assets.push( + persist_jump_hop_tile_asset( + state, + owner_user_id, + profile_id.as_str(), + index, + tile_slice, + request_context, + ) + .await?, + ); + } + payload.tile_atlas_asset = Some(tile_atlas_asset); + payload.tile_assets = Some(tile_assets); + } + if payload.character_asset.is_none() { + payload.character_asset = Some(build_jump_hop_default_character_asset( + profile_id.as_str(), + theme_text.as_str(), + )); } - payload.character_asset = Some(character_asset); - payload.tile_atlas_asset = Some(tile_atlas_asset); - payload.tile_assets = Some(tile_assets); - payload.cover_composite = payload.cover_composite.clone().or_else(|| { - Some(format!( - "/generated-jump-hop-assets/{profile_id}/cover-composite.png" - )) - }); Ok(()) } -fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String { - let subject_text = tile_prompt.trim(); - let subject_text = if subject_text.is_empty() { - "等距立体地块图集" - } else { - subject_text - }; - let cell_plan = [ - "第1行第1列:start 起点地块", - "第1行第2列:normal 普通地块", - "第1行第3列:target 目标地块", - "第2行第1列:finish 终点地块", - "第2行第2列:bonus 奖励地块", - "第2行第3列:accent 视觉强调地块", - ] - .join(";"); +fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool { + let value = value.trim(); + value.starts_with("/generated-jump-hop-assets/") + && (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-")) +} + +fn is_jump_hop_image_asset_usable(asset: &JumpHopCharacterAsset) -> bool { + !asset.image_src.trim().is_empty() + && !asset.image_object_key.trim().is_empty() + && !asset.asset_object_id.trim().is_empty() + && !asset.generation_provider.trim().is_empty() +} + +fn prepare_jump_hop_green_screen_image_for_persist( + image: crate::openai_image_generation::DownloadedOpenAiImage, + failure_label: &str, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("{failure_label}解码失败:{error}"), + })) + })?; + let mut encoded = std::io::Cursor::new(Vec::new()); + crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source) + .write_to(&mut encoded, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("{failure_label}绿幕去背失败:{error}"), + })) + })?; + + Ok(crate::openai_image_generation::DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + +fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String { + let theme_text = theme_text.trim(); + if theme_text.is_empty() { + return "跳一跳".to_string(); + } + + replace_jump_hop_pokemon_prompt_terms(theme_text) +} + +fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String { + let mut value = value.trim().to_string(); + if value.is_empty() { + return value; + } + + // 中文注释:仅对宝可梦相关词做生成侧脱敏,避免地块图集触发上游安全拦截。 + const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [ + ("宝可梦", "原创幻想萌宠冒险道具"), + ("神奇宝贝", "原创幻想萌宠冒险道具"), + ("口袋妖怪", "原创幻想萌宠冒险道具"), + ("精灵球", "彩色冒险能量球"), + ("皮卡丘", "黄色闪电萌宠符号"), + ("Pokémon", "原创幻想萌宠冒险道具"), + ("Pokemon", "原创幻想萌宠冒险道具"), + ("POKEMON", "原创幻想萌宠冒险道具"), + ("pokemon", "原创幻想萌宠冒险道具"), + ("Pikachu", "黄色闪电萌宠符号"), + ("PIKACHU", "黄色闪电萌宠符号"), + ("pikachu", "黄色闪电萌宠符号"), + ("Poké Ball", "彩色冒险能量球"), + ("Poke Ball", "彩色冒险能量球"), + ("pokeball", "彩色冒险能量球"), + ]; + + for (from, to) in POKEMON_REPLACEMENTS { + value = value.replace(from, to); + } + + value +} + +fn build_jump_hop_background_prompt(theme_text: &str) -> String { + let theme_text = normalize_jump_hop_generation_theme_text(theme_text); format!( - "生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。" + "生成一张9:16竖版跳一跳游戏背景底图,主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊,从画面底部延伸到上方;该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线,禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感,但元素数量必须少,不能抢跳板、角色和交互层的视觉;两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图,不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印;左上角也不要画返回按钮或任何固定图标,运行态会叠加独立可点击按钮资产。\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然,哑光手绘质感,柔和光照,主体背景不油亮、不厚重CG、不暗黑;中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no top-left back button, no score UI, no other UI panels, consistent 2D/2.5D front-facing 30-degree game perspective." ) } +fn build_jump_hop_background_negative_prompt() -> &'static str { + "文字、Logo、水印、UI按钮、返回按钮、左上角图标、右上角按钮、底部按钮、UI面板、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感" +} + +fn build_jump_hop_back_button_prompt(theme_text: &str) -> String { + let theme_text = normalize_jump_hop_generation_theme_text(theme_text); + + format!( + "生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标,圆心居中,主体视觉尺寸占画布约72%-82%,外沿有一圈干净描边,内部只有一个居中的向左箭头;不要写“返回”文字,不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1,输出绿色背景主体图,背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal." + ) +} + +fn build_jump_hop_back_button_negative_prompt() -> &'static str { + "文字、返回文字、Logo、水印、数字、多个按钮、UI面板、海报排版、复杂徽章、花盘、浮雕边、异形外框、主题物体主体、木槌、角色、跳板、地块、落脚物、平台、透明棋盘格、白底、黑底、灰底、真实摄影、厚重CG、暗黑幻想风、油亮塑料、纯绿色按钮主体、与绿幕混在一起" +} + +fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String { + let theme_text = normalize_jump_hop_generation_theme_text(theme_text); + let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt); + let subject_text = if sanitized_tile_prompt.is_empty() { + theme_text.as_str() + } else { + sanitized_tile_prompt.as_str() + }; + + format!( + "生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳跃落点主题物体,按五行五列均匀摆放在纯洋红抠图画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为正面30度视角的跳跃游戏素材,画面内容是{subject_text}。所有落点素材都必须保持统一的正面30度视角:相机位于物体正前方略高位置,镜头向下约30度,能看到清晰正面、侧壁、下沿和少量上表面。\n构图验收标准:主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;不要让顶面占据主要视觉,不要画成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标。\n水果主题尤其要避免俯拍:橙瓣必须看到橙皮正面外侧和果肉厚度,椰子必须看到壳的正面侧壁和切口厚度,浆果不能只是一个从上往下看的圆形球顶。\n每一个落点都必须直接使用主题物体或合理发散物体做主体造型,主题要一眼可见;例如主题为水果时,可以是苹果切片、橙瓣、西瓜块、草莓、菠萝块、香蕉、葡萄串等水果物体,苹果可近似圆,香蕉可近似长条或长方形,西瓜可近似扇形,造型以物体本身外轮廓为准。\n主题物体本身就是唯一可落脚体:雪花落点就是一枚带厚度的雪花,向日葵落点就是一朵带厚度的向日葵,水果落点就是水果切片或水果本体;不要在主题物体下面再垫任何石头、土块、木板、圆台、底盘、托盘、岛屿、花盆、地面块或通用承托物。\n只画主题物体裸素材,不画外层面板、棋盘底座、菜单、UI按钮、标题、文字、角标、装饰边框、工具栏、装备栏、图标卡、角色或游戏界面。\n整体风格为清爽自然的休闲手游主题物体素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每个落点都是符合主题且有设计感的立体感物体,有清晰轮廓和明显自身厚度;不要把不同主题物体强行改造成统一地砖、统一按钮或统一抽象图标。\n造型规则完全由物体本身决定:允许圆形、长条、弧形、三角、扇形、块状、枝叶状、多件组合、轻微夸张和一定程度发散;只在同一2D/2.5D手绘风格、正面30度视角、材质包装、清晰轮廓、单格规格和安全留白上保持一致。\n25个落点应尽量选择不同主题物体或相关发散物体,差异主要来自物体种类和原生轮廓,不使用固定形状脚本;相邻格可以形状相似,只要物体不同且主题清楚。\n允许用主题物体自身的切面、边缘厚度、花瓣层、果皮边、雪花厚边或云朵体积表现可落脚感;禁止额外支撑层、承托底座、脚下地板、下方石台、下方土墩、下方圆盘、下方托盘或“物体摆在平台上”的画法。\n每个落点必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯洋红安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个落点只保留主体内部明暗、外轮廓和自身厚度,不绘制落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、彩色光晕、发光底边、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个落点同一材质体系、同一光向和同一正面30度视角,但物体类别、外轮廓和细节有变化;每个落点之间只能是纯洋红空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX},背景平整无纹理、无渐变、无阴影、无黑底;主体允许使用绿色、白色、雪地、云朵、草地和花朵,但主体自身不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的洋红色,主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板、图标集页面、物体下方额外底座或物体摆在地板上。\nEnglish guardrail: isolated front-facing 30-degree camera-pitch theme-object assets only, camera slightly above the object and looking down about 30 degrees from the front; every object must show a clear front face, side wall, lower rim, object thickness, and only a small top surface; visible front/side area must be close to or larger than the top area; never produce top-down, overhead, bird's-eye, flat icon, round top-view disk assets; the theme object itself is the only landing object, each object's native silhouette decides the shape, no extra base under the object, no pedestal, no plinth, no floor slab, no colored shadow or magenta fringe around objects, consistent 2D/2.5D style wrapper, solid magenta chroma key background {JUMP_HOP_TILE_ATLAS_KEY_HEX}, no text, no poster, no UI screen, no inventory icons." + ) +} + +fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { + "文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、纯俯视角、正上方视角、鸟瞰视角、平铺俯拍、顶面占主画面、只看顶面、圆形顶视图、扁平图标、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界" +} + +fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { + let mut value = tile_prompt.trim().to_string(); + if value.is_empty() { + return value; + } + value = replace_jump_hop_pokemon_prompt_terms(value.as_str()); + + const REPLACEMENTS: [(&str, &str); 18] = [ + ("俯视角", "正面30度视角"), + ("正上方视角", "正面30度视角"), + ("鸟瞰视角", "正面30度视角"), + ("平铺俯拍", "正面30度视角"), + ("可落脚平台素材", "跳跃落点主题物体"), + ("清爽游戏化立体感平台素材", "清爽游戏化立体感主题物体"), + ("平台裸素材", "主题物体裸素材"), + ("每格一个完整平台", "每格一个完整主题物体"), + ("平台素材", "主题物体"), + ("可落脚平台", "跳跃落点"), + ("可落脚", "落点"), + ("平台", "主题物体"), + ("跳台", "落点"), + ("地块", "主题物体"), + ("地砖", "主题物体"), + ("底座", "承托物"), + ("底盘", "承托物"), + ("地板", "承托物"), + ]; + + for (from, to) in REPLACEMENTS { + value = value.replace(from, to); + } + while value.contains("正面30度视角正面30度视角") { + value = value.replace("正面30度视角正面30度视角", "正面30度视角"); + } + + value +} + fn slice_jump_hop_tile_atlas( image: &crate::openai_image_generation::DownloadedOpenAiImage, ) -> Result, AppError> { @@ -595,7 +857,8 @@ fn slice_jump_hop_tile_atlas( "message": format!("跳一跳地块图集解码失败:{error}"), })) })?; - let source = apply_generated_asset_sheet_green_screen_alpha(source); + let alpha_options = GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(); + let source = apply_generated_asset_sheet_alpha_with_options(source, alpha_options); let width = source.width(); let height = source.height(); let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; @@ -609,8 +872,8 @@ fn slice_jump_hop_tile_atlas( ); } - let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len()); - for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() { + let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_COUNT); + for index in 0..JUMP_HOP_TILE_ITEM_COUNT { let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS; @@ -623,7 +886,12 @@ fn slice_jump_hop_tile_atlas( x1.saturating_sub(x0).max(1), y1.saturating_sub(y0).max(1), ); - let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped); + let cleaned = + crop_generated_asset_sheet_view_edge_matte_with_options(cropped, alpha_options); + let cleaned = keep_jump_hop_largest_alpha_component(cleaned); + let cleaned = + crop_generated_asset_sheet_view_edge_matte_with_options(cleaned, alpha_options); + let cleaned = pad_jump_hop_tile_slice_image(cleaned); let mut cursor = std::io::Cursor::new(Vec::new()); cleaned .write_to(&mut cursor, image::ImageFormat::Png) @@ -643,26 +911,116 @@ fn slice_jump_hop_tile_atlas( Ok(slices) } +fn pad_jump_hop_tile_slice_image(image: image::DynamicImage) -> image::DynamicImage { + let source = image.to_rgba8(); + let (width, height) = source.dimensions(); + if width == 0 || height == 0 { + return image::DynamicImage::ImageRgba8(source); + } + + // 中文注释:生图偶尔会让主体贴近单元格边缘;切片入库前补透明安全边, + // 避免运行态缩放或滤镜让主体看起来被裁掉。 + let pad_x = (width / 12).clamp(8, 24); + let pad_y = (height / 12).clamp(8, 24); + let mut padded = image::RgbaImage::from_pixel( + width.saturating_add(pad_x.saturating_mul(2)), + height.saturating_add(pad_y.saturating_mul(2)), + image::Rgba([0, 0, 0, 0]), + ); + image::imageops::overlay(&mut padded, &source, pad_x.into(), pad_y.into()); + image::DynamicImage::ImageRgba8(padded) +} + +fn keep_jump_hop_largest_alpha_component(image: image::DynamicImage) -> image::DynamicImage { + let mut source = image.to_rgba8(); + let (width, height) = source.dimensions(); + if width == 0 || height == 0 { + return image::DynamicImage::ImageRgba8(source); + } + + // 中文注释:模型偶尔会让相邻格的叶片、果梗或阴影越界进当前格; + // 每格只保留最大的 alpha 连通主体,能去掉这些小碎片再入库。 + let width_usize = width as usize; + let height_usize = height as usize; + let pixel_count = width_usize.saturating_mul(height_usize); + let mut visited = vec![false; pixel_count]; + let mut best_component = Vec::::new(); + + for start in 0..pixel_count { + if visited[start] || source.as_raw()[start * 4 + 3] <= 16 { + visited[start] = true; + continue; + } + + let mut queue = VecDeque::from([start]); + let mut component = Vec::::new(); + visited[start] = true; + + while let Some(index) = queue.pop_front() { + component.push(index); + let x = index % width_usize; + let y = index / width_usize; + + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 + { + continue; + } + let next = next_y as usize * width_usize + next_x as usize; + if visited[next] { + continue; + } + visited[next] = true; + if source.as_raw()[next * 4 + 3] > 16 { + queue.push_back(next); + } + } + } + } + + if component.len() > best_component.len() { + best_component = component; + } + } + + if best_component.is_empty() { + return image::DynamicImage::ImageRgba8(source); + } + + let mut keep = vec![false; pixel_count]; + for index in best_component { + keep[index] = true; + } + for index in 0..pixel_count { + if keep[index] { + continue; + } + let pixel = + source.get_pixel_mut((index % width_usize) as u32, (index / width_usize) as u32); + pixel.0[3] = 0; + } + + image::DynamicImage::ImageRgba8(source) +} + fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { match index { 0 => JumpHopTileType::Start, - 1 => JumpHopTileType::Normal, - 2 => JumpHopTileType::Target, - 3 => JumpHopTileType::Finish, - 4 => JumpHopTileType::Bonus, - _ => JumpHopTileType::Accent, + value if value % 11 == 0 => JumpHopTileType::Bonus, + value if value % 7 == 0 => JumpHopTileType::Accent, + value if value % 3 == 0 => JumpHopTileType::Target, + _ => JumpHopTileType::Normal, } } -fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str { - match tile_type { - JumpHopTileType::Start => "tile-start", - JumpHopTileType::Normal => "tile-normal", - JumpHopTileType::Target => "tile-target", - JumpHopTileType::Finish => "tile-finish", - JumpHopTileType::Bonus => "tile-bonus", - JumpHopTileType::Accent => "tile-accent", - } +fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String { + format!("tile-{:02}", tile_index + 1) } #[allow(clippy::too_many_arguments)] @@ -674,7 +1032,7 @@ async fn persist_jump_hop_tile_asset( tile_slice: JumpHopTileAtlasSlice, request_context: &RequestContext, ) -> Result { - let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type); + let slot = jump_hop_tile_asset_slot_name(tile_index); let image = crate::openai_image_generation::DownloadedOpenAiImage { bytes: tile_slice.bytes, mime_type: "image/png".to_string(), @@ -684,7 +1042,7 @@ async fn persist_jump_hop_tile_asset( state, owner_user_id, profile_id, - slot, + slot.as_str(), &format!( "跳一跳地块切片 {}:{}", tile_index + 1, @@ -700,10 +1058,13 @@ async fn persist_jump_hop_tile_asset( Ok(JumpHopTileAsset { tile_type: tile_slice.tile_type, + tile_id: Some(slot), image_src: persisted.image_src, image_object_key: persisted.image_object_key, asset_object_id: persisted.asset_object_id, source_atlas_cell: tile_slice.source_atlas_cell, + atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1), + atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1), visual_width: 256, visual_height: 192, top_surface_radius: 42.0, @@ -711,6 +1072,22 @@ async fn persist_jump_hop_tile_asset( }) } +fn build_jump_hop_default_character_asset( + profile_id: &str, + theme_text: &str, +) -> JumpHopCharacterAsset { + JumpHopCharacterAsset { + asset_id: format!("{profile_id}-builtin-character"), + image_src: "builtin://jump-hop/default-character".to_string(), + image_object_key: String::new(), + asset_object_id: format!("{profile_id}-builtin-character"), + generation_provider: "builtin-three".to_string(), + prompt: format!("内置默认 3D 角色:{}", theme_text.trim()), + width: 0, + height: 0, + } +} + async fn persist_jump_hop_generated_image_asset( state: &AppState, owner_user_id: &str, @@ -893,18 +1270,31 @@ fn build_jump_hop_work_play_tracking_draft( WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route) } +fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool { + runtime_mode.trim().eq_ignore_ascii_case("draft") +} + fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse { + let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title); JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, - work_title: payload.work_title.trim().to_string(), - work_description: payload.work_description.trim().to_string(), + theme_text: theme_text.clone(), + work_title: clean_or_default(&payload.work_title, &theme_text), + work_description: clean_or_default( + &payload.work_description, + &format!("{theme_text}主题的俯视角跳跃作品"), + ), theme_tags: normalize_tags(payload.theme_tags.clone()), difficulty: payload.difficulty.clone(), style_preset: payload.style_preset.clone(), - character_prompt: payload.character_prompt.trim().to_string(), - tile_prompt: payload.tile_prompt.trim().to_string(), + default_character: Some(default_jump_hop_character()), + character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"), + tile_prompt: clean_or_default( + &payload.tile_prompt, + &format!("{theme_text}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"), + ), end_mood_prompt: payload .end_mood_prompt .as_ref() @@ -915,6 +1305,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft tile_assets: Vec::new(), path: None, cover_composite: None, + back_button_asset: None, generation_status: JumpHopGenerationStatus::Draft, } } @@ -923,13 +1314,7 @@ fn validate_workspace_request( request_context: &RequestContext, payload: &JumpHopWorkspaceCreateRequest, ) -> Result<(), Response> { - ensure_non_empty(request_context, &payload.work_title, "workTitle")?; - ensure_non_empty( - request_context, - &payload.character_prompt, - "characterPrompt", - )?; - ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?; + ensure_non_empty(request_context, &payload.theme_text, "themeText")?; if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID { return Err(jump_hop_error_response( request_context, @@ -943,6 +1328,32 @@ fn validate_workspace_request( Ok(()) } +fn normalize_theme_text(theme_text: &str, fallback: &str) -> String { + clean_or_default(theme_text, fallback) + .chars() + .take(60) + .collect::() +} + +fn clean_or_default(value: &str, fallback: &str) -> String { + let value = value.trim(); + if value.is_empty() { + fallback.trim().to_string() + } else { + value.to_string() + } +} + +fn default_jump_hop_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter { + shared_contracts::jump_hop::JumpHopDefaultCharacter { + character_id: "jump-hop-default-runner".to_string(), + display_name: "默认角色".to_string(), + model_kind: "builtin-three".to_string(), + body_color: "#f59e0b".to_string(), + accent_color: "#2563eb".to_string(), + } +} + fn ensure_non_empty( request_context: &RequestContext, value: &str, @@ -1046,32 +1457,306 @@ mod tests { use super::*; #[test] - fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() { - let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块"); + fn jump_hop_draft_runtime_mode_detection_matches_client_normalization() { + assert!(is_jump_hop_draft_runtime_mode("draft")); + assert!(is_jump_hop_draft_runtime_mode(" DRAFT ")); + assert!(!is_jump_hop_draft_runtime_mode("published")); + assert!(!is_jump_hop_draft_runtime_mode("")); + } - assert!(prompt.contains("2行*3列")); - assert!(prompt.contains("第1行第1列:start 起点地块")); - assert!(prompt.contains("第2行第3列:accent 视觉强调地块")); + #[test] + fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() { + let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台"); + + assert!(prompt.contains("五行五列")); + assert!(prompt.contains("25个独立")); + assert!(prompt.contains("跳跃落点主题物体")); + assert!(prompt.contains("不要画成游戏界面")); + assert!(prompt.contains("视觉方向为正面30度视角")); + assert!(prompt.contains("所有落点素材都必须保持统一的正面30度视角")); + assert!(prompt.contains("相机位于物体正前方略高位置")); + assert!(prompt.contains("镜头向下约30度")); + assert!(prompt.contains("能看到清晰正面、侧壁、下沿和少量上表面")); + assert!(prompt.contains("主体正面或侧壁可见面积必须接近或大于顶面面积")); + assert!(prompt.contains("顶面只能作为辅助可见面")); + assert!(prompt.contains("不要让顶面占据主要视觉")); + assert!(prompt.contains("不要画成纯俯视、正上方俯拍、鸟瞰地图块")); + assert!(prompt.contains("水果主题尤其要避免俯拍")); + assert!(prompt.contains("橙瓣必须看到橙皮正面外侧和果肉厚度")); + assert!(prompt.contains("浆果不能只是一个从上往下看的圆形球顶")); + assert!(prompt.contains("主题要一眼可见")); + assert!(prompt.contains("每个落点都是符合主题且有设计感的立体感物体")); + assert!(prompt.contains("清爽自然的休闲手游主题物体素材")); + assert!(prompt.contains("符合主题且有设计感的立体感物体")); + assert!(prompt.contains("每一个落点都必须直接使用主题物体或合理发散物体")); + assert!(prompt.contains("苹果可近似圆")); + assert!(prompt.contains("香蕉可近似长条或长方形")); + assert!(prompt.contains("主题物体本身就是唯一可落脚体")); + assert!(prompt.contains("雪花落点就是一枚带厚度的雪花")); + assert!(prompt.contains("不要在主题物体下面再垫任何石头、土块、木板")); + assert!(prompt.contains("造型规则完全由物体本身决定")); + assert!(prompt.contains("允许圆形、长条、弧形、三角、扇形、块状")); + assert!(prompt.contains("只在同一2D/2.5D手绘风格")); + assert!(prompt.contains("同一正面30度视角")); + assert!(prompt.contains("不使用固定形状脚本")); + assert!(prompt.contains("允许用主题物体自身的切面、边缘厚度")); + assert!(prompt.contains("禁止额外支撑层、承托底座、脚下地板")); + assert!(prompt.contains("四周至少保留18%纯洋红安全留白")); + assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX)); + assert!(prompt.contains("主体允许使用绿色、白色、雪地、云朵、草地和花朵")); + assert!(prompt.contains("不绘制落地投影")); + assert!(prompt.contains("不绘制落地投影、接触阴影、方形阴影、洋红阴影")); + assert!(prompt.contains("紫色底边、彩色光晕、发光底边")); + assert!(prompt.contains("不画分隔线、网格线、容器框或棋盘格")); + assert!(prompt.contains("主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影")); + assert!(prompt.contains("English guardrail")); + assert!(prompt.contains("front-facing 30-degree camera-pitch")); + assert!(prompt.contains("camera slightly above the object")); + assert!( + prompt.contains("visible front/side area must be close to or larger than the top area") + ); + assert!(prompt.contains("never produce top-down")); + assert!(prompt.contains("each object's native silhouette decides the shape")); + assert!(prompt.contains("no extra base under the object")); + assert!(prompt.contains("no pedestal")); + assert!(prompt.contains("no floor slab")); + assert!(prompt.contains("no colored shadow or magenta fringe around objects")); + assert!(!prompt.contains("可落脚平台素材")); + assert!(!prompt.contains("平台裸素材")); + assert!(!prompt.contains("每格一个完整平台")); + assert!(!prompt.contains("25个平台")); + assert!(!prompt.contains("platform, each")); + assert!(!prompt.contains("only platform")); + assert!(!prompt.contains("基础轮廓优先做不规则主题剪影")); + assert!(!prompt.contains("25格造型要混排")); + assert!(!prompt.contains("no simple circles")); + assert!(!prompt.contains("no simple squares")); + assert!(!prompt.contains("纯绿色绿幕")); + assert!(!prompt.contains("#00FF00")); + assert!(!prompt.contains("isolated top-down")); + assert!(!prompt.contains("按5行*5列")); + assert!(!prompt.contains("2D地板图标")); + assert!(!prompt.contains("清爽自然的游戏图标")); + assert!(!prompt.contains("边缘厚度暗示")); + assert!(!prompt.contains("统一投影")); assert!(!prompt.contains("每个物品生成")); assert!(!prompt.contains("不同视图")); } #[test] - fn jump_hop_tile_atlas_slices_one_png_per_tile_type() { - let width = 300; - let height = 200; - let colors = [ - [220, 24, 24, 255], - [240, 150, 32, 255], - [248, 220, 72, 255], - [52, 168, 84, 255], - [38, 132, 255, 255], - [156, 92, 220, 255], - ]; + fn jump_hop_background_prompt_keeps_center_corridor_and_side_atmosphere() { + let prompt = build_jump_hop_background_prompt("水果"); + + assert!(prompt.contains("9:16竖版跳一跳游戏背景底图")); + assert!(prompt.contains("主题关键词严格只使用“水果”")); + assert!(prompt.contains("整体风格需要和同一主题的跳一跳游戏元素一致")); + assert!(prompt.contains("左右两侧氛围为主")); + assert!(prompt.contains("中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊")); + assert!(prompt.contains("该区域只能使用少量低对比度纹理")); + assert!(prompt.contains("中央纵轴1/2区域要有明显纵深感")); + assert!(prompt.contains("两侧可以更有立体感、空间层次和主题氛围")); + assert!(prompt.contains("不画任何跳板、地块、落脚物、角色、UI按钮")); + assert!(prompt.contains("左上角也不要画返回按钮或任何固定图标")); + assert!(prompt.contains("运行态会叠加独立可点击按钮资产")); + assert!(prompt.contains("视角保持正面约30度")); + assert!(prompt.contains("中央区域需要给运行态地块和陶泥儿角色留出干净可读空间")); + assert!(prompt.contains("English guardrail")); + assert!(prompt.contains("left and right sides carry the atmosphere")); + assert!(prompt.contains("central vertical half-width corridor stays simple")); + assert!(prompt.contains("no top-left back button")); + assert!(prompt.contains("no platforms")); + assert!(prompt.contains("no landing objects")); + assert!(prompt.contains("no other UI panels")); + } + + #[test] + fn jump_hop_back_button_prompt_builds_standalone_green_screen_asset() { + let prompt = build_jump_hop_back_button_prompt("水果"); + + assert!(prompt.contains("独立透明素材")); + assert!(prompt.contains("主题关键词严格只使用“水果”")); + assert!(prompt.contains("单个标准圆形图标")); + assert!(prompt.contains("内部只有一个居中的向左箭头")); + assert!(prompt.contains("不要写“返回”文字")); + assert!(prompt.contains("背景必须是单一纯绿色 #00FF00")); + assert!(prompt.contains("后续由服务端扣除绿色背景")); + assert!(prompt.contains("one standalone circular mobile game back button asset only")); + assert!(prompt.contains("solid #00FF00 green-screen background")); + } + + #[test] + fn jump_hop_background_negative_prompt_blocks_runtime_layer_conflicts() { + let negative_prompt = build_jump_hop_background_negative_prompt(); + + assert!(negative_prompt.contains("跳板")); + assert!(negative_prompt.contains("地块")); + assert!(negative_prompt.contains("落脚物")); + assert!(negative_prompt.contains("角色")); + assert!(negative_prompt.contains("UI按钮")); + assert!(negative_prompt.contains("返回按钮")); + assert!(negative_prompt.contains("左上角图标")); + assert!(negative_prompt.contains("右上角按钮")); + assert!(negative_prompt.contains("底部按钮")); + assert!(negative_prompt.contains("UI面板")); + assert!(negative_prompt.contains("中央堆满元素")); + assert!(negative_prompt.contains("中央遮挡")); + assert!(negative_prompt.contains("纯俯视地图")); + assert!(negative_prompt.contains("平铺俯拍")); + } + + #[test] + fn jump_hop_legacy_cover_placeholder_is_not_treated_as_background() { + assert!(is_jump_hop_legacy_cover_composite_placeholder( + "/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png", + )); + assert!(is_jump_hop_legacy_cover_composite_placeholder( + "/generated-jump-hop-assets/jump-hop-profile-test/cover-composite-123.png", + )); + assert!(!is_jump_hop_legacy_cover_composite_placeholder( + "/generated-jump-hop-assets/jump-hop-profile-test/background/image.png", + )); + assert!(!is_jump_hop_legacy_cover_composite_placeholder( + "/uploads/custom-cover.png", + )); + } + + #[test] + fn jump_hop_generation_prompt_only_rewrites_pokemon_terms() { + let background_prompt = build_jump_hop_background_prompt("宝可梦"); + assert!(background_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”")); + assert!(!background_prompt.contains("宝可梦")); + + let back_button_prompt = build_jump_hop_back_button_prompt("Pokemon"); + assert!(back_button_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”")); + assert!(!back_button_prompt.contains("Pokemon")); + + let tile_prompt = build_jump_hop_tile_atlas_prompt( + "宝可梦", + "宝可梦主题的正面30度视角主题物体图集,包含皮卡丘和精灵球装饰", + ); + assert!(tile_prompt.contains("主题为“原创幻想萌宠冒险道具”")); + assert!(tile_prompt.contains("画面内容是原创幻想萌宠冒险道具主题")); + assert!(tile_prompt.contains("黄色闪电萌宠符号")); + assert!(tile_prompt.contains("彩色冒险能量球")); + assert!(!tile_prompt.contains("宝可梦")); + assert!(!tile_prompt.contains("皮卡丘")); + assert!(!tile_prompt.contains("精灵球")); + + let normal_prompt = + build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集"); + assert!(normal_prompt.contains("主题为“水果”")); + assert!(normal_prompt.contains("画面内容是水果主题的正面30度视角主题物体图集")); + } + + #[test] + fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() { + let prompt = build_jump_hop_tile_atlas_prompt( + "科幻芯片", + "科幻芯片主题的俯视角清爽游戏化立体感平台素材", + ); + + assert!(prompt.contains("画面内容是科幻芯片主题的正面30度视角清爽游戏化立体感主题物体")); + assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材")); + assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角")); + + let top_down_prompt = + build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台"); + + assert!(top_down_prompt.contains("画面内容是水果主题正面30度视角圆形主题物体")); + assert!(!top_down_prompt.contains("画面内容是水果主题鸟瞰视角")); + assert!(!top_down_prompt.contains("画面内容是水果主题平铺俯拍")); + + let legacy_prompt = build_jump_hop_tile_atlas_prompt( + "雪花", + "雪花主题可落脚平台素材,每格一个完整平台,不要底座", + ); + + assert!(legacy_prompt.contains("雪花主题跳跃落点主题物体")); + assert!(legacy_prompt.contains("每格一个完整主题物体")); + assert!(legacy_prompt.contains("不要承托物")); + assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚平台素材")); + assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚")); + assert!(!legacy_prompt.contains("画面内容是雪花主题平台")); + assert!(!legacy_prompt.contains("画面内容是雪花主题地块")); + } + + #[test] + fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() { + let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); + + assert!(negative_prompt.contains("油亮高光")); + assert!(negative_prompt.contains("厚重CG渲染")); + assert!(negative_prompt.contains("游戏界面")); + assert!(negative_prompt.contains("图标集页面")); + assert!(negative_prompt.contains("纯俯视角")); + assert!(negative_prompt.contains("正上方视角")); + assert!(negative_prompt.contains("鸟瞰视角")); + assert!(negative_prompt.contains("顶面占主画面")); + assert!(negative_prompt.contains("只看顶面")); + assert!(negative_prompt.contains("圆形顶视图")); + assert!(negative_prompt.contains("扁平图标")); + assert!(negative_prompt.contains("方形阴影")); + assert!(negative_prompt.contains("洋红阴影")); + assert!(negative_prompt.contains("紫色底边")); + assert!(negative_prompt.contains("粉色脏边")); + assert!(negative_prompt.contains("洋红色描边")); + assert!(negative_prompt.contains("彩色光晕")); + assert!(negative_prompt.contains("发光底边")); + assert!(negative_prompt.contains("方形底板")); + assert!(negative_prompt.contains("额外底座")); + assert!(negative_prompt.contains("承托底座")); + assert!(negative_prompt.contains("台座")); + assert!(negative_prompt.contains("物体摆在平台上")); + assert!(negative_prompt.contains("物体下方垫地板")); + assert!(!negative_prompt.contains("规则圆盘")); + assert!(!negative_prompt.contains("正圆平台")); + assert!(!negative_prompt.contains("规则方块")); + assert!(!negative_prompt.contains("圆角矩形")); + assert!(!negative_prompt.contains("杯垫")); + assert!(!negative_prompt.contains("重复圆形")); + assert!(!negative_prompt.contains("建筑")); + assert!(!negative_prompt.contains("楼房")); + } + + #[test] + fn jump_hop_tile_slice_keeps_largest_alpha_component() { + let mut image = image::RgbaImage::from_pixel(80, 80, image::Rgba([0, 0, 0, 0])); + for y in 12..52 { + for x in 12..52 { + image.put_pixel(x, y, image::Rgba([220, 70, 50, 255])); + } + } + for y in 68..74 { + for x in 36..42 { + image.put_pixel(x, y, image::Rgba([40, 190, 80, 255])); + } + } + + let cleaned = keep_jump_hop_largest_alpha_component(image::DynamicImage::ImageRgba8(image)) + .to_rgba8(); + + assert_eq!(cleaned.get_pixel(20, 20).0[3], 255); + assert_eq!( + cleaned.get_pixel(38, 70).0[3], + 0, + "相邻格侵入的小碎片不应扩大当前地块切片边界" + ); + } + + #[test] + fn jump_hop_tile_atlas_slices_twenty_five_png_tiles() { + let width = 500; + let height = 500; let mut atlas = image::RgbaImage::new(width, height); - for row in 0..2 { - for col in 0..3 { - let color = image::Rgba(colors[row * 3 + col]); + for row in 0..5 { + for col in 0..5 { + let index = row * 5 + col; + let color = image::Rgba([ + 40 + index as u8 * 3, + 24 + index as u8 * 5, + 120 + index as u8 * 2, + 255, + ]); for y in row as u32 * 100..(row as u32 + 1) * 100 { for x in col as u32 * 100..(col as u32 + 1) * 100 { atlas.put_pixel(x, y, color); @@ -1091,20 +1776,104 @@ mod tests { let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); - assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len()); + assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT); for (index, slice) in slices.iter().enumerate() { assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index)); assert_eq!( slice.source_atlas_cell, - format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1) + format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1) ); let decoded = image::load_from_memory(slice.bytes.as_slice()) .expect("tile slice should decode") .to_rgba8(); + assert_eq!( + decoded.dimensions(), + (116, 116), + "跳一跳地块切片应在 100x100 单元格外补透明安全边" + ); + let color = [ + 40 + index as u8 * 3, + 24 + index as u8 * 5, + 120 + index as u8 * 2, + 255, + ]; assert!( - decoded.pixels().any(|pixel| pixel.0 == colors[index]), + decoded.pixels().any(|pixel| pixel.0 == color), "第 {index} 个地块切片应保留对应格子的主体颜色" ); } } + + #[test] + fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() { + let width = 500; + let height = 500; + let mut atlas = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255])); + for row in 0..5 { + for col in 0..5 { + let color = if row == 0 && col == 0 { + image::Rgba([62, 188, 74, 255]) + } else if row == 0 && col == 1 { + image::Rgba([246, 246, 238, 255]) + } else { + image::Rgba([120, 96, 72, 255]) + }; + let center_x = col as u32 * 100 + 50; + let center_y = row as u32 * 100 + 50; + for y in center_y - 24..center_y + 24 { + for x in center_x - 28..center_x + 28 { + atlas.put_pixel(x, y, color); + } + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(atlas) + .write_to(&mut encoded, image::ImageFormat::Png) + .expect("atlas should encode"); + let image = crate::openai_image_generation::DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); + let green_tile = image::load_from_memory(slices[0].bytes.as_slice()) + .expect("green tile should decode") + .to_rgba8(); + let white_tile = image::load_from_memory(slices[1].bytes.as_slice()) + .expect("white tile should decode") + .to_rgba8(); + + assert!( + green_tile + .pixels() + .any(|pixel| pixel.0 == [62, 188, 74, 255]) + ); + assert!( + white_tile + .pixels() + .any(|pixel| pixel.0 == [246, 246, 238, 255]) + ); + assert_eq!(green_tile.get_pixel(0, 0).0[3], 0); + assert_eq!(white_tile.get_pixel(0, 0).0[3], 0); + } + + #[test] + fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() { + let slots = (0..JUMP_HOP_TILE_ITEM_COUNT) + .map(jump_hop_tile_asset_slot_name) + .collect::>(); + let unique_slots = slots + .iter() + .cloned() + .collect::>(); + + assert_eq!( + unique_slots.len(), + JUMP_HOP_TILE_ITEM_COUNT, + "25 个地块切片必须写入 25 个独立 slot/path,不能按重复的 tile_type 互相覆盖" + ); + } } diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs index db4a3353..1d69d4c3 100644 --- a/server-rs/crates/api-server/src/modules/jump_hop.rs +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -1,15 +1,16 @@ use axum::{ - Router, middleware, + middleware, routing::{delete, get, post}, + Router, }; use crate::{ auth::{require_bearer_auth, require_runtime_principal_auth}, jump_hop::{ 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, - jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, - restart_jump_hop_run, start_jump_hop_run, + get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work, + get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, + publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, }, state::AppState, }; @@ -62,6 +63,13 @@ pub fn router(state: AppState) -> Router { "/api/runtime/jump-hop/works/{profile_id}", get(get_jump_hop_runtime_work), ) + .route( + "/api/runtime/jump-hop/works/{profile_id}/leaderboard", + get(get_jump_hop_leaderboard).route_layer(middleware::from_fn_with_state( + state.clone(), + require_runtime_principal_auth, + )), + ) .route( "/api/runtime/jump-hop/runs", post(start_jump_hop_run).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs index e7f835bf..71a990d5 100644 --- a/server-rs/crates/module-jump-hop/src/application.rs +++ b/server-rs/crates/module-jump-hop/src/application.rs @@ -5,61 +5,18 @@ use crate::{ JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType, }; +const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0; +const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.008; + pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath { let config = difficulty_config(difficulty); - let mut rng = DeterministicRng::new(seed, difficulty.as_str()); - let platform_count = rng.range_u32(config.min_platforms, config.max_platforms) as usize; - let mut platforms = Vec::with_capacity(platform_count); - let mut x = 0.0f32; - let mut y = 0.0f32; - - for index in 0..platform_count { - let tile_type = if index == 0 { - JumpHopTileType::Start - } else if index + 1 == platform_count { - JumpHopTileType::Finish - } else if index % 7 == 0 { - JumpHopTileType::Bonus - } else if index % 5 == 0 { - JumpHopTileType::Target - } else if index % 4 == 0 { - JumpHopTileType::Accent - } else { - JumpHopTileType::Normal - }; - let width = rng.range_f32(config.min_width, config.max_width); - let height = width * rng.range_f32(0.86, 1.04); - let landing_radius = width * config.landing_radius_factor; - let perfect_radius = landing_radius * config.perfect_radius_factor; - - platforms.push(JumpHopPlatform { - platform_id: format!("jump-hop-platform-{index:03}"), - tile_type, - x, - y, - width, - height, - landing_radius, - perfect_radius, - score_value: if tile_type == JumpHopTileType::Bonus { - 180 - } else { - 100 - }, - }); - - if index + 1 < platform_count { - let distance = rng.range_f32(config.min_gap, config.max_gap); - let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 }; - x += distance * 0.62 * direction; - y += distance; - } - } + let platform_count = 8usize; + let platforms = build_platforms_until(seed, difficulty, platform_count); JumpHopPath { seed: seed.trim().to_string(), difficulty, - finish_index: platform_count.saturating_sub(1) as u32, + finish_index: u32::MAX, platforms, camera_preset: "portrait-isometric-9x16".to_string(), scoring: JumpHopScoring { @@ -85,6 +42,7 @@ pub fn start_run( if path.platforms.is_empty() { return Err(JumpHopError::EmptyPath); } + let path = normalize_jump_hop_path_platform_size(path); Ok(JumpHopRunSnapshot { run_id, @@ -103,7 +61,9 @@ pub fn start_run( pub fn apply_jump( run: &JumpHopRunSnapshot, - charge_ms: u32, + drag_distance: f32, + drag_vector_x: Option, + drag_vector_y: Option, jumped_at_ms: u64, ) -> Result { if run.status != JumpHopRunStatus::Playing { @@ -111,46 +71,42 @@ pub fn apply_jump( } let current_index = run.current_platform_index as usize; let next_index = current_index + 1; + let path = extend_jump_hop_path(run.path.clone(), next_index + 3); let current = run .path .platforms .get(current_index) .ok_or(JumpHopError::EmptyPath)?; - let target = run - .path + let target = path .platforms .get(next_index) .ok_or(JumpHopError::NoNextPlatform)?; - let capped_charge = charge_ms.min(run.path.scoring.max_charge_ms); - let jump_distance = capped_charge as f32 * run.path.scoring.charge_to_distance_ratio; + let capped_drag_distance = drag_distance.clamp(0.0, run.path.scoring.max_charge_ms as f32); + let jump_distance = capped_drag_distance * run.path.scoring.charge_to_distance_ratio; let vector_x = target.x - current.x; let vector_y = target.y - current.y; let target_distance = vector_x.hypot(vector_y).max(0.0001); - let unit_x = vector_x / target_distance; - let unit_y = vector_y / target_distance; + let (unit_x, unit_y) = normalize_jump_direction( + drag_vector_x, + drag_vector_y, + vector_x / target_distance, + vector_y / target_distance, + ); let landed_x = current.x + unit_x * jump_distance; let landed_y = current.y + unit_y * jump_distance; let landing_error = (landed_x - target.x).hypot(landed_y - target.y); + let target_landing_radius = target.landing_radius; let mut next = run.clone(); - let result = if landing_error <= target.perfect_radius { - if next_index as u32 == run.path.finish_index { - JumpHopJumpResultKind::Finish - } else { - JumpHopJumpResultKind::Perfect - } - } else if landing_error <= target.landing_radius { - if next_index as u32 == run.path.finish_index { - JumpHopJumpResultKind::Finish - } else { - JumpHopJumpResultKind::Hit - } + next.path = path; + let result = if landing_error <= target_landing_radius { + JumpHopJumpResultKind::Hit } else { JumpHopJumpResultKind::Miss }; next.last_jump = Some(JumpHopLastJump { - charge_ms: capped_charge, + charge_ms: capped_drag_distance.round() as u32, jump_distance, target_platform_index: next_index as u32, landed_x, @@ -166,23 +122,8 @@ pub fn apply_jump( } next.current_platform_index = next_index as u32; - next.combo = next.combo.saturating_add(1); - next.score = next.score.saturating_add(target.score_value); - if matches!( - result, - JumpHopJumpResultKind::Perfect | JumpHopJumpResultKind::Finish - ) { - next.score = next - .score - .saturating_add(run.path.scoring.perfect_bonus) - .saturating_add(next.combo.saturating_mul(run.path.scoring.hit_bonus)); - } else { - next.score = next.score.saturating_add(run.path.scoring.hit_bonus); - } - if result == JumpHopJumpResultKind::Finish { - next.status = JumpHopRunStatus::Cleared; - next.finished_at_ms = Some(jumped_at_ms); - } + next.combo = 0; + next.score = next.current_platform_index; Ok(next) } @@ -201,9 +142,31 @@ pub fn restart_run( ) } +fn normalize_jump_hop_path_platform_size(mut path: JumpHopPath) -> JumpHopPath { + let should_scale_legacy_path = path + .platforms + .iter() + .any(|platform| platform.width < 1.2 && platform.landing_radius < 0.75); + if !should_scale_legacy_path { + if (path.scoring.charge_to_distance_ratio - JUMP_HOP_CHARGE_TO_DISTANCE_RATIO).abs() + > f32::EPSILON + { + path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO; + } + return path; + } + + for platform in &mut path.platforms { + platform.width *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER; + platform.height *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER; + platform.landing_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER; + platform.perfect_radius *= JUMP_HOP_PLATFORM_SIZE_MULTIPLIER; + } + path.scoring.charge_to_distance_ratio = JUMP_HOP_CHARGE_TO_DISTANCE_RATIO; + path +} + struct DifficultyConfig { - min_platforms: u32, - max_platforms: u32, min_gap: f32, max_gap: f32, min_width: f32, @@ -214,54 +177,143 @@ struct DifficultyConfig { max_charge_ms: u32, } +fn build_platforms_until( + seed: &str, + difficulty: JumpHopDifficulty, + required_count: usize, +) -> Vec { + let config = difficulty_config(difficulty); + let mut platforms = Vec::with_capacity(required_count); + let mut x = 0.0f32; + let mut y = 0.0f32; + + for index in 0..required_count { + platforms.push(build_platform(seed, difficulty, index, x, y, &config)); + if index + 1 < required_count { + let mut rng = DeterministicRng::new(seed, &format!("{}:{index}", difficulty.as_str())); + let distance = rng.range_f32(config.min_gap, config.max_gap); + let lane = rng.range_f32(0.42, 0.86); + let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 }; + x += distance * lane * direction; + y += distance; + } + } + + platforms +} + +fn build_platform( + seed: &str, + difficulty: JumpHopDifficulty, + index: usize, + x: f32, + y: f32, + config: &DifficultyConfig, +) -> JumpHopPlatform { + let mut rng = DeterministicRng::new(seed, &format!("platform:{}:{index}", difficulty.as_str())); + let tile_type = if index == 0 { + JumpHopTileType::Start + } else if index % 11 == 0 { + JumpHopTileType::Bonus + } else if index % 7 == 0 { + JumpHopTileType::Accent + } else if index % 3 == 0 { + JumpHopTileType::Target + } else { + JumpHopTileType::Normal + }; + let width = rng.range_f32(config.min_width, config.max_width); + let height = width * rng.range_f32(0.88, 1.06); + let landing_radius = width * config.landing_radius_factor; + + JumpHopPlatform { + platform_id: format!("jump-hop-platform-{index:05}"), + tile_type, + x, + y, + width: width * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER, + height: height * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER, + landing_radius: landing_radius * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER, + perfect_radius: landing_radius + * config.perfect_radius_factor + * JUMP_HOP_PLATFORM_SIZE_MULTIPLIER, + score_value: 1, + } +} + +fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHopPath { + if path.platforms.len() >= required_count { + return path; + } + path.platforms = build_platforms_until(&path.seed, path.difficulty, required_count); + path.finish_index = u32::MAX; + path +} + +fn normalize_jump_direction( + drag_vector_x: Option, + drag_vector_y: Option, + fallback_x: f32, + fallback_y: f32, +) -> (f32, f32) { + let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else { + return (fallback_x, fallback_y); + }; + let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else { + return (fallback_x, fallback_y); + }; + // 前端提交的是屏幕拖拽向量:x 轴同向,y 轴向下为正。 + // 真实起跳需要“反向弹出”,同时把屏幕 y 翻回世界坐标的向上为正。 + let jump_x = -drag_x; + let jump_y = drag_y; + let length = jump_x.hypot(jump_y); + if length < 0.0001 { + (fallback_x, fallback_y) + } else { + (jump_x / length, jump_y / length) + } +} + fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { match difficulty { JumpHopDifficulty::Easy => DifficultyConfig { - min_platforms: 12, - max_platforms: 14, min_gap: 1.0, max_gap: 1.45, min_width: 0.9, max_width: 1.08, landing_radius_factor: 0.62, perfect_radius_factor: 0.32, - charge_to_distance_ratio: 0.004, + charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 700, }, JumpHopDifficulty::Standard => DifficultyConfig { - min_platforms: 16, - max_platforms: 18, min_gap: 1.22, max_gap: 1.78, min_width: 0.82, max_width: 1.0, landing_radius_factor: 0.54, perfect_radius_factor: 0.26, - charge_to_distance_ratio: 0.004, + charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 780, }, JumpHopDifficulty::Advanced => DifficultyConfig { - min_platforms: 20, - max_platforms: 24, min_gap: 1.45, max_gap: 2.05, min_width: 0.72, max_width: 0.94, landing_radius_factor: 0.48, perfect_radius_factor: 0.22, - charge_to_distance_ratio: 0.004, + charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 860, }, JumpHopDifficulty::Challenge => DifficultyConfig { - min_platforms: 26, - max_platforms: 32, min_gap: 1.7, max_gap: 2.35, min_width: 0.66, max_width: 0.88, landing_radius_factor: 0.42, perfect_radius_factor: 0.18, - charge_to_distance_ratio: 0.004, + charge_to_distance_ratio: JUMP_HOP_CHARGE_TO_DISTANCE_RATIO, max_charge_ms: 950, }, } @@ -289,13 +341,6 @@ impl DeterministicRng { (self.state >> 32) as u32 } - fn range_u32(&mut self, min: u32, max: u32) -> u32 { - if max <= min { - return min; - } - min + self.next_u32() % (max - min + 1) - } - fn range_f32(&mut self, min: f32, max: f32) -> f32 { if max <= min { return min; @@ -319,14 +364,67 @@ mod tests { let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge); assert_eq!(first, second); - assert!((16..=18).contains(&first.platforms.len())); - assert!((26..=32).contains(&challenge.platforms.len())); + assert_eq!(first.platforms.len(), 8); + assert_eq!(challenge.platforms.len(), 8); assert_eq!(first.platforms.first().unwrap().tile_type.as_str(), "start"); - assert_eq!(first.platforms.last().unwrap().tile_type.as_str(), "finish"); + assert_eq!(first.finish_index, u32::MAX); } #[test] - fn jump_resolution_distinguishes_perfect_hit_and_miss() { + fn difficulty_charge_to_distance_ratio_is_doubled() { + let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy); + let standard = generate_jump_hop_path("seed-ratio-standard", JumpHopDifficulty::Standard); + let advanced = generate_jump_hop_path("seed-ratio-advanced", JumpHopDifficulty::Advanced); + let challenge = generate_jump_hop_path("seed-ratio-challenge", JumpHopDifficulty::Challenge); + + assert_eq!(easy.scoring.charge_to_distance_ratio, 0.008); + assert_eq!(standard.scoring.charge_to_distance_ratio, 0.008); + assert_eq!(advanced.scoring.charge_to_distance_ratio, 0.008); + assert_eq!(challenge.scoring.charge_to_distance_ratio, 0.008); + } + + #[test] + fn generated_platforms_use_double_size_and_landing_radius() { + let path = generate_jump_hop_path("seed-size", JumpHopDifficulty::Standard); + let first_platform = path.platforms.first().expect("platform should exist"); + + assert!(first_platform.width >= 1.64); + assert!(first_platform.width <= 2.0); + assert!(first_platform.height >= 1.44); + assert!(first_platform.height <= 2.12); + assert!(first_platform.landing_radius >= 0.88); + assert!(first_platform.landing_radius <= 1.08); + } + + #[test] + fn start_run_normalizes_legacy_single_size_platforms() { + let mut path = generate_jump_hop_path("seed-legacy", JumpHopDifficulty::Standard); + for platform in &mut path.platforms { + platform.width /= 2.0; + platform.height /= 2.0; + platform.landing_radius /= 2.0; + platform.perfect_radius /= 2.0; + } + let legacy_width = path.platforms[0].width; + let legacy_landing_radius = path.platforms[0].landing_radius; + + let run = start_run( + "run-legacy".to_string(), + "user-legacy".to_string(), + "profile-legacy".to_string(), + path, + 100, + ) + .expect("run should start"); + + assert!((run.path.platforms[0].width - legacy_width * 2.0).abs() < 0.0001); + assert!( + (run.path.platforms[0].landing_radius - legacy_landing_radius * 2.0).abs() < 0.0001 + ); + } + + #[test] + fn jump_resolution_distinguishes_hit_and_miss() { let path = generate_jump_hop_path("seed-b", JumpHopDifficulty::Easy); let run = start_run( "run-1".to_string(), @@ -338,25 +436,25 @@ mod tests { .expect("run should start"); let target = &run.path.platforms[1]; let distance = target.x.hypot(target.y); - let perfect_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32; - - let perfect = apply_jump(&run, perfect_charge, 200).expect("jump should resolve"); - assert_eq!( - perfect.last_jump.as_ref().unwrap().result, - JumpHopJumpResultKind::Perfect - ); - assert_eq!(perfect.status, JumpHopRunStatus::Playing); - assert_eq!(perfect.current_platform_index, 1); + let target_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32; let hit = - apply_jump(&run, perfect_charge.saturating_add(80), 200).expect("jump should resolve"); + apply_jump(&run, target_charge as f32, None, None, 200).expect("jump should resolve"); assert_eq!( hit.last_jump.as_ref().unwrap().result, JumpHopJumpResultKind::Hit ); + assert_eq!(hit.status, JumpHopRunStatus::Playing); + assert_eq!(hit.current_platform_index, 1); - let miss = - apply_jump(&run, perfect_charge.saturating_add(900), 200).expect("jump should resolve"); + let miss = apply_jump( + &run, + target_charge.saturating_add(900) as f32, + None, + None, + 200, + ) + .expect("jump should resolve"); assert_eq!(miss.status, JumpHopRunStatus::Failed); assert_eq!( miss.last_jump.as_ref().unwrap().result, @@ -364,6 +462,39 @@ mod tests { ); } + #[test] + fn jump_resolution_uses_screen_drag_y_axis_for_forward_jump_direction() { + let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy); + let run = start_run( + "run-screen-axis".to_string(), + "user-screen-axis".to_string(), + "profile-screen-axis".to_string(), + path, + 100, + ) + .expect("run should start"); + let current = &run.path.platforms[0]; + let target = &run.path.platforms[1]; + let target_distance = (target.x - current.x).hypot(target.y - current.y); + let charge = (target_distance / run.path.scoring.charge_to_distance_ratio).round() as u32; + + let result = apply_jump( + &run, + charge as f32, + Some(-(target.x - current.x)), + Some(target.y - current.y), + 200, + ) + .expect("jump should resolve"); + + assert_eq!(result.status, JumpHopRunStatus::Playing); + assert_eq!( + result.last_jump.as_ref().unwrap().result, + JumpHopJumpResultKind::Hit + ); + assert_eq!(result.current_platform_index, 1); + } + #[test] fn restart_returns_to_first_platform_and_playing_state() { let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy); @@ -392,4 +523,32 @@ mod tests { assert_eq!(restarted.started_at_ms, 300); assert!(restarted.finished_at_ms.is_none()); } + + #[test] + fn successful_jump_extends_infinite_path_buffer_and_counts_jumps() { + let path = generate_jump_hop_path("seed-d", JumpHopDifficulty::Easy); + let mut run = start_run( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + path, + 100, + ) + .expect("run should start"); + + for step in 0..9 { + let current = &run.path.platforms[run.current_platform_index as usize]; + let target = &run.path.platforms[run.current_platform_index as usize + 1]; + let distance = (target.x - current.x).hypot(target.y - current.y); + let charge = (distance / run.path.scoring.charge_to_distance_ratio).round() as u32; + run = apply_jump(&run, charge as f32, None, None, 200 + step) + .expect("jump should resolve"); + } + + assert_eq!(run.status, JumpHopRunStatus::Playing); + assert_eq!(run.current_platform_index, 9); + assert_eq!(run.score, 9); + assert!(run.path.platforms.len() >= 12); + assert!(run.finished_at_ms.is_none()); + } } diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 049fc150..d21553a1 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -404,9 +404,9 @@ pub fn default_creation_entry_type_snapshots( build_default_creation_entry_type_snapshot( "jump-hop", "跳一跳", - "俯视角跳跃闯关", + "主题驱动平台跳跃", "可创建", - "/creation-type-references/puzzle.webp", + "/creation-type-references/jump-hop.webp", true, true, 45, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 7231d9b3..d9b96adb 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -446,6 +446,29 @@ mod tests { assert_eq!(wooden_fish.image_src, "/wooden-fish/default-hit-object.png"); } + #[test] + fn default_creation_entry_types_include_jump_hop_theme_only_entry() { + let configs = default_creation_entry_type_snapshots(1); + let jump_hop = configs + .iter() + .find(|item| item.id == "jump-hop") + .expect("jump-hop creation entry should be seeded"); + + assert_eq!(jump_hop.title, "跳一跳"); + assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃"); + assert!(jump_hop.visible); + assert!(jump_hop.open); + assert_eq!(jump_hop.badge, "可创建"); + assert_eq!(jump_hop.sort_order, 45); + assert_eq!( + jump_hop.image_src, + "/creation-type-references/jump-hop.webp" + ); + assert_eq!(jump_hop.category_id, "recommended"); + assert_eq!(jump_hop.category_label, "热门推荐"); + assert_eq!(jump_hop.category_sort_order, 20); + } + #[test] fn normalized_clamps_music_volume_into_valid_range() { let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light); diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs index 92810d15..d95b4675 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs @@ -2,13 +2,80 @@ use super::color::{ 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, compute_generated_asset_sheet_green_screen_score, + compute_generated_asset_sheet_key_color_score, compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel, 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( 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 { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); @@ -16,6 +83,7 @@ pub fn apply_generated_asset_sheet_green_screen_alpha( image.as_mut(), width as usize, height as usize, + options, ); image::DynamicImage::ImageRgba8(image) } @@ -24,13 +92,14 @@ fn remove_generated_asset_sheet_green_screen_background( pixels: &mut [u8], width: usize, height: usize, + options: GeneratedAssetSheetAlphaOptions, ) -> bool { let pixel_count = width.saturating_mul(height); if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { 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 background_hints = vec![0.0f32; 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 blue = pixels[offset + 2]; let alpha = pixels[offset + 3]; - let green_score = - compute_generated_asset_sheet_green_screen_score([red, green, blue, alpha]); - let white_score = - compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]); + let key_score = + compute_generated_asset_sheet_key_score([red, green, blue, alpha], options.key_color); + let white_score = if options.remove_near_white_background { + compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]) + } else { + 0.0 + }; let transparency_hint = 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; - 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 = @@ -62,10 +134,10 @@ fn remove_generated_asset_sheet_green_screen_background( } let alpha = pixels[pixel_index * 4 + 3]; 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 - && green_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) - || white_scores[pixel_index] > 0.32; + && key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) + || (options.remove_near_white_background && white_scores[pixel_index] > 0.32); if !strong_candidate { return; } @@ -113,26 +185,34 @@ fn remove_generated_asset_sheet_green_screen_background( } let next_offset = next_pixel_index * 4; 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 hint = background_hints[next_pixel_index]; let reachable_soft_edge = hint > 0.08 && alpha < 224 - && (green_score > 0.04 || white_score > 0.08 || alpha < 180); - let green_background = green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 && green_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); - if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge { + && (key_score > 0.04 + || (options.remove_near_white_background && white_score > 0.08) + || alpha < 180); + 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; queue.push(next_pixel_index); } } } - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 - && green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - { - background_mask[pixel_index] = 1; + if options.remove_disconnected_hard_key_background { + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 + && key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + { + background_mask[pixel_index] = 1; + } } } @@ -153,10 +233,14 @@ fn remove_generated_asset_sheet_green_screen_background( pixels[offset + 2], pixels[offset + 3], ]; - let green_score = green_scores[pixel_index]; + let key_score = key_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; } if !touches_generated_asset_sheet_background_mask( @@ -188,12 +272,12 @@ fn remove_generated_asset_sheet_green_screen_background( continue; } 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 hint = background_hints[pixel_index]; let soft_matte_candidate = alpha < 224 - || white_score > 0.10 - || green_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; + || (options.remove_near_white_background && white_score > 0.10) + || key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; if hint < GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate { continue; } @@ -278,9 +362,9 @@ fn remove_generated_asset_sheet_green_screen_background( continue; } - let green_score = green_scores[pixel_index]; + let key_score = key_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 } else { 0.0 @@ -301,30 +385,47 @@ fn remove_generated_asset_sheet_green_screen_background( let mut red = pixels[offset] as f32; let mut green = pixels[offset + 1] 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 { red = lerp_generated_asset_sheet_channel(red, sample_red 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); - 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); } - if white_score > 0.1 { + if options.remove_near_white_background && white_score > 0.1 { red = red.min(sample_red as f32 + 26.0); green = green.min(sample_green 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 { - 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) .round() .max(red.max(blue)); 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); if spread < 20.0 { 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); } } + 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 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 { next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; if next_alpha < 10 { @@ -364,6 +481,66 @@ fn remove_generated_asset_sheet_green_screen_background( 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( pixels: &[u8], width: usize, diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs index 833082ed..ecd5e2c8 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs @@ -139,6 +139,24 @@ pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) - .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 { if pixel[3] == 0 { return 1.0; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs index 1abfdff2..fa55105e 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/mod.rs @@ -5,7 +5,10 @@ pub mod persist; pub mod prompt; 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 persist::{ GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetUpload, @@ -14,5 +17,6 @@ pub use persist::{ pub use prompt::{GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt}; pub use sheet::{ 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, }; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index 8d2a6d6a..740f4f43 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -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::{ - 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_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination, 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 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( 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 { let mut image = image.to_rgba8(); 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(|| { GeneratedAssetSheetCellBounds { x0: 0, @@ -359,6 +378,7 @@ fn remove_generated_asset_sheet_view_edge_matte( pixels: &mut [u8], width: usize, height: usize, + options: GeneratedAssetSheetAlphaOptions, ) -> bool { let pixel_count = width.saturating_mul(height); 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 + 3], ]; - if !is_generated_asset_sheet_view_background_pixel(pixel) { + if !is_generated_asset_sheet_view_background_pixel_with_options(pixel, options) { continue; } background_mask[pixel_index] = 1; @@ -434,7 +454,7 @@ fn remove_generated_asset_sheet_view_edge_matte( pixels[offset + 2], 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; } background_mask[next_pixel_index] = 1; @@ -452,12 +472,15 @@ fn remove_generated_asset_sheet_view_edge_matte( continue; } let offset = pixel_index * 4; - if !is_generated_asset_sheet_view_background_pixel([ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]) { + if !is_generated_asset_sheet_view_background_pixel_with_options( + [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ], + options, + ) { continue; } @@ -526,7 +549,7 @@ fn remove_generated_asset_sheet_view_edge_matte( pixels[offset + 2], 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; } if !touches_generated_asset_sheet_background_mask( @@ -539,7 +562,7 @@ fn remove_generated_asset_sheet_view_edge_matte( 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 + 1] = 0; pixels[offset + 2] = 0; @@ -559,17 +582,61 @@ fn remove_generated_asset_sheet_view_edge_matte( y, &background_mask, &visible_mask, + options, ) .unwrap_or(( pixels[offset], pixels[offset + 1], pixels[offset + 2], )); - let next_red = replacement.0.max(pixels[offset]); - let next_blue = replacement.2.max(pixels[offset + 2]); - let next_green = replacement - .1 - .min(next_red.max(next_blue).saturating_add(12)); + let (next_red, next_green, next_blue) = if options.key_color.is_green_screen() { + let next_red = replacement.0.max(pixels[offset]); + let next_blue = replacement.2.max(pixels[offset + 2]); + let next_green = replacement + .1 + .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] || next_green != pixels[offset + 1] || next_blue != pixels[offset + 2] @@ -605,6 +672,7 @@ fn collect_generated_asset_sheet_visible_neighbor_color( y: usize, background_mask: &[u8], visible_mask: &[u8], + options: GeneratedAssetSheetAlphaOptions, ) -> Option<(u8, u8, u8)> { let mut total_weight = 0.0f32; let mut total_red = 0.0f32; @@ -638,8 +706,9 @@ fn collect_generated_asset_sheet_visible_neighbor_color( pixels[next_offset + 2], next_alpha, ]; - if is_generated_asset_sheet_green_contaminated_edge_pixel(pixel) - || is_generated_asset_sheet_soft_edge_pixel(pixel) + if is_generated_asset_sheet_key_contaminated_edge_pixel(pixel, options) + || (options.key_color.is_green_screen() + && is_generated_asset_sheet_soft_edge_pixel(pixel)) { continue; } @@ -670,3 +739,73 @@ fn collect_generated_asset_sheet_visible_neighbor_color( (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 +} diff --git a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs index df530028..40a0c8f0 100644 --- a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs +++ b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs @@ -2,9 +2,11 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; use platform_image::DownloadedImage; use platform_image::generated_asset_sheets::{ - GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, - GeneratedAssetSheetPromptInput, apply_generated_asset_sheet_green_screen_alpha, + GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetPersistInput, + 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, + crop_generated_asset_sheet_view_edge_matte_with_options, prepare_generated_asset_sheet_put_request, slice_generated_asset_sheet, 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); } +#[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] fn generated_asset_sheet_view_edge_matte_trims_transparent_border() { let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0])); diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index cd2c0a51..cbad6f68 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -44,7 +44,6 @@ pub enum JumpHopTileType { #[serde(rename_all = "kebab-case")] pub enum JumpHopActionType { CompileDraft, - RegenerateCharacter, RegenerateTiles, UpdateWorkMeta, UpdateDifficulty, @@ -71,12 +70,20 @@ pub enum JumpHopJumpResult { #[serde(rename_all = "camelCase")] pub struct JumpHopWorkspaceCreateRequest { pub template_id: String, + pub theme_text: String, + #[serde(default)] pub work_title: String, + #[serde(default)] pub work_description: String, + #[serde(default)] pub theme_tags: Vec, + #[serde(default = "default_jump_hop_difficulty")] pub difficulty: JumpHopDifficulty, + #[serde(default = "default_jump_hop_style_preset")] pub style_preset: JumpHopStylePreset, + #[serde(default)] pub character_prompt: String, + #[serde(default)] pub tile_prompt: String, #[serde(default)] pub end_mood_prompt: Option, @@ -89,6 +96,8 @@ pub struct JumpHopActionRequest { #[serde(default)] pub profile_id: Option, #[serde(default)] + pub theme_text: Option, + #[serde(default)] pub work_title: Option, #[serde(default)] pub work_description: Option, @@ -112,6 +121,8 @@ pub struct JumpHopActionRequest { pub tile_assets: Option>, #[serde(default)] pub cover_composite: Option, + #[serde(default)] + pub back_button_asset: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -127,14 +138,30 @@ pub struct JumpHopCharacterAsset { pub height: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopDefaultCharacter { + pub character_id: String, + pub display_name: String, + pub model_kind: String, + pub body_color: String, + pub accent_color: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct JumpHopTileAsset { pub tile_type: JumpHopTileType, + #[serde(default)] + pub tile_id: Option, pub image_src: String, pub image_object_key: String, pub asset_object_id: String, pub source_atlas_cell: String, + #[serde(default)] + pub atlas_row: Option, + #[serde(default)] + pub atlas_col: Option, pub visual_width: u32, pub visual_height: u32, pub top_surface_radius: f32, @@ -193,11 +220,14 @@ pub struct JumpHopDraftResponse { pub template_name: String, #[serde(default)] pub profile_id: Option, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, pub difficulty: JumpHopDifficulty, pub style_preset: JumpHopStylePreset, + #[serde(default)] + pub default_character: Option, pub character_prompt: String, pub tile_prompt: String, #[serde(default)] @@ -212,6 +242,8 @@ pub struct JumpHopDraftResponse { pub path: Option, #[serde(default)] pub cover_composite: Option, + #[serde(default)] + pub back_button_asset: Option, pub generation_status: JumpHopGenerationStatus, } @@ -251,6 +283,7 @@ pub struct JumpHopWorkSummaryResponse { pub owner_user_id: String, #[serde(default)] pub source_session_id: Option, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -274,9 +307,13 @@ pub struct JumpHopWorkProfileResponse { pub summary: JumpHopWorkSummaryResponse, pub draft: JumpHopDraftResponse, pub path: JumpHopPath, + #[serde(default)] + pub default_character: Option, pub character_asset: JumpHopCharacterAsset, pub tile_atlas_asset: JumpHopCharacterAsset, pub tile_assets: Vec, + #[serde(default)] + pub back_button_asset: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -305,6 +342,7 @@ pub struct JumpHopGalleryCardResponse { pub profile_id: String, pub owner_user_id: String, pub author_display_name: String, + pub theme_text: String, pub work_title: String, pub work_description: String, #[serde(default)] @@ -343,6 +381,8 @@ pub struct JumpHopRuntimeRunSnapshotResponse { pub owner_user_id: String, pub status: JumpHopRunStatus, pub current_platform_index: u32, + pub successful_jump_count: u32, + pub duration_ms: u64, pub score: u32, pub combo: u32, pub path: JumpHopPath, @@ -363,15 +403,29 @@ pub struct JumpHopRunResponse { #[serde(rename_all = "camelCase")] pub struct JumpHopStartRunRequest { pub profile_id: String, + #[serde(default)] + pub runtime_mode: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct JumpHopJumpRequest { - pub charge_ms: u32, + pub drag_distance: f32, + #[serde(default)] + pub drag_vector_x: Option, + #[serde(default)] + pub drag_vector_y: Option, pub client_event_id: String, } +fn default_jump_hop_difficulty() -> JumpHopDifficulty { + JumpHopDifficulty::Standard +} + +fn default_jump_hop_style_preset() -> JumpHopStylePreset { + JumpHopStylePreset::MinimalBlocks +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct JumpHopRestartRunRequest { @@ -384,6 +438,25 @@ pub struct JumpHopJumpResponse { pub run: JumpHopRuntimeRunSnapshotResponse, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopLeaderboardEntry { + pub rank: u32, + pub player_id: String, + pub successful_jump_count: u32, + pub duration_ms: u64, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopLeaderboardResponse { + pub profile_id: String, + pub items: Vec, + #[serde(default)] + pub viewer_best: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -393,6 +466,7 @@ mod tests { fn jump_hop_workspace_request_uses_camel_case() { let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest { template_id: "jump-hop".to_string(), + theme_text: "跳一跳".to_string(), work_title: "跳一跳".to_string(), work_description: "俯视角跳跃闯关".to_string(), theme_tags: vec!["休闲".to_string()], diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 98381c09..df224a68 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -1,15 +1,15 @@ use super::*; use crate::mapper::{ map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row, - map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result, - map_jump_hop_works_procedure_result, + map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result, + map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result, }; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse, - JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, - JumpHopTileType, JumpHopWorkProfileResponse, + JumpHopJumpRequest, JumpHopLeaderboardResponse, JumpHopRestartRunRequest, + JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, + JumpHopStylePreset, JumpHopWorkProfileResponse, }; use shared_kernel::build_prefixed_uuid_id; @@ -253,7 +253,7 @@ impl SpacetimeClient { let work = self .get_jump_hop_work_profile(profile_id, String::new()) .await?; - validate_jump_hop_runtime_ready(&work)?; + validate_jump_hop_runtime_ready(&work, "published")?; Ok(work) } @@ -262,17 +262,24 @@ impl SpacetimeClient { payload: JumpHopStartRunRequest, owner_user_id: String, ) -> Result { + let runtime_mode = normalize_jump_hop_runtime_mode(payload.runtime_mode.as_deref()); 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 - .get_jump_hop_work_profile(profile_id.clone(), String::new()) + .get_jump_hop_work_profile(profile_id.clone(), work_owner_user_id) .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 procedure_input = JumpHopRunStartInput { client_event_id: format!("{run_id}:start"), run_id, owner_user_id, profile_id, + runtime_mode: runtime_mode.to_string(), started_at_ms: current_unix_micros().div_euclid(1000), }; self.start_jump_hop_run_with_input(procedure_input).await @@ -327,7 +334,9 @@ impl SpacetimeClient { let procedure_input = JumpHopRunJumpInput { run_id, owner_user_id, - charge_ms: payload.charge_ms, + drag_distance: payload.drag_distance, + drag_vector_x: payload.drag_vector_x, + drag_vector_y: payload.drag_vector_y, client_event_id: payload.client_event_id, jumped_at_ms: current_unix_micros().div_euclid(1000), }; @@ -420,13 +429,39 @@ impl SpacetimeClient { self.get_jump_hop_work_profile(card.profile_id, String::new()) .await } + + pub async fn get_jump_hop_leaderboard( + &self, + profile_id: String, + viewer_player_id: String, + ) -> Result { + let procedure_input = JumpHopLeaderboardGetInput { + profile_id, + viewer_player_id, + limit: 50, + }; + + self.call_after_connect("get_jump_hop_leaderboard", move |connection, sender| { + connection.procedures().get_jump_hop_leaderboard_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_leaderboard_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } } fn validate_jump_hop_runtime_ready( work: &JumpHopWorkProfileResponse, + runtime_mode: &str, ) -> Result<(), SpacetimeClientError> { let status = work.summary.publication_status.trim().to_ascii_lowercase(); - if status != "published" { + if runtime_mode == "published" && status != "published" { return Err(SpacetimeClientError::validation_failed( "jump-hop runtime 只能启动已发布作品", )); @@ -436,11 +471,11 @@ fn validate_jump_hop_runtime_ready( "jump-hop runtime 需要 ready 状态作品", )); } - validate_jump_hop_character_asset_ready(&work.character_asset, "character_asset")?; - validate_jump_hop_character_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; - if work.tile_assets.is_empty() { + validate_jump_hop_default_character_ready(work)?; + validate_jump_hop_tile_atlas_asset_ready(&work.tile_atlas_asset, "tile_atlas_asset")?; + if work.tile_assets.len() < 25 { return Err(SpacetimeClientError::validation_failed( - "jump-hop runtime 缺少地块资产", + "jump-hop runtime 需要 25 个地块资产", )); } for (index, asset) in work.tile_assets.iter().enumerate() { @@ -461,7 +496,34 @@ fn validate_jump_hop_runtime_ready( Ok(()) } -fn validate_jump_hop_character_asset_ready( +fn normalize_jump_hop_runtime_mode(value: Option<&str>) -> &'static str { + if value + .map(|value| value.trim().eq_ignore_ascii_case("draft")) + .unwrap_or(false) + { + "draft" + } else { + "published" + } +} + +fn validate_jump_hop_default_character_ready( + work: &JumpHopWorkProfileResponse, +) -> Result<(), SpacetimeClientError> { + let Some(default_character) = work.default_character.as_ref() else { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 缺少内置默认角色配置", + )); + }; + if default_character.model_kind.trim() != "builtin-three" { + return Err(SpacetimeClientError::validation_failed( + "jump-hop runtime 默认角色必须使用 builtin-three", + )); + } + Ok(()) +} + +fn validate_jump_hop_tile_atlas_asset_ready( asset: &JumpHopCharacterAsset, field: &str, ) -> Result<(), SpacetimeClientError> { @@ -499,7 +561,6 @@ enum JumpHopActionProcedure { #[derive(Clone, Copy)] enum JumpHopDraftMergeScope { CompileDraft, - RegenerateCharacter, RegenerateTiles, UpdateWorkMeta, UpdateDifficulty, @@ -508,7 +569,6 @@ enum JumpHopDraftMergeScope { #[derive(Clone, Copy)] enum JumpHopAssetRefresh { Preserve, - Character, Tiles, } @@ -520,12 +580,18 @@ fn build_jump_hop_action_plan( ) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> { let scope = match payload.action_type { JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft, - JumpHopActionType::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter, JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles, JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta, JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty, }; - let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?; + let mut base_draft = current.draft.clone(); + if matches!(payload.action_type, JumpHopActionType::RegenerateTiles) { + if let Some(draft) = base_draft.as_mut() { + draft.tile_atlas_asset = None; + draft.tile_assets.clear(); + } + } + let mut draft = merge_action_into_draft(base_draft, payload, scope)?; let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?; draft.profile_id = Some(profile_id.clone()); @@ -538,16 +604,6 @@ fn build_jump_hop_action_plan( JumpHopAssetRefresh::Preserve, now_micros, )?), - JumpHopActionType::RegenerateCharacter => { - JumpHopActionProcedure::Compile(build_compile_input( - current, - owner_user_id, - &profile_id, - &mut draft, - JumpHopAssetRefresh::Character, - now_micros, - )?) - } JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input( current, owner_user_id, @@ -587,6 +643,13 @@ fn merge_action_into_draft( { draft.work_title = value.trim().to_string(); } + if let Some(value) = payload + .theme_text + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.theme_text = value.trim().chars().take(60).collect(); + } if let Some(value) = payload.work_description.as_ref() { draft.work_description = value.trim().to_string(); } @@ -614,10 +677,7 @@ fn merge_action_into_draft( .filter(|value| !value.is_empty()); } } - if matches!( - scope, - JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter - ) { + if matches!(scope, JumpHopDraftMergeScope::CompileDraft) { if let Some(value) = payload .character_prompt .as_ref() @@ -646,10 +706,7 @@ fn merge_action_into_draft( { draft.profile_id = Some(profile_id.to_string()); } - if matches!( - scope, - JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter - ) { + if matches!(scope, JumpHopDraftMergeScope::CompileDraft) { if let Some(asset) = payload.character_asset.clone() { draft.character_asset = Some(asset); } @@ -673,6 +730,14 @@ fn merge_action_into_draft( { 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() { return Err(SpacetimeClientError::validation_failed( "jump-hop work_title 不能为空", @@ -689,28 +754,19 @@ fn build_compile_input( refresh: JumpHopAssetRefresh, now_micros: i64, ) -> Result { - let force_character = matches!(refresh, JumpHopAssetRefresh::Character); - let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles); - if force_character { - draft.character_asset = None; - } - if force_tiles { - draft.tile_atlas_asset = None; - draft.tile_assets.clear(); - } - let character_asset = draft.character_asset.clone().ok_or_else(|| { - SpacetimeClientError::validation_failed( - "jump-hop compile-draft 缺少真实角色资产,请先由 api-server 生成并持久化 asset_object", - ) - })?; + let character_asset = draft.character_asset.clone().unwrap_or_else(|| { + build_jump_hop_default_character_asset(profile_id, draft.theme_text.as_str()) + }); + draft.character_asset = Some(character_asset.clone()); + draft.default_character = Some(default_jump_hop_default_character()); let tile_atlas_asset = draft.tile_atlas_asset.clone().ok_or_else(|| { SpacetimeClientError::validation_failed( "jump-hop compile-draft 缺少真实地块图集资产,请先由 api-server 生成并持久化 asset_object", ) })?; - let tile_assets = if draft.tile_assets.is_empty() { + let tile_assets = if draft.tile_assets.len() < 25 { return Err(SpacetimeClientError::validation_failed( - "jump-hop compile-draft 缺少真实地块资产,请先由 api-server 生成并持久化 asset_object", + "jump-hop compile-draft 需要 25 个真实地块资产,请先由 api-server 生成并持久化 asset_object", )); } else { draft.tile_assets.clone() @@ -729,7 +785,7 @@ fn build_compile_input( work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_tags_json: Some(json_string(&draft.theme_tags)?), - theme_text: Some(draft.work_title.clone()), + theme_text: Some(draft.theme_text.clone()), difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()), style_preset: Some(style_to_str(&draft.style_preset).to_string()), character_prompt: Some(draft.character_prompt.clone()), @@ -739,6 +795,11 @@ fn build_compile_input( tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?), tile_assets_json: Some(json_string(&tile_assets)?), cover_composite, + back_button_asset_json: draft + .back_button_asset + .as_ref() + .map(json_string) + .transpose()?, generation_status: Some("ready".to_string()), compiled_at_micros: now_micros, }) @@ -809,26 +870,29 @@ fn default_draft() -> JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, + theme_text: JUMP_HOP_TEMPLATE_NAME.to_string(), work_title: JUMP_HOP_TEMPLATE_NAME.to_string(), work_description: "俯视角跳跃闯关".to_string(), theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()], difficulty: JumpHopDifficulty::Standard, style_preset: JumpHopStylePreset::MinimalBlocks, - character_prompt: "俯视角可爱主角,透明背景".to_string(), - tile_prompt: "等距立体地块图集".to_string(), + default_character: Some(default_jump_hop_default_character()), + character_prompt: "内置默认 3D 角色".to_string(), + tile_prompt: "跳一跳主题的正面30度视角主题物体图集,物体本身作为跳跃落点".to_string(), end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, tile_assets: Vec::new(), path: None, cover_composite: None, + back_button_asset: None, generation_status: JumpHopGenerationStatus::Draft, } } fn build_config_json(draft: &JumpHopDraftResponse) -> Result { serde_json::to_string(&serde_json::json!({ - "themeText": draft.work_title, + "themeText": draft.theme_text, "difficulty": difficulty_to_str(&draft.difficulty), "stylePreset": style_to_str(&draft.style_preset), "characterPrompt": draft.character_prompt, @@ -838,94 +902,6 @@ fn build_config_json(draft: &JumpHopDraftResponse) -> Result, - profile_id: &str, - prompt: &str, - force_new: bool, - now_micros: i64, -) -> JumpHopCharacterAsset { - if !force_new { - if let Some(asset) = existing { - return asset; - } - } - let revision = force_new.then_some(now_micros); - let suffix = asset_revision_suffix(revision); - JumpHopCharacterAsset { - asset_id: format!("{profile_id}-character{suffix}"), - image_src: format!("/generated-jump-hop-assets/{profile_id}/character{suffix}.png"), - image_object_key: format!("generated-jump-hop-assets/{profile_id}/character{suffix}.png"), - asset_object_id: format!("{profile_id}-character{suffix}-object"), - generation_provider: "deterministic-placeholder".to_string(), - prompt: prompt.to_string(), - width: 768, - height: 768, - } -} - -fn ensure_tile_atlas_asset( - existing: Option, - profile_id: &str, - prompt: &str, - force_new: bool, - now_micros: i64, -) -> JumpHopCharacterAsset { - if !force_new { - if let Some(asset) = existing { - return asset; - } - } - let revision = force_new.then_some(now_micros); - let suffix = asset_revision_suffix(revision); - JumpHopCharacterAsset { - asset_id: format!("{profile_id}-tile-atlas{suffix}"), - image_src: format!("/generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"), - image_object_key: format!("generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"), - asset_object_id: format!("{profile_id}-tile-atlas{suffix}-object"), - generation_provider: "deterministic-placeholder".to_string(), - prompt: prompt.to_string(), - width: 1024, - height: 1024, - } -} - -fn ensure_tile_assets( - existing: Vec, - profile_id: &str, - force_new: bool, - now_micros: i64, -) -> Vec { - if !force_new && !existing.is_empty() { - return existing; - } - let suffix = asset_revision_suffix(force_new.then_some(now_micros)); - [ - JumpHopTileType::Start, - JumpHopTileType::Normal, - JumpHopTileType::Target, - JumpHopTileType::Finish, - JumpHopTileType::Bonus, - JumpHopTileType::Accent, - ] - .into_iter() - .enumerate() - .map(|(index, tile_type)| JumpHopTileAsset { - tile_type, - image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"), - image_object_key: format!( - "generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png" - ), - asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"), - source_atlas_cell: format!("cell-{index}{suffix}"), - visual_width: 256, - visual_height: 192, - top_surface_radius: 42.0, - landing_radius: 34.0, - }) - .collect() -} - fn resolve_cover_composite( draft: &JumpHopDraftResponse, profile_id: &str, @@ -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) -> String { revision .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)] mod tests { use super::*; @@ -992,8 +994,9 @@ mod tests { const NOW_MICROS: i64 = 1_763_456_789_000_000; #[test] - fn jump_hop_action_compile_draft_builds_compile_input_with_assets() { - let session = session_with_draft(draft_without_assets()); + fn jump_hop_action_compile_draft_builds_compile_input_with_25_tile_assets_and_builtin_character() + { + let session = session_with_draft(draft_without_character_asset()); let payload = action(JumpHopActionType::CompileDraft); let (plan, draft) = @@ -1011,7 +1014,7 @@ mod tests { .character_asset_json .as_deref() .unwrap_or("") - .contains("-character") + .contains("builtin-three") ); assert!( input @@ -1025,59 +1028,19 @@ mod tests { .tile_assets_json .as_deref() .unwrap_or("") - .contains("tile-0-object") + .contains("old-tile-25-object") ); + assert_eq!(draft.tile_assets.len(), 25); assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready); } - #[test] - fn jump_hop_action_regenerate_character_replaces_only_character_asset_input() { - let session = session_with_draft(draft_with_assets()); - let mut payload = action(JumpHopActionType::RegenerateCharacter); - payload.character_prompt = Some("新的主角提示词".to_string()); - - let (plan, _draft) = - build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) - .expect("regenerate-character should build plan"); - - let JumpHopActionProcedure::Compile(input) = plan else { - panic!("regenerate-character should call compile_jump_hop_draft"); - }; - assert!( - !input - .character_asset_json - .as_deref() - .unwrap_or("") - .contains("old-character") - ); - assert!( - input - .character_asset_json - .as_deref() - .unwrap_or("") - .contains(&NOW_MICROS.to_string()) - ); - assert!( - input - .tile_atlas_asset_json - .as_deref() - .unwrap_or("") - .contains("old-tile-atlas") - ); - assert!( - input - .tile_assets_json - .as_deref() - .unwrap_or("") - .contains("old-normal-tile") - ); - } - #[test] fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() { let session = session_with_draft(draft_with_assets()); let mut payload = action(JumpHopActionType::RegenerateTiles); payload.tile_prompt = Some("新的地块提示词".to_string()); + payload.tile_atlas_asset = Some(tile_atlas_asset("new-tile-atlas", NOW_MICROS)); + payload.tile_assets = Some(tile_assets("new", 25)); let (plan, _draft) = build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) @@ -1091,7 +1054,7 @@ mod tests { .character_asset_json .as_deref() .unwrap_or("") - .contains("old-character") + .contains("builtin-three") ); assert!( !input @@ -1105,24 +1068,43 @@ mod tests { .tile_assets_json .as_deref() .unwrap_or("") - .contains("old-normal-tile") + .contains("old-tile-01-object") ); assert!( input .tile_atlas_asset_json .as_deref() .unwrap_or("") - .contains(&NOW_MICROS.to_string()) + .contains("new-tile-atlas") ); assert!( input .tile_assets_json .as_deref() .unwrap_or("") - .contains(&NOW_MICROS.to_string()) + .contains("new-tile-25-object") ); } + #[test] + fn jump_hop_action_compile_draft_persists_theme_text_separately_from_title() { + let session = session_with_draft(draft_without_character_asset()); + let mut payload = action(JumpHopActionType::CompileDraft); + payload.theme_text = Some(" 森林蘑菇跳台 ".to_string()); + payload.work_title = Some("自动标题".to_string()); + + let (plan, draft) = + build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("compile-draft should build plan"); + + let JumpHopActionProcedure::Compile(input) = plan else { + panic!("compile-draft should call compile_jump_hop_draft"); + }; + assert_eq!(draft.theme_text, "森林蘑菇跳台"); + assert_eq!(input.theme_text.as_deref(), Some("森林蘑菇跳台")); + assert_eq!(input.work_title, "自动标题"); + } + #[test] fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() { let session = session_with_draft(draft_with_assets()); @@ -1167,22 +1149,22 @@ mod tests { .character_asset .as_ref() .map(|asset| asset.asset_id.as_str()), - Some("old-character") + Some("jump-hop-profile-test-builtin-character") ); assert_eq!( draft .tile_assets .first() .map(|asset| asset.asset_object_id.as_str()), - Some("old-normal-tile-object") + Some("old-tile-01-object") ); } - /// 构造不携带资产覆盖的 JumpHop action,单测按需再覆盖字段。 fn action(action_type: JumpHopActionType) -> JumpHopActionRequest { JumpHopActionRequest { action_type, profile_id: None, + theme_text: None, work_title: None, work_description: None, theme_tags: None, @@ -1209,9 +1191,11 @@ mod tests { } } - fn draft_without_assets() -> JumpHopDraftResponse { + fn draft_without_character_asset() -> JumpHopDraftResponse { JumpHopDraftResponse { profile_id: None, + tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)), + tile_assets: tile_assets("old", 25), ..base_draft() } } @@ -1219,37 +1203,9 @@ mod tests { fn draft_with_assets() -> JumpHopDraftResponse { JumpHopDraftResponse { profile_id: Some(PROFILE_ID.to_string()), - character_asset: Some(JumpHopCharacterAsset { - asset_id: "old-character".to_string(), - image_src: "/generated-jump-hop-assets/old-character.png".to_string(), - image_object_key: "generated-jump-hop-assets/old-character.png".to_string(), - asset_object_id: "old-character-object".to_string(), - generation_provider: "old-provider".to_string(), - prompt: "旧角色提示词".to_string(), - width: 768, - height: 768, - }), - tile_atlas_asset: Some(JumpHopCharacterAsset { - asset_id: "old-tile-atlas".to_string(), - image_src: "/generated-jump-hop-assets/old-tile-atlas.png".to_string(), - image_object_key: "generated-jump-hop-assets/old-tile-atlas.png".to_string(), - asset_object_id: "old-tile-atlas-object".to_string(), - generation_provider: "old-provider".to_string(), - prompt: "旧地块提示词".to_string(), - width: 1024, - height: 1024, - }), - tile_assets: vec![JumpHopTileAsset { - tile_type: JumpHopTileType::Normal, - image_src: "/generated-jump-hop-assets/old-normal-tile.png".to_string(), - image_object_key: "generated-jump-hop-assets/old-normal-tile.png".to_string(), - asset_object_id: "old-normal-tile-object".to_string(), - source_atlas_cell: "old-cell".to_string(), - visual_width: 256, - visual_height: 192, - top_surface_radius: 42.0, - landing_radius: 34.0, - }], + character_asset: Some(build_jump_hop_default_character_asset(PROFILE_ID, "旧主题")), + tile_atlas_asset: Some(tile_atlas_asset("old-tile-atlas", 0)), + tile_assets: tile_assets("old", 25), path: Some(sample_jump_hop_path()), cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()), generation_status: JumpHopGenerationStatus::Ready, @@ -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 { + (0..count) + .map(|index| JumpHopTileAsset { + tile_type: if index == 0 { + JumpHopTileType::Start + } else { + JumpHopTileType::Normal + }, + tile_id: Some(format!("tile-{:02}", index + 1)), + image_src: format!("/generated-jump-hop-assets/{prefix}-tile-{}.png", index + 1), + image_object_key: format!( + "generated-jump-hop-assets/{prefix}-tile-{}.png", + index + 1 + ), + asset_object_id: format!("{prefix}-tile-{:02}-object", index + 1), + source_atlas_cell: format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1), + atlas_row: Some(index as u32 / 5 + 1), + atlas_col: Some(index as u32 % 5 + 1), + visual_width: 256, + visual_height: 192, + top_surface_radius: 42.0, + landing_radius: 34.0, + }) + .collect() + } + fn base_draft() -> JumpHopDraftResponse { JumpHopDraftResponse { template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, + theme_text: "旧主题".to_string(), work_title: "旧标题".to_string(), work_description: "旧描述".to_string(), theme_tags: vec!["旧标签".to_string()], difficulty: JumpHopDifficulty::Standard, style_preset: JumpHopStylePreset::MinimalBlocks, + default_character: Some(default_jump_hop_default_character()), character_prompt: "旧角色提示词".to_string(), tile_prompt: "旧地块提示词".to_string(), end_mood_prompt: None, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index fa080b9d..271f1be4 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -171,8 +171,8 @@ pub(crate) use self::inventory::{ }; pub(crate) use self::jump_hop::{ map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row, - map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result, - map_jump_hop_works_procedure_result, + map_jump_hop_leaderboard_procedure_result, map_jump_hop_run_procedure_result, + map_jump_hop_work_procedure_result, map_jump_hop_works_procedure_result, }; pub(crate) use self::match3d::{ map_match3d_agent_session_procedure_result, map_match3d_click_item_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs index a2384840..eec6ba97 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -1,10 +1,11 @@ use super::*; pub use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, - JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, + JumpHopDefaultCharacter, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, - JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, + JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, + JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopPath, JumpHopPlatform, + JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, @@ -61,15 +62,40 @@ pub(crate) fn map_jump_hop_run_procedure_result( Ok(map_jump_hop_run_snapshot(run)) } +pub(crate) fn map_jump_hop_leaderboard_procedure_result( + result: JumpHopLeaderboardProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(JumpHopLeaderboardResponse { + profile_id: result.profile_id, + items: result + .items + .into_iter() + .map(map_jump_hop_leaderboard_entry_snapshot) + .collect(), + viewer_best: result + .viewer_best + .map(map_jump_hop_leaderboard_entry_snapshot), + }) +} + pub(crate) fn map_jump_hop_gallery_card_view_row( row: JumpHopGalleryCardViewRow, ) -> JumpHopGalleryCardResponse { + let theme_text = if row.theme_text.trim().is_empty() { + row.work_title.clone() + } else { + row.theme_text.clone() + }; JumpHopGalleryCardResponse { public_work_code: row.public_work_code, work_id: row.work_id, profile_id: row.profile_id, owner_user_id: row.owner_user_id, author_display_name: row.author_display_name, + theme_text, work_title: row.work_title, work_description: row.work_description, 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( snapshot: JumpHopWorkSnapshot, ) -> Result { + let theme_text = if snapshot.theme_text.trim().is_empty() { + snapshot.work_title.clone() + } else { + snapshot.theme_text.clone() + }; let draft = JumpHopDraftResponse { template_id: "jump-hop".to_string(), template_name: "跳一跳".to_string(), profile_id: Some(snapshot.profile_id.clone()), + theme_text: theme_text.clone(), work_title: snapshot.work_title.clone(), work_description: snapshot.work_description.clone(), theme_tags: snapshot.theme_tags.clone(), difficulty: parse_difficulty(&snapshot.difficulty), style_preset: parse_style_preset(&snapshot.style_preset), + default_character: Some(default_jump_hop_character()), character_prompt: snapshot.character_prompt.clone(), tile_prompt: snapshot.tile_prompt.clone(), end_mood_prompt: snapshot.end_mood_prompt.clone(), @@ -126,6 +159,7 @@ fn map_jump_hop_work_snapshot( .collect(), path: Some(map_jump_hop_path(snapshot.path.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), }; let character_asset = draft @@ -143,6 +177,7 @@ fn map_jump_hop_work_snapshot( profile_id: snapshot.profile_id, owner_user_id: snapshot.owner_user_id, source_session_id: empty_string_to_none(snapshot.source_session_id), + theme_text, work_title: snapshot.work_title, work_description: snapshot.work_description, theme_tags: snapshot.theme_tags, @@ -159,6 +194,7 @@ fn map_jump_hop_work_snapshot( }, draft, path: map_jump_hop_path(snapshot.path), + default_character: Some(default_jump_hop_character()), character_asset, tile_atlas_asset, tile_assets: snapshot @@ -166,19 +202,27 @@ fn map_jump_hop_work_snapshot( .into_iter() .map(map_tile_asset) .collect(), + back_button_asset: snapshot.back_button_asset.map(map_character_asset), }) } 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 { template_id: snapshot.template_id, template_name: snapshot.template_name, profile_id: snapshot.profile_id, + theme_text, work_title: snapshot.work_title, work_description: snapshot.work_description, theme_tags: snapshot.theme_tags, difficulty: parse_difficulty(&snapshot.difficulty), style_preset: parse_style_preset(&snapshot.style_preset), + default_character: Some(default_jump_hop_character()), character_prompt: snapshot.character_prompt, tile_prompt: snapshot.tile_prompt, end_mood_prompt: snapshot.end_mood_prompt, @@ -191,6 +235,7 @@ fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftRe .collect(), path: snapshot.path.map(map_jump_hop_path), cover_composite: snapshot.cover_composite, + back_button_asset: snapshot.back_button_asset.map(map_character_asset), 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 { JumpHopTileAsset { tile_type: parse_tile_type(&snapshot.tile_type), + tile_id: snapshot.tile_id, image_src: snapshot.image_src, image_object_key: snapshot.image_object_key, asset_object_id: snapshot.asset_object_id, source_atlas_cell: snapshot.source_atlas_cell, + atlas_row: snapshot.atlas_row, + atlas_col: snapshot.atlas_col, visual_width: snapshot.visual_width, visual_height: snapshot.visual_height, top_surface_radius: snapshot.top_surface_radius, @@ -263,6 +311,8 @@ fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunS crate::module_bindings::JumpHopRunStatus::Playing => JumpHopRunStatus::Playing, }, current_platform_index: snapshot.current_platform_index, + successful_jump_count: snapshot.current_platform_index, + duration_ms: jump_hop_duration_ms(snapshot.started_at_ms, snapshot.finished_at_ms), score: snapshot.score, combo: snapshot.combo, path: map_jump_hop_path(snapshot.path), @@ -286,6 +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 { + finished_at_ms + .unwrap_or(started_at_ms) + .saturating_sub(started_at_ms) +} + fn parse_difficulty(value: &str) -> JumpHopDifficulty { match value { "easy" => JumpHopDifficulty::Easy, diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs index 5b0ef784..47efee78 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -296,26 +296,30 @@ pub(crate) fn build_creation_entry_config_record_from_rows( event_banners_json: header.event_banners_json, creation_types: creation_types .into_iter() - .map(|item| module_runtime::CreationEntryTypeSnapshot { - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - image_src: item.image_src, - visible: item.visible, - open: item.open, - sort_order: item.sort_order, - category_id: creation_entry_text_or_default( - item.category_id, - module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID, - ), - category_label: creation_entry_text_or_default( - item.category_label, - module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL, - ), - category_sort_order: item.category_sort_order, - updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), - unified_creation_spec_json: item.unified_creation_spec_json, + .map(|item| { + normalize_creation_entry_type_snapshot( + module_runtime::CreationEntryTypeSnapshot { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + category_id: creation_entry_text_or_default( + item.category_id, + module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_ID, + ), + category_label: creation_entry_text_or_default( + item.category_label, + module_runtime::DEFAULT_CREATION_ENTRY_CATEGORY_LABEL, + ), + category_sort_order: item.category_sort_order, + updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), + unified_creation_spec_json: item.unified_creation_spec_json, + }, + ) }) .collect(), updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), @@ -353,20 +357,22 @@ fn map_creation_entry_config_snapshot( creation_types: snapshot .creation_types .into_iter() - .map(|item| module_runtime::CreationEntryTypeSnapshot { - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - image_src: item.image_src, - visible: item.visible, - open: item.open, - sort_order: item.sort_order, - category_id: item.category_id, - category_label: item.category_label, - category_sort_order: item.category_sort_order, - updated_at_micros: item.updated_at_micros, - unified_creation_spec_json: item.unified_creation_spec_json, + .map(|item| { + normalize_creation_entry_type_snapshot(module_runtime::CreationEntryTypeSnapshot { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + category_id: item.category_id, + category_label: item.category_label, + category_sort_order: item.category_sort_order, + updated_at_micros: item.updated_at_micros, + unified_creation_spec_json: item.unified_creation_spec_json, + }) }) .collect(), updated_at_micros: snapshot.updated_at_micros, @@ -380,6 +386,150 @@ fn creation_entry_text_or_default(value: Option, default_value: &str) -> .unwrap_or_else(|| default_value.to_string()) } +fn normalize_creation_entry_type_snapshot( + item: module_runtime::CreationEntryTypeSnapshot, +) -> module_runtime::CreationEntryTypeSnapshot { + // 中文注释:旧库里残留的跳一跳系统默认入口行仍会从订阅缓存命中,这里统一做读模型纠偏, + // 这样无论走订阅缓存还是 procedure 回退,创作页都只会看到新的跳一跳入口口径。 + if item.id == "jump-hop" + && item.title == "跳一跳" + && item.subtitle == "俯视角跳跃闯关" + && item.badge == "可创建" + && item.image_src == "/creation-type-references/puzzle.webp" + && item.visible + && item.open + && item.sort_order == 45 + { + return module_runtime::CreationEntryTypeSnapshot { + subtitle: "主题驱动平台跳跃".to_string(), + image_src: "/creation-type-references/jump-hop.webp".to_string(), + ..item + }; + } + + item +} + +#[cfg(test)] +mod tests { + use super::*; + use spacetimedb_sdk::Timestamp; + + fn build_creation_entry_header() -> CreationEntryConfig { + CreationEntryConfig { + config_id: "creation-entry-config".to_string(), + start_title: "新建作品".to_string(), + start_description: "选择模板后进入对应的创作表单。".to_string(), + start_idle_badge: "模板 Tab".to_string(), + start_busy_badge: "正在开启".to_string(), + modal_title: "选择创作类型".to_string(), + modal_description: "先选玩法类型,再进入对应创作工作台。".to_string(), + updated_at: Timestamp::from_micros_since_unix_epoch(1_000_000), + event_title: None, + event_description: None, + event_cover_image_src: None, + event_prize_pool_mud_points: 0, + event_starts_at_text: None, + event_ends_at_text: None, + 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( result: RuntimeSettingProcedureResult, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 71239f7b..ed8ef0aa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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_library_detail_procedure; pub mod get_jump_hop_agent_session_procedure; +pub mod get_jump_hop_leaderboard_procedure; pub mod get_jump_hop_run_procedure; pub mod get_jump_hop_work_profile_procedure; pub mod get_match_3_d_agent_session_procedure; @@ -438,6 +439,11 @@ pub mod jump_hop_gallery_view_table; pub mod jump_hop_jump_procedure; pub mod jump_hop_jump_result_kind_type; pub mod jump_hop_last_jump_type; +pub mod jump_hop_leaderboard_entry_row_type; +pub mod jump_hop_leaderboard_entry_snapshot_type; +pub mod jump_hop_leaderboard_entry_table; +pub mod jump_hop_leaderboard_get_input_type; +pub mod jump_hop_leaderboard_procedure_result_type; pub mod jump_hop_path_type; pub mod jump_hop_platform_type; pub mod jump_hop_run_get_input_type; @@ -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_library_detail_procedure::get_custom_world_library_detail; pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session; +pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard; pub use get_jump_hop_run_procedure::get_jump_hop_run; pub use get_jump_hop_work_profile_procedure::get_jump_hop_work_profile; pub use get_match_3_d_agent_session_procedure::get_match_3_d_agent_session; @@ -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_result_kind_type::JumpHopJumpResultKind; pub use jump_hop_last_jump_type::JumpHopLastJump; +pub use jump_hop_leaderboard_entry_row_type::JumpHopLeaderboardEntryRow; +pub use jump_hop_leaderboard_entry_snapshot_type::JumpHopLeaderboardEntrySnapshot; +pub use jump_hop_leaderboard_entry_table::*; +pub use jump_hop_leaderboard_get_input_type::JumpHopLeaderboardGetInput; +pub use jump_hop_leaderboard_procedure_result_type::JumpHopLeaderboardProcedureResult; pub use jump_hop_path_type::JumpHopPath; pub use jump_hop_platform_type::JumpHopPlatform; pub use jump_hop_run_get_input_type::JumpHopRunGetInput; @@ -2416,6 +2428,7 @@ pub struct DbUpdate { jump_hop_event: __sdk::TableUpdate, jump_hop_gallery_card_view: __sdk::TableUpdate, jump_hop_gallery_view: __sdk::TableUpdate, + jump_hop_leaderboard_entry: __sdk::TableUpdate, jump_hop_runtime_run: __sdk::TableUpdate, jump_hop_work_profile: __sdk::TableUpdate, match_3_d_agent_message: __sdk::TableUpdate, @@ -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_table::parse_table_update(table_update)?, ), + "jump_hop_leaderboard_entry" => db_update.jump_hop_leaderboard_entry.append( + jump_hop_leaderboard_entry_table::parse_table_update(table_update)?, + ), "jump_hop_runtime_run" => db_update.jump_hop_runtime_run.append( jump_hop_runtime_run_table::parse_table_update(table_update)?, ), @@ -3059,6 +3075,12 @@ impl __sdk::DbUpdate for DbUpdate { diff.jump_hop_event = cache .apply_diff_to_table::("jump_hop_event", &self.jump_hop_event) .with_updates_by_pk(|row| &row.event_id); + diff.jump_hop_leaderboard_entry = cache + .apply_diff_to_table::( + "jump_hop_leaderboard_entry", + &self.jump_hop_leaderboard_entry, + ) + .with_updates_by_pk(|row| &row.entry_id); diff.jump_hop_runtime_run = cache .apply_diff_to_table::( "jump_hop_runtime_run", @@ -3544,6 +3566,9 @@ impl __sdk::DbUpdate for DbUpdate { "jump_hop_gallery_view" => db_update .jump_hop_gallery_view .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "jump_hop_leaderboard_entry" => db_update + .jump_hop_leaderboard_entry + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "jump_hop_runtime_run" => db_update .jump_hop_runtime_run .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3887,6 +3912,9 @@ impl __sdk::DbUpdate for DbUpdate { "jump_hop_gallery_view" => db_update .jump_hop_gallery_view .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "jump_hop_leaderboard_entry" => db_update + .jump_hop_leaderboard_entry + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "jump_hop_runtime_run" => db_update .jump_hop_runtime_run .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4146,6 +4174,7 @@ pub struct AppliedDiff<'r> { jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>, jump_hop_gallery_card_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryCardViewRow>, jump_hop_gallery_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryViewRow>, + jump_hop_leaderboard_entry: __sdk::TableAppliedDiff<'r, JumpHopLeaderboardEntryRow>, jump_hop_runtime_run: __sdk::TableAppliedDiff<'r, JumpHopRuntimeRunRow>, jump_hop_work_profile: __sdk::TableAppliedDiff<'r, JumpHopWorkProfileRow>, match_3_d_agent_message: __sdk::TableAppliedDiff<'r, Match3DAgentMessageRow>, @@ -4438,6 +4467,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.jump_hop_gallery_view, event, ); + callbacks.invoke_table_row_callbacks::( + "jump_hop_leaderboard_entry", + &self.jump_hop_leaderboard_entry, + event, + ); callbacks.invoke_table_row_callbacks::( "jump_hop_runtime_run", &self.jump_hop_runtime_run, @@ -5460,6 +5494,7 @@ impl __sdk::SpacetimeModule for RemoteModule { jump_hop_event_table::register_table(client_cache); jump_hop_gallery_card_view_table::register_table(client_cache); jump_hop_gallery_view_table::register_table(client_cache); + jump_hop_leaderboard_entry_table::register_table(client_cache); jump_hop_runtime_run_table::register_table(client_cache); jump_hop_work_profile_table::register_table(client_cache); match_3_d_agent_message_table::register_table(client_cache); @@ -5572,6 +5607,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "jump_hop_event", "jump_hop_gallery_card_view", "jump_hop_gallery_view", + "jump_hop_leaderboard_entry", "jump_hop_runtime_run", "jump_hop_work_profile", "match_3_d_agent_message", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs new file mode 100644 index 00000000..93176d49 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::jump_hop_leaderboard_get_input_type::JumpHopLeaderboardGetInput; +use super::jump_hop_leaderboard_procedure_result_type::JumpHopLeaderboardProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetJumpHopLeaderboardArgs { + pub input: JumpHopLeaderboardGetInput, +} + +impl __sdk::InModule for GetJumpHopLeaderboardArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_jump_hop_leaderboard`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_jump_hop_leaderboard { + fn get_jump_hop_leaderboard(&self, input: JumpHopLeaderboardGetInput) { + self.get_jump_hop_leaderboard_then(input, |_, _| {}); + } + + fn get_jump_hop_leaderboard_then( + &self, + input: JumpHopLeaderboardGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_jump_hop_leaderboard for super::RemoteProcedures { + fn get_jump_hop_leaderboard_then( + &self, + input: JumpHopLeaderboardGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopLeaderboardProcedureResult>( + "get_jump_hop_leaderboard", + GetJumpHopLeaderboardArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs index d8f3e7f5..df9575f0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs @@ -25,6 +25,7 @@ pub struct JumpHopDraftCompileInput { pub tile_atlas_asset_json: Option, pub tile_assets_json: Option, pub cover_composite: Option, + pub back_button_asset_json: Option, pub generation_status: Option, pub compiled_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs index 09e12197..adfeae68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs @@ -14,6 +14,7 @@ pub struct JumpHopDraftSnapshot { pub template_id: String, pub template_name: String, pub profile_id: Option, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -27,6 +28,7 @@ pub struct JumpHopDraftSnapshot { pub tile_assets: Vec, pub path: Option, pub cover_composite: Option, + pub back_button_asset: Option, pub generation_status: String, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_row_type.rs index 25622a80..011228e1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_row_type.rs @@ -12,6 +12,7 @@ pub struct JumpHopGalleryCardViewRow { pub profile_id: String, pub owner_user_id: String, pub author_display_name: String, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -38,6 +39,7 @@ pub struct JumpHopGalleryCardViewRowCols { pub profile_id: __sdk::__query_builder::Col, pub owner_user_id: __sdk::__query_builder::Col, pub author_display_name: __sdk::__query_builder::Col, + pub theme_text: __sdk::__query_builder::Col, pub work_title: __sdk::__query_builder::Col, pub work_description: __sdk::__query_builder::Col, pub theme_tags: __sdk::__query_builder::Col>, @@ -63,6 +65,7 @@ impl __sdk::__query_builder::HasCols for JumpHopGalleryCardViewRow { table_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_description: __sdk::__query_builder::Col::new(table_name, "work_description"), theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_row_type.rs index cdf7e954..cadb7726 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_row_type.rs @@ -16,6 +16,7 @@ pub struct JumpHopGalleryViewRow { pub owner_user_id: String, pub source_session_id: String, pub author_display_name: String, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -51,6 +52,7 @@ pub struct JumpHopGalleryViewRowCols { pub owner_user_id: __sdk::__query_builder::Col, pub source_session_id: __sdk::__query_builder::Col, pub author_display_name: __sdk::__query_builder::Col, + pub theme_text: __sdk::__query_builder::Col, pub work_title: __sdk::__query_builder::Col, pub work_description: __sdk::__query_builder::Col, pub theme_tags: __sdk::__query_builder::Col>, @@ -88,6 +90,7 @@ impl __sdk::__query_builder::HasCols for JumpHopGalleryViewRow { table_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_description: __sdk::__query_builder::Col::new(table_name, "work_description"), theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_row_type.rs new file mode 100644 index 00000000..369cbcce --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_row_type.rs @@ -0,0 +1,72 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLeaderboardEntryRow { + pub entry_id: String, + pub profile_id: String, + pub player_id: String, + pub successful_jump_count: u32, + pub duration_ms: u64, + pub run_id: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for JumpHopLeaderboardEntryRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `JumpHopLeaderboardEntryRow`. +/// +/// Provides typed access to columns for query building. +pub struct JumpHopLeaderboardEntryRowCols { + pub entry_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub player_id: __sdk::__query_builder::Col, + pub successful_jump_count: __sdk::__query_builder::Col, + pub duration_ms: __sdk::__query_builder::Col, + pub run_id: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for JumpHopLeaderboardEntryRow { + type Cols = JumpHopLeaderboardEntryRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + JumpHopLeaderboardEntryRowCols { + entry_id: __sdk::__query_builder::Col::new(table_name, "entry_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + player_id: __sdk::__query_builder::Col::new(table_name, "player_id"), + successful_jump_count: __sdk::__query_builder::Col::new( + table_name, + "successful_jump_count", + ), + duration_ms: __sdk::__query_builder::Col::new(table_name, "duration_ms"), + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `JumpHopLeaderboardEntryRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct JumpHopLeaderboardEntryRowIxCols { + pub entry_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for JumpHopLeaderboardEntryRow { + type IxCols = JumpHopLeaderboardEntryRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + JumpHopLeaderboardEntryRowIxCols { + entry_id: __sdk::__query_builder::IxCol::new(table_name, "entry_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for JumpHopLeaderboardEntryRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_snapshot_type.rs new file mode 100644 index 00000000..f8269a17 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLeaderboardEntrySnapshot { + pub rank: u32, + pub player_id: String, + pub successful_jump_count: u32, + pub duration_ms: u64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for JumpHopLeaderboardEntrySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_table.rs new file mode 100644 index 00000000..1d6ea6ec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_entry_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::jump_hop_leaderboard_entry_row_type::JumpHopLeaderboardEntryRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `jump_hop_leaderboard_entry`. +/// +/// Obtain a handle from the [`JumpHopLeaderboardEntryTableAccess::jump_hop_leaderboard_entry`] method on [`super::RemoteTables`], +/// like `ctx.db.jump_hop_leaderboard_entry()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_leaderboard_entry().on_insert(...)`. +pub struct JumpHopLeaderboardEntryTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `jump_hop_leaderboard_entry`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait JumpHopLeaderboardEntryTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`JumpHopLeaderboardEntryTableHandle`], which mediates access to the table `jump_hop_leaderboard_entry`. + fn jump_hop_leaderboard_entry(&self) -> JumpHopLeaderboardEntryTableHandle<'_>; +} + +impl JumpHopLeaderboardEntryTableAccess for super::RemoteTables { + fn jump_hop_leaderboard_entry(&self) -> JumpHopLeaderboardEntryTableHandle<'_> { + JumpHopLeaderboardEntryTableHandle { + imp: self + .imp + .get_table::("jump_hop_leaderboard_entry"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct JumpHopLeaderboardEntryInsertCallbackId(__sdk::CallbackId); +pub struct JumpHopLeaderboardEntryDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for JumpHopLeaderboardEntryTableHandle<'ctx> { + type Row = JumpHopLeaderboardEntryRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = JumpHopLeaderboardEntryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopLeaderboardEntryInsertCallbackId { + JumpHopLeaderboardEntryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: JumpHopLeaderboardEntryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = JumpHopLeaderboardEntryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopLeaderboardEntryDeleteCallbackId { + JumpHopLeaderboardEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: JumpHopLeaderboardEntryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct JumpHopLeaderboardEntryUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for JumpHopLeaderboardEntryTableHandle<'ctx> { + type UpdateCallbackId = JumpHopLeaderboardEntryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> JumpHopLeaderboardEntryUpdateCallbackId { + JumpHopLeaderboardEntryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: JumpHopLeaderboardEntryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `entry_id` unique index on the table `jump_hop_leaderboard_entry`, +/// which allows point queries on the field of the same name +/// via the [`JumpHopLeaderboardEntryEntryIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_leaderboard_entry().entry_id().find(...)`. +pub struct JumpHopLeaderboardEntryEntryIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> JumpHopLeaderboardEntryTableHandle<'ctx> { + /// Get a handle on the `entry_id` unique index on the table `jump_hop_leaderboard_entry`. + pub fn entry_id(&self) -> JumpHopLeaderboardEntryEntryIdUnique<'ctx> { + JumpHopLeaderboardEntryEntryIdUnique { + imp: self.imp.get_unique_constraint::("entry_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> JumpHopLeaderboardEntryEntryIdUnique<'ctx> { + /// Find the subscribed row whose `entry_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("jump_hop_leaderboard_entry"); + _table.add_unique_constraint::("entry_id", |row| &row.entry_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `JumpHopLeaderboardEntryRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait jump_hop_leaderboard_entryQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `JumpHopLeaderboardEntryRow`. + fn jump_hop_leaderboard_entry( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl jump_hop_leaderboard_entryQueryTableAccess for __sdk::QueryTableAccessor { + fn jump_hop_leaderboard_entry( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("jump_hop_leaderboard_entry") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_get_input_type.rs new file mode 100644 index 00000000..0c66a38b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_get_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLeaderboardGetInput { + pub profile_id: String, + pub viewer_player_id: String, + pub limit: u32, +} + +impl __sdk::InModule for JumpHopLeaderboardGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_procedure_result_type.rs new file mode 100644 index 00000000..b1ff0a33 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_leaderboard_procedure_result_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::jump_hop_leaderboard_entry_snapshot_type::JumpHopLeaderboardEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLeaderboardProcedureResult { + pub ok: bool, + pub profile_id: String, + pub items: Vec, + pub viewer_best: Option, + pub error_message: Option, +} + +impl __sdk::InModule for JumpHopLeaderboardProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs index e73b5530..090fbea8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs @@ -9,7 +9,9 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct JumpHopRunJumpInput { pub run_id: String, pub owner_user_id: String, - pub charge_ms: u32, + pub drag_distance: f32, + pub drag_vector_x: Option, + pub drag_vector_y: Option, pub client_event_id: String, pub jumped_at_ms: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs index 40578dae..d5c00ddf 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs @@ -10,6 +10,7 @@ pub struct JumpHopRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub runtime_mode: String, pub client_event_id: String, pub started_at_ms: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_row_type.rs index 64c5205f..1a78cae3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_row_type.rs @@ -19,6 +19,7 @@ pub struct JumpHopRuntimeRunRow { pub snapshot_json: String, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub runtime_mode: Option, } impl __sdk::InModule for JumpHopRuntimeRunRow { @@ -41,6 +42,7 @@ pub struct JumpHopRuntimeRunRowCols { pub snapshot_json: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub runtime_mode: __sdk::__query_builder::Col>, } 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"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + runtime_mode: __sdk::__query_builder::Col::new(table_name, "runtime_mode"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs index 6874988f..9ca1fe02 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs @@ -8,10 +8,13 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct JumpHopTileAssetSnapshot { pub tile_type: String, + pub tile_id: Option, pub image_src: String, pub image_object_key: String, pub asset_object_id: String, pub source_atlas_cell: String, + pub atlas_row: Option, + pub atlas_col: Option, pub visual_width: u32, pub visual_height: u32, pub top_surface_radius: f32, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs index 660ea530..3a8b9e68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs @@ -32,6 +32,8 @@ pub struct JumpHopWorkProfileRow { pub updated_at: __sdk::Timestamp, pub published_at: Option<__sdk::Timestamp>, pub visible: bool, + pub theme_text: Option, + pub back_button_asset_json: Option, } impl __sdk::InModule for JumpHopWorkProfileRow { @@ -67,6 +69,8 @@ pub struct JumpHopWorkProfileRowCols { pub updated_at: __sdk::__query_builder::Col, pub published_at: __sdk::__query_builder::Col>, pub visible: __sdk::__query_builder::Col, + pub theme_text: __sdk::__query_builder::Col>, + pub back_button_asset_json: __sdk::__query_builder::Col>, } 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"), published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), 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", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs index bda718d0..969a4297 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs @@ -16,6 +16,7 @@ pub struct JumpHopWorkSnapshot { pub owner_user_id: String, pub source_session_id: String, pub author_display_name: String, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -30,6 +31,7 @@ pub struct JumpHopWorkSnapshot { pub path: JumpHopPath, pub cover_image_src: String, pub cover_composite: Option, + pub back_button_asset: Option, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index 724a08f0..cc600047 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -52,6 +52,7 @@ pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec, @@ -103,6 +105,7 @@ pub struct JumpHopGalleryCardViewRow { pub profile_id: String, pub owner_user_id: String, pub author_display_name: String, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -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( ctx: &ReducerContext, input: JumpHopAgentSessionCreateInput, @@ -291,6 +317,7 @@ fn create_jump_hop_agent_session_tx( template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), profile_id: None, + theme_text: config.theme_text.clone(), work_title: input.work_title.clone(), work_description: input.work_description.clone(), 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(), path: None, cover_composite: None, + back_button_asset: None, generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(), }; ctx.db @@ -356,6 +384,7 @@ fn compile_jump_hop_draft_tx( template_id: JUMP_HOP_TEMPLATE_ID.to_string(), template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), 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_description: input.work_description.trim().to_string(), theme_tags: tags.clone(), @@ -382,6 +411,11 @@ fn compile_jump_hop_draft_tx( .unwrap_or_default(), path: Some(path.clone()), 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 .clone() @@ -416,12 +450,14 @@ fn compile_jump_hop_draft_tx( path_json: to_json_string(&path), cover_image_src: 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(), publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(), play_count: 0, updated_at: compiled_at, published_at: None, visible: true, + theme_text: Some(draft.theme_text.clone()), }; upsert_work(ctx, row); replace_session( @@ -612,6 +648,15 @@ fn start_jump_hop_run_tx( ) -> Result { require_non_empty(&input.run_id, "jump_hop run_id")?; let work = find_work(ctx, &input.profile_id)?; + let runtime_mode = normalize_runtime_mode(&input.runtime_mode); + if runtime_mode == JUMP_HOP_RUNTIME_MODE_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::(&work.path_json)?; let domain_run = start_run( input.run_id.clone(), @@ -622,8 +667,10 @@ fn start_jump_hop_run_tx( ) .map_err(|error| error.to_string())?; let snapshot = domain_run; - upsert_run(ctx, &snapshot, input.started_at_ms); - increment_work_play_count(ctx, &work, input.started_at_ms); + 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); + } insert_event( ctx, input.client_event_id, @@ -651,10 +698,22 @@ fn jump_hop_jump_tx( ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = parse_json::(&row.snapshot_json)?; - let domain_next = apply_jump(&snapshot, input.charge_ms, input.jumped_at_ms as u64) - .map_err(|error| error.to_string())?; + let domain_next = apply_jump( + &snapshot, + input.drag_distance, + input.drag_vector_x, + input.drag_vector_y, + input.jumped_at_ms as u64, + ) + .map_err(|error| error.to_string())?; let next = domain_next; replace_run(ctx, &row, &next, input.jumped_at_ms); + if next.status == module_jump_hop::JumpHopRunStatus::Failed + && 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( ctx, input.client_event_id, @@ -671,6 +730,50 @@ fn jump_hop_jump_tx( Ok(next) } +fn get_jump_hop_leaderboard_tx( + ctx: &ReducerContext, + input: JumpHopLeaderboardGetInput, +) -> Result< + ( + String, + Vec, + Option, + ), + String, +> { + require_non_empty(&input.profile_id, "jump_hop profile_id")?; + let 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::>(); + sort_jump_hop_leaderboard_rows(&mut rows); + let ranked_rows = rows + .iter() + .enumerate() + .map(|(index, row)| (index as u32 + 1, row)) + .collect::>(); + let viewer_best = clean_optional(&input.viewer_player_id).and_then(|viewer_player_id| { + ranked_rows + .iter() + .find(|(_, row)| row.player_id == viewer_player_id) + .map(|(rank, row)| leaderboard_entry_snapshot(*rank, row)) + }); + let items = ranked_rows + .into_iter() + .take(limit) + .map(|(rank, row)| leaderboard_entry_snapshot(rank, row)) + .collect::>(); + + Ok((input.profile_id, items, viewer_best)) +} + fn restart_jump_hop_run_tx( ctx: &ReducerContext, input: JumpHopRunRestartInput, @@ -684,7 +787,8 @@ fn restart_jump_hop_run_tx( ) .map_err(|error| error.to_string())?; 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( ctx, input.client_action_id, @@ -706,6 +810,7 @@ fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result Result { 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 { work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), + theme_text, work_title: row.work_title.clone(), work_description: row.work_description.clone(), theme_tags: parse_tags(&row.theme_tags_json)?, @@ -795,6 +906,12 @@ fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result JumpHopRuntimeRunRow { JumpHopRuntimeRunRow { run_id: snapshot.run_id.clone(), @@ -995,6 +1133,7 @@ fn run_row_from_snapshot( snapshot_json: to_json_string(snapshot), created_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 { !row.work_title.trim().is_empty() - && !row.character_asset_json.trim().is_empty() && !row.tile_atlas_asset_json.trim().is_empty() && !row.tile_assets_json.trim().is_empty() && !row.path_json.trim().is_empty() + && row + .back_button_asset_json + .as_deref() + .and_then(clean_optional) + .is_some() } 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(), difficulty: JumpHopDifficulty::Standard.as_str().to_string(), style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(), - character_prompt: format!("{seed}的俯视角主角,透明背景,全身可见"), - tile_prompt: format!("{seed}的等距地块图集,包含起点、普通、目标和终点地块"), + character_prompt: "内置默认 3D 角色".to_string(), + tile_prompt: format!("{seed}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"), end_mood_prompt: String::new(), } } @@ -1235,6 +1491,8 @@ fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow { updated_at: row.updated_at, published_at: row.published_at, 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(), created_at: row.created_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::>(); + assert_eq!( + player_ids, + vec!["player-early", "player-fast", "player-late", "player-slow"] + ); + } + + #[test] + fn jump_hop_leaderboard_replaces_only_better_player_score() { + let existing = leaderboard_row("player", 6, 4_000, 10); + + assert!(is_jump_hop_leaderboard_candidate_better( + 7, 8_000, &existing + )); + assert!(is_jump_hop_leaderboard_candidate_better( + 6, 3_500, &existing + )); + assert!(!is_jump_hop_leaderboard_candidate_better( + 6, 4_500, &existing + )); + assert!(!is_jump_hop_leaderboard_candidate_better( + 5, 1_000, &existing + )); } } diff --git a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs index 31715f0e..c5b66771 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs @@ -56,6 +56,12 @@ pub struct JumpHopWorkProfileRow { // 后台可见性开关;默认显示,隐藏后不进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, + // 跳一跳生成主题独立于作品标题;旧行按 work_title 兜底。 + #[default(None::)] + pub(crate) theme_text: Option, + // 跳一跳左上角真实可点击返回按钮的独立透明资产快照;旧行为空时运行态使用样式兜底。 + #[default(None::)] + pub(crate) back_button_asset_json: Option, } #[spacetimedb::table( @@ -77,6 +83,9 @@ pub struct JumpHopRuntimeRunRow { pub(crate) snapshot_json: String, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, + // draft / published,用于隔离试玩统计和公开排行榜;旧行按 published 兜底。 + #[default(None::)] + pub(crate) runtime_mode: Option, } #[spacetimedb::table( @@ -94,3 +103,19 @@ pub struct JumpHopEventRow { pub(crate) result: String, pub(crate) occurred_at: Timestamp, } + +#[spacetimedb::table( + accessor = jump_hop_leaderboard_entry, + index(accessor = by_jump_hop_leaderboard_profile_id, btree(columns = [profile_id])), + index(accessor = by_jump_hop_leaderboard_player_profile, btree(columns = [player_id, profile_id])) +)] +pub struct JumpHopLeaderboardEntryRow { + #[primary_key] + pub(crate) entry_id: String, + pub(crate) profile_id: String, + pub(crate) player_id: String, + pub(crate) successful_jump_count: u32, + pub(crate) duration_ms: u64, + pub(crate) run_id: String, + pub(crate) updated_at: Timestamp, +} diff --git a/server-rs/crates/spacetime-module/src/jump_hop/types.rs b/server-rs/crates/spacetime-module/src/jump_hop/types.rs index 2ba30116..42c1d12b 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/types.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/types.rs @@ -14,6 +14,8 @@ pub const JUMP_HOP_GENERATION_READY: &str = "ready"; pub const JUMP_HOP_EVENT_RUN_STARTED: &str = "run-started"; pub const JUMP_HOP_EVENT_RUN_RESTARTED: &str = "run-restarted"; pub const JUMP_HOP_EVENT_JUMP: &str = "jump"; +pub const JUMP_HOP_RUNTIME_MODE_DRAFT: &str = "draft"; +pub const JUMP_HOP_RUNTIME_MODE_PUBLISHED: &str = "published"; #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct JumpHopAgentSessionCreateInput { @@ -54,6 +56,7 @@ pub struct JumpHopDraftCompileInput { pub tile_atlas_asset_json: Option, pub tile_assets_json: Option, pub cover_composite: Option, + pub back_button_asset_json: Option, pub generation_status: Option, pub compiled_at_micros: i64, } @@ -102,6 +105,7 @@ pub struct JumpHopRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub runtime_mode: String, pub client_event_id: String, pub started_at_ms: i64, } @@ -112,11 +116,13 @@ pub struct JumpHopRunGetInput { pub owner_user_id: String, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct JumpHopRunJumpInput { pub run_id: String, pub owner_user_id: String, - pub charge_ms: u32, + pub drag_distance: f32, + pub drag_vector_x: Option, + pub drag_vector_y: Option, pub client_event_id: String, pub jumped_at_ms: i64, } @@ -158,6 +164,31 @@ pub struct JumpHopRunProcedureResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopLeaderboardEntrySnapshot { + pub rank: u32, + pub player_id: String, + pub successful_jump_count: u32, + pub duration_ms: u64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopLeaderboardGetInput { + pub profile_id: String, + pub viewer_player_id: String, + pub limit: u32, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopLeaderboardProcedureResult { + pub ok: bool, + pub profile_id: String, + pub items: Vec, + pub viewer_best: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct JumpHopCreatorConfigSnapshot { @@ -187,10 +218,16 @@ pub struct JumpHopCharacterAssetSnapshot { #[serde(rename_all = "camelCase")] pub struct JumpHopTileAssetSnapshot { pub tile_type: String, + #[serde(default)] + pub tile_id: Option, pub image_src: String, pub image_object_key: String, pub asset_object_id: String, pub source_atlas_cell: String, + #[serde(default)] + pub atlas_row: Option, + #[serde(default)] + pub atlas_col: Option, pub visual_width: u32, pub visual_height: u32, pub top_surface_radius: f32, @@ -203,6 +240,8 @@ pub struct JumpHopDraftSnapshot { pub template_id: String, pub template_name: String, pub profile_id: Option, + #[serde(default)] + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -216,6 +255,7 @@ pub struct JumpHopDraftSnapshot { pub tile_assets: Vec, pub path: Option, pub cover_composite: Option, + pub back_button_asset: Option, pub generation_status: String, } @@ -244,6 +284,7 @@ pub struct JumpHopWorkSnapshot { pub owner_user_id: String, pub source_session_id: String, pub author_display_name: String, + pub theme_text: String, pub work_title: String, pub work_description: String, pub theme_tags: Vec, @@ -258,6 +299,7 @@ pub struct JumpHopWorkSnapshot { pub path: module_jump_hop::JumpHopPath, pub cover_image_src: String, pub cover_composite: Option, + pub back_button_asset: Option, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index d0a0dbd0..dfd882fb 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -13,7 +13,8 @@ use crate::bark_battle::tables::{ }; use crate::big_fish::big_fish_runtime_run; use crate::jump_hop::tables::{ - jump_hop_agent_session, jump_hop_event, jump_hop_runtime_run, jump_hop_work_profile, + jump_hop_agent_session, jump_hop_event, jump_hop_leaderboard_entry, jump_hop_runtime_run, + jump_hop_work_profile, }; use crate::match3d::tables::{ match_3_d_work_profile, match3d_agent_message, match3d_agent_session, match3d_runtime_run, @@ -244,6 +245,7 @@ macro_rules! migration_tables { jump_hop_work_profile, jump_hop_runtime_run, jump_hop_event, + jump_hop_leaderboard_entry, wooden_fish_agent_session, wooden_fish_work_profile, wooden_fish_runtime_run, @@ -1328,6 +1330,12 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("visible".to_string()) .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" { diff --git a/server-rs/crates/spacetime-module/src/public_work.rs b/server-rs/crates/spacetime-module/src/public_work.rs index 98aaa6ce..e4a2edb5 100644 --- a/server-rs/crates/spacetime-module/src/public_work.rs +++ b/server-rs/crates/spacetime-module/src/public_work.rs @@ -338,6 +338,7 @@ fn map_custom_world_detail_entry(row: CustomWorldProfileSnapshot) -> PublicWorkD fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalleryEntry { 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 theme_text = row.theme_text.clone(); PublicWorkGalleryEntry { source_type: "jump-hop".to_string(), @@ -352,7 +353,7 @@ fn map_jump_hop_gallery_entry(row: JumpHopGalleryCardViewRow) -> PublicWorkGalle summary_text: row.work_description, cover_image_src: empty_string_to_option(row.cover_image_src), 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, remix_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 { + let theme_text = row.theme_text.clone(); let entry = PublicWorkGalleryEntry { source_type: "jump-hop".to_string(), work_id: row.work_id, @@ -376,7 +378,7 @@ fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntr summary_text: row.work_description, cover_image_src: empty_string_to_option(row.cover_image_src), 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, remix_count: 0, like_count: 0, @@ -388,6 +390,7 @@ fn map_jump_hop_detail_entry(row: JumpHopGalleryViewRow) -> PublicWorkDetailEntr "sourceType": "jump-hop", "difficulty": row.difficulty, "stylePreset": row.style_preset, + "themeText": theme_text, "tileAssetCount": row.tile_assets.len(), "platformCount": row.path.platforms.len(), "generationStatus": row.generation_status, diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index a3daa9b0..232fc1e3 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -296,6 +296,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { migrate_bark_battle_entry_to_open_default(ctx, now); migrate_baby_object_match_entry_from_old_coming_soon_default(ctx, now); migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx, now); + migrate_jump_hop_entry_from_old_puzzle_default(ctx, now); } fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) { @@ -447,6 +448,35 @@ fn migrate_wooden_fish_entry_from_old_puzzle_image_default(ctx: &ReducerContext, }); } +fn migrate_jump_hop_entry_from_old_puzzle_default(ctx: &ReducerContext, now: Timestamp) { + let id = "jump-hop".to_string(); + let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { + return; + }; + + // 中文注释:只纠偏跳一跳重设计前的系统默认入口,避免覆盖后台手动配置。 + let still_old_puzzle_default = row.title == "跳一跳" + && row.subtitle == "俯视角跳跃闯关" + && row.badge == "可创建" + && row.image_src == "/creation-type-references/puzzle.webp" + && row.visible + && row.open + && row.sort_order == 45; + if !still_old_puzzle_default { + return; + } + + ctx.db + .creation_entry_type_config() + .id() + .update(CreationEntryTypeConfig { + subtitle: "主题驱动平台跳跃".to_string(), + image_src: "/creation-type-references/jump-hop.webp".to_string(), + updated_at: now, + ..row + }); +} + fn default_creation_entry_type_configs(now: Timestamp) -> Vec { module_runtime::default_creation_entry_type_snapshots(now.to_micros_since_unix_epoch()) .into_iter() diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 28e35014..ee12fd88 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -525,6 +525,7 @@ test('creation start card maps backend jump-hop draft to template card', () => { profileId: 'jump-hop-profile-1', ownerUserId: 'user-1', sourceSessionId: 'jump-hop-session-1', + themeText: '跳一跳生成草稿', workTitle: '跳一跳生成草稿', workDescription: '后端仍在生成跳一跳玩法。', themeTags: ['跳一跳'], diff --git a/src/components/jump-hop-result/JumpHopResultView.test.tsx b/src/components/jump-hop-result/JumpHopResultView.test.tsx index 70b2fa12..f19ec837 100644 --- a/src/components/jump-hop-result/JumpHopResultView.test.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.test.tsx @@ -1,144 +1,205 @@ /* @vitest-environment jsdom */ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { expect, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; -import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { JumpHopWorkProfileResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; import { JumpHopResultView } from './JumpHopResultView'; -const draft: JumpHopDraftResponse = { - templateId: 'jump-hop', - templateName: '跳一跳', - profileId: 'profile-1', - workTitle: '云端跳台', - workDescription: '一路跳到星星。', - themeTags: ['云朵', '星空'], - difficulty: 'standard', - stylePreset: 'paper-toy', - characterPrompt: '纸片小兔', - tilePrompt: '柔软云朵平台', - endMoodPrompt: '星光门', - characterAsset: { - assetId: 'character-1', - imageSrc: 'data:image/png;base64,character', - imageObjectKey: 'jump-hop/character.png', - assetObjectId: 'asset-character', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '角色图', - width: 1024, - height: 1024, - }, - tileAtlasAsset: { - assetId: 'tiles-1', - imageSrc: 'data:image/png;base64,tiles', - imageObjectKey: 'jump-hop/tiles.png', - assetObjectId: 'asset-tiles', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '地块图', - width: 1024, - height: 1024, - }, - tileAssets: [ - { - tileType: 'start', - imageSrc: 'data:image/png;base64,tile-start', - imageObjectKey: 'jump-hop/tile-start.png', - assetObjectId: 'asset-tile-start', - sourceAtlasCell: 'A1', - visualWidth: 128, - visualHeight: 96, - topSurfaceRadius: 24, - landingRadius: 28, - }, - { - tileType: 'finish', - imageSrc: 'data:image/png;base64,tile-finish', - imageObjectKey: 'jump-hop/tile-finish.png', - assetObjectId: 'asset-tile-finish', - sourceAtlasCell: 'A2', - visualWidth: 128, - visualHeight: 96, - topSurfaceRadius: 24, - landingRadius: 28, - }, - ], - path: { - seed: 'jump-hop-seed', - difficulty: 'standard', - platforms: [ - { - platformId: 'platform-1', - tileType: 'start', - x: 0, - y: 0, - width: 48, - height: 36, - landingRadius: 22, - perfectRadius: 12, - scoreValue: 1, - }, - { - platformId: 'platform-2', - tileType: 'finish', - x: 16, - y: 18, - width: 60, - height: 42, - landingRadius: 22, - perfectRadius: 12, - scoreValue: 2, - }, - ], - finishIndex: 1, - cameraPreset: 'default', - scoring: { - chargeToDistanceRatio: 1.2, - maxChargeMs: 1800, - hitBonus: 20, - perfectBonus: 50, - }, - }, - coverComposite: 'data:image/png;base64,cover', - generationStatus: 'ready', -}; +vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({ + useJumpHopLeaderboard: vi.fn(), +})); -test('jump hop result view exposes test run and publish actions', async () => { - const user = userEvent.setup(); - const onBack = vi.fn(); - const onEdit = vi.fn(); - const onStartTestRun = vi.fn(); - const onPublish = vi.fn(); - const onRegenerateCharacter = vi.fn(); - const onRegenerateTiles = vi.fn(); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useJumpHopLeaderboard).mockReturnValue({ + leaderboard: null, + isLoading: false, + error: null, + refresh: vi.fn(), + }); +}); + +test('跳一跳结果页展示排行榜列表', () => { + vi.mocked(useJumpHopLeaderboard).mockReturnValue({ + leaderboard: { + profileId: 'jump-hop-profile-test', + items: [ + { + rank: 1, + playerId: 'player-1', + successfulJumpCount: 12, + durationMs: 40123, + updatedAt: '2026-05-27T00:00:00Z', + }, + { + rank: 2, + playerId: 'player-2', + successfulJumpCount: 10, + durationMs: 38210, + updatedAt: '2026-05-26T00:00:00Z', + }, + ], + viewerBest: null, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + }); render( {}} + onEdit={() => {}} + onStartTestRun={() => {}} + onPublish={() => {}} + onRegenerateTiles={() => {}} />, ); - expect(screen.getByText('云端跳台')).toBeTruthy(); - expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '发布' })).toBeTruthy(); - - await user.click(screen.getByRole('button', { name: '试玩' })); - await user.click(screen.getByRole('button', { name: '发布' })); - await user.click(screen.getByRole('button', { name: '返回' })); - await user.click(screen.getByRole('button', { name: '返回编辑' })); - await user.click(screen.getByRole('button', { name: '角色' })); - await user.click(screen.getByRole('button', { name: '地块' })); - - expect(onStartTestRun).toHaveBeenCalledTimes(1); - expect(onPublish).toHaveBeenCalledTimes(1); - expect(onBack).toHaveBeenCalledTimes(1); - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onRegenerateCharacter).toHaveBeenCalledTimes(1); - expect(onRegenerateTiles).toHaveBeenCalledTimes(1); + expect(screen.getByText('排行榜')).toBeTruthy(); + expect(screen.getByText('player-1')).toBeTruthy(); + expect(screen.getByText('12 跳')).toBeTruthy(); + expect(screen.getByText('00:40')).toBeTruthy(); + expect(screen.getByText('player-2')).toBeTruthy(); }); + +test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => { + render( + {}} + onEdit={() => {}} + onStartTestRun={() => {}} + onPublish={() => {}} + onRegenerateTiles={() => {}} + />, + ); + + expect(screen.getByTestId('jump-hop-result-character-logo').getAttribute('src')).toBe( + '/branding/jump-hop-taonier-character.png', + ); +}); + +test('跳一跳草稿结果页不请求公开排行榜', () => { + render( + {}} + 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, + }; +} diff --git a/src/components/jump-hop-result/JumpHopResultView.tsx b/src/components/jump-hop-result/JumpHopResultView.tsx index d2f6cd78..bc084de5 100644 --- a/src/components/jump-hop-result/JumpHopResultView.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.tsx @@ -2,18 +2,22 @@ import { ArrowLeft, Loader2, Play, - RefreshCcw, Send, Shuffle, } from 'lucide-react'; -import { type CSSProperties, useMemo, useState } from 'react'; +import { type CSSProperties, useState } from 'react'; import type { JumpHopDraftResponse, JumpHopPath, - JumpHopPlatform, + JumpHopTileAsset, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import { + formatJumpHopDurationLabel, + selectJumpHopTileAsset, +} from '../../services/jump-hop/jumpHopRuntimeModel'; +import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type JumpHopResultViewProps = { @@ -34,7 +38,6 @@ type JumpHopResultViewProps = { onEdit: () => void; onStartTestRun: () => void; onPublish: () => void; - onRegenerateCharacter: () => void; onRegenerateTiles: () => void; }; @@ -44,43 +47,6 @@ function isJumpHopWorkProfile( return 'summary' in profile; } -type MiniMapPlatform = { - platform: JumpHopPlatform; - index: number; - x: number; - y: number; - width: number; - height: number; - isStart: boolean; - isFinish: boolean; -}; - -const difficultyToneByValue: Record< - JumpHopPath['difficulty'], - { accent: string; soft: string; label: string } -> = { - advanced: { - accent: '#df7f40', - soft: 'rgba(249, 115, 22, 0.16)', - label: '进阶', - }, - challenge: { - accent: '#b64a35', - soft: 'rgba(182, 98, 63, 0.16)', - label: '挑战', - }, - easy: { - accent: '#14b8a6', - soft: 'rgba(20, 184, 166, 0.16)', - label: '轻松', - }, - standard: { - accent: '#2563eb', - soft: 'rgba(37, 99, 235, 0.16)', - label: '标准', - }, -}; - const tileToneByType: Record = { accent: '#c4b5fd', bonus: '#fde68a', @@ -90,155 +56,191 @@ const tileToneByType: Record = { target: '#fecdd3', }; -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value); +const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC = + '/branding/jump-hop-taonier-character.png'; + +function JumpHopDefaultCharacterPreview() { + return ( +
+
+ +
+ ); } -function normalizePathPlatforms(path: JumpHopPath | null | undefined) { - const platforms = path?.platforms ?? []; - if (platforms.length === 0) { - return []; +function JumpHopTilePoolPreview({ + tileAssets, + tileAtlasAsset, + tileAtlasFallbackSrc, +}: { + tileAssets: JumpHopTileAsset[]; + tileAtlasAsset?: JumpHopDraftResponse['tileAtlasAsset'] | null; + tileAtlasFallbackSrc?: string | null; +}) { + const visibleTiles = tileAssets.slice(0, 25); + const atlasSrc = + tileAtlasAsset?.imageSrc?.trim() || tileAtlasFallbackSrc?.trim() || ''; + const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc; + if (visibleTiles.length > 0) { + return ( +
+ {visibleTiles.map((tile, index) => ( +
+ {tile.imageSrc ? ( + + ) : ( + + )} +
+ ))} +
+ ); } - const coordinatePlatforms = platforms.filter( - (platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y), - ); - const shouldUseCoordinates = coordinatePlatforms.length >= 2; - const xValues = shouldUseCoordinates - ? coordinatePlatforms.map((platform) => platform.x) - : []; - const yValues = shouldUseCoordinates - ? coordinatePlatforms.map((platform) => platform.y) - : []; - const minX = Math.min(...xValues); - const maxX = Math.max(...xValues); - const minY = Math.min(...yValues); - const maxY = Math.max(...yValues); - const xRange = Math.max(maxX - minX, 1); - const yRange = Math.max(maxY - minY, 1); - const denominator = Math.max(platforms.length - 1, 1); - - return platforms.map((platform, index): MiniMapPlatform => { - const sequenceRatio = index / denominator; - const hasCoordinates = - shouldUseCoordinates && - isFiniteNumber(platform.x) && - isFiniteNumber(platform.y); - const x = hasCoordinates - ? 12 + ((platform.x - minX) / xRange) * 76 - : 12 + sequenceRatio * 76; - const y = hasCoordinates - ? 14 + ((platform.y - minY) / yRange) * 72 - : 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18; - - return { - platform, - index, - x, - y, - width: Math.min(Math.max(platform.width || 54, 42), 82), - height: Math.min(Math.max(platform.height || 42, 34), 68), - isStart: index === 0 || platform.tileType === 'start', - isFinish: - index === path?.finishIndex || - platform.tileType === 'finish' || - platform.tileType === 'target', - }; - }); -} - -function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) { - const platforms = useMemo(() => normalizePathPlatforms(path), [path]); - const tone = - difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard; - const pathPoints = platforms - .map((platform) => `${platform.x},${platform.y}`) - .join(' '); - - if (platforms.length === 0) { - return null; + if (atlasSrc) { + return ( + + ); } return ( -
-
- - - - {platforms.map((item) => { - const tileTone = - tileToneByType[item.platform.tileType] ?? tileToneByType.normal; - const scoreBoost = - isFiniteNumber(item.platform.scoreValue) && - item.platform.scoreValue > 1; - const style = { - left: `${item.x}%`, - top: `${item.y}%`, - width: `${item.width}%`, - height: `${item.height}%`, - background: tileTone, - borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)', - zIndex: 10 + item.index, - } as CSSProperties; + ))} +
+ ); +} +function JumpHopFirstPlatformsPreview({ + path, + tileAssets, +}: { + path: JumpHopPath | null | undefined; + tileAssets: JumpHopTileAsset[]; +}) { + const platforms = (path?.platforms ?? []).slice(0, 3); + + return ( +
+
+ {platforms.map((platform, index) => { + const asset = selectJumpHopTileAsset( + tileAssets, + path?.seed, + index, + platform.platformId, + ); + const style = { + left: `${50 + (index - 1) * 24}%`, + top: `${68 - index * 22}%`, + width: `${34 - index * 3}%`, + zIndex: 10 + index, + } as CSSProperties; return (
- - {item.isStart || item.isFinish ? ( - - {item.isStart ? '起' : '终'} - - ) : null} +
+ {asset?.imageSrc ? ( + + ) : ( +
+ )}
); })} -
- {tone.label} + {platforms.length === 0 ? ( +
+ 路径 +
+ ) : null} +
+ ); +} + +function JumpHopResultLeaderboard({ + profileId, +}: { + profileId?: string | null; +}) { + const { leaderboard, isLoading, error } = useJumpHopLeaderboard(profileId); + const items = leaderboard?.items ?? []; + + return ( +
+
+
+ 排行榜 +
+ {isLoading ? ( + + ) : null}
-
- {platforms.length} +
+ {items.slice(0, 5).map((entry) => ( +
+ + {entry.rank} + + {entry.playerId} + {entry.successfulJumpCount} 跳 + {formatJumpHopDurationLabel(entry.durationMs)} +
+ ))} + {items.length === 0 ? ( +
+ {error ?? '暂无成绩'} +
+ ) : null}
); @@ -252,7 +254,6 @@ export function JumpHopResultView({ onEdit, onStartTestRun, onPublish, - onRegenerateCharacter, onRegenerateTiles, }: JumpHopResultViewProps) { const [isPublishing, setIsPublishing] = useState(false); @@ -264,12 +265,15 @@ export function JumpHopResultView({ path: NonNullable; }; const path = isWorkProfile ? profile.path : safeDraft.path; - const characterAsset = isWorkProfile - ? profile.characterAsset - : safeDraft.characterAsset; const tileAtlasAsset = isWorkProfile ? profile.tileAtlasAsset : safeDraft.tileAtlasAsset; + const tileAssets = isWorkProfile ? profile.tileAssets : safeDraft.tileAssets; + const profileId = isWorkProfile + ? profile.summary.profileId + : safeDraft.profileId; + const canShowLeaderboard = + isWorkProfile && profile.summary.publicationStatus === 'published'; const titleSource = isWorkProfile ? profile.summary.workTitle : profile.workTitle; @@ -278,15 +282,12 @@ export function JumpHopResultView({ : profile.workDescription; const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳'; const summary = summarySource?.trim() || safeDraft.workDescription.trim(); - const pathPlatforms = normalizePathPlatforms(path); - const canRenderPathMiniMap = pathPlatforms.length > 0; const hasAssets = Boolean( - profile.characterImageSrc?.trim() || - profile.tileAtlasImageSrc?.trim() || + profile.tileAtlasImageSrc?.trim() || profile.pathPreviewImageSrc?.trim() || - characterAsset?.imageSrc?.trim() || tileAtlasAsset?.imageSrc?.trim() || - canRenderPathMiniMap, + tileAssets.length > 0 || + path?.platforms.length, ); const handlePublish = async () => { @@ -310,15 +311,6 @@ export function JumpHopResultView({ 返回
-
{!hasAssets ? ( @@ -419,6 +367,9 @@ export function JumpHopResultView({
结果操作
+ {canShowLeaderboard ? ( + + ) : null} {error ? (
{error} diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index 9b626482..c6332727 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -1,212 +1,1137 @@ /* @vitest-environment jsdom */ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { expect, test, vi } from 'vitest'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; import type { JumpHopRuntimeRunSnapshotResponse, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { buildJumpHopVisiblePlatforms } from '../../services/jump-hop/jumpHopRuntimeModel'; +import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; import { JumpHopRuntimeShell } from './JumpHopRuntimeShell'; -const profile: JumpHopWorkProfileResponse = { - summary: { - runtimeKind: 'jump-hop', - workId: 'work-1', - profileId: 'profile-1', - ownerUserId: 'user-1', - sourceSessionId: 'session-1', - workTitle: '云端跳台', - workDescription: '一路跳到星星。', - themeTags: ['云朵'], - difficulty: 'standard', - stylePreset: 'paper-toy', - coverImageSrc: 'data:image/png;base64,cover', - publicationStatus: 'draft', - playCount: 0, - updatedAt: '2026-05-30T10:00:00.000Z', - publishedAt: null, - publishReady: true, - generationStatus: 'ready', - }, - draft: { - templateId: 'jump-hop', - templateName: '跳一跳', - profileId: 'profile-1', - workTitle: '云端跳台', - workDescription: '一路跳到星星。', - themeTags: ['云朵'], - difficulty: 'standard', - stylePreset: 'paper-toy', - characterPrompt: '纸片小兔', - tilePrompt: '云朵平台', - endMoodPrompt: '星光门', - characterAsset: { - assetId: 'character-1', - imageSrc: 'data:image/png;base64,character', - imageObjectKey: 'jump-hop/character.png', - assetObjectId: 'asset-character', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '角色图', - width: 1024, - height: 1024, - }, - tileAtlasAsset: { - assetId: 'tiles-1', - imageSrc: 'data:image/png;base64,tiles', - imageObjectKey: 'jump-hop/tiles.png', - assetObjectId: 'asset-tiles', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '地块图', - width: 1024, - height: 1024, - }, - tileAssets: [ - { - tileType: 'start', - imageSrc: 'data:image/png;base64,tile-start', - imageObjectKey: 'jump-hop/tile-start.png', - assetObjectId: 'asset-tile-start', - sourceAtlasCell: 'A1', - visualWidth: 128, - visualHeight: 96, - topSurfaceRadius: 24, - landingRadius: 28, - }, - ], - path: { - seed: 'jump-hop-seed', - difficulty: 'standard', - platforms: [ - { - platformId: 'platform-1', - tileType: 'start', - x: 0, - y: 0, - width: 48, - height: 36, - landingRadius: 22, - perfectRadius: 12, - scoreValue: 1, - }, - ], - finishIndex: 0, - cameraPreset: 'default', - scoring: { - chargeToDistanceRatio: 1.2, - maxChargeMs: 1800, - hitBonus: 20, - perfectBonus: 50, - }, - }, - coverComposite: 'data:image/png;base64,cover', - generationStatus: 'ready', - }, - path: { - seed: 'jump-hop-seed', - difficulty: 'standard', - platforms: [ - { - platformId: 'platform-1', - tileType: 'start', - x: 0, - y: 0, - width: 48, - height: 36, - landingRadius: 22, - perfectRadius: 12, - scoreValue: 1, - }, - ], - finishIndex: 0, - cameraPreset: 'default', - scoring: { - chargeToDistanceRatio: 1.2, - maxChargeMs: 1800, - hitBonus: 20, - perfectBonus: 50, - }, - }, - characterAsset: { - assetId: 'character-1', - imageSrc: 'data:image/png;base64,character', - imageObjectKey: 'jump-hop/character.png', - assetObjectId: 'asset-character', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '角色图', - width: 1024, - height: 1024, - }, - tileAtlasAsset: { - assetId: 'tiles-1', - imageSrc: 'data:image/png;base64,tiles', - imageObjectKey: 'jump-hop/tiles.png', - assetObjectId: 'asset-tiles', - generationProvider: 'vector-engine-gpt-image-2', - prompt: '地块图', - width: 1024, - height: 1024, - }, - tileAssets: [ - { - tileType: 'start', - imageSrc: 'data:image/png;base64,tile-start', - imageObjectKey: 'jump-hop/tile-start.png', - assetObjectId: 'asset-tile-start', - sourceAtlasCell: 'A1', - visualWidth: 128, - visualHeight: 96, - topSurfaceRadius: 24, - landingRadius: 28, - }, - ], -}; +vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ + useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({ + resolvedUrl: source?.trim() ?? '', + isResolving: false, + shouldResolve: Boolean(source?.trim().startsWith('/generated-')), + })), +})); -const run: JumpHopRuntimeRunSnapshotResponse = { - runId: 'run-1', - profileId: 'profile-1', - ownerUserId: 'user-1', - status: 'playing', - currentPlatformIndex: 0, - score: 0, - combo: 0, - path: profile.path, - lastJump: null, - startedAtMs: 1000, - finishedAtMs: null, -}; +vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({ + useJumpHopLeaderboard: vi.fn(), +})); -test('jump hop runtime shell supports jump, restart and exit actions', async () => { - const user = userEvent.setup(); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useJumpHopLeaderboard).mockReturnValue({ + leaderboard: null, + isLoading: false, + error: null, + refresh: vi.fn(), + }); +}); + +function dispatchPointerEvent( + target: HTMLElement, + type: string, + options: { pointerId: number; clientX: number; clientY: number }, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.assign(event, options); + target.dispatchEvent(event); +} + +test('跳一跳运行态松手时提交向后拖动向量', async () => { + vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); - const onRestart = vi.fn(); - const onExit = vi.fn(); + const run = buildRun(); + const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []); + const current = visiblePlatforms[0]!; + const target = visiblePlatforms[1]!; + const stageSize = { width: 320, height: 568 }; + const xPixelsPerWorldUnit = + Math.abs( + ((target.screenX - current.screenX) / 100) * stageSize.width, + ) / Math.abs(target.platform.x - current.platform.x); + const yPixelsPerWorldUnit = + Math.abs( + ((target.screenY - current.screenY) / 100) * stageSize.height, + ) / Math.abs(target.platform.y - current.platform.y); render( {}} />, ); - await user.pointer([ - { target: screen.getByRole('button', { name: '起跳' }), keys: '[MouseLeft>]' }, - ]); - await user.pointer([ - { target: screen.getByRole('button', { name: '起跳' }), keys: '[/MouseLeft]' }, - ]); - - await waitFor(() => { - expect(onJump).toHaveBeenCalledWith({ chargeMs: expect.any(Number) }); + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); }); - await user.click(screen.getByRole('button', { name: '重开' })); - await user.click(screen.getByRole('button', { name: '返回' })); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); - expect(onRestart).toHaveBeenCalledTimes(1); - expect(onExit).toHaveBeenCalledTimes(1); + expect(onJump).toHaveBeenCalledTimes(1); + const jumpPayload = onJump.mock.calls[0]?.[0]; + expect(jumpPayload?.dragVectorX).toBeCloseTo(-48 / xPixelsPerWorldUnit, 2); + expect(jumpPayload?.dragVectorY).toBeCloseTo(58 / yPixelsPerWorldUnit, 2); + expect(jumpPayload?.dragDistance).toBeGreaterThan(74); + expect(jumpPayload?.dragDistance).toBeLessThan(76); + vi.useRealTimers(); +}); + +test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () => { + const onJump = vi.fn().mockResolvedValue(undefined); + const run = buildRun(); + const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []); + const current = visiblePlatforms[0]!; + const target = visiblePlatforms[1]!; + const stageSize = { width: 320, height: 568 }; + const xPixelsPerWorldUnit = + Math.abs( + ((target.screenX - current.screenX) / 100) * stageSize.width, + ) / Math.abs(target.platform.x - current.platform.x); + const yPixelsPerWorldUnit = + Math.abs( + ((target.screenY - current.screenY) / 100) * stageSize.height, + ) / Math.abs(target.platform.y - current.platform.y); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 40, + clientY: 40, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 10, + clientY: 20, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 10, + clientY: 20, + }); + }); + + const jumpPayload = onJump.mock.calls[0]?.[0]; + expect(jumpPayload?.dragVectorX).toBeLessThan(0); + expect(jumpPayload?.dragVectorY).toBeLessThan(0); + expect(Math.abs(jumpPayload?.dragVectorX ?? 0)).toBeLessThan(30); + expect(Math.abs(jumpPayload?.dragVectorY ?? 0)).toBeLessThan(20); + expect(jumpPayload?.dragVectorX).toBeCloseTo(-30 / xPixelsPerWorldUnit, 2); + expect(jumpPayload?.dragVectorY).toBeCloseTo(-20 / yPixelsPerWorldUnit, 2); +}); + +test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', async () => { + const onJump = vi.fn().mockResolvedValue(undefined); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + + expect(screen.queryByText('起跳')).toBeNull(); + expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); +}); + +test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => { + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + + const character = screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement; + const stretchTransform = character.style.getPropertyValue( + '--jump-hop-character-stretch-transform', + ); + const styleText = Array.from(document.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n'); + + expect(stretchTransform).toContain('matrix('); + expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)'); + expect(styleText).toContain('var(--jump-hop-character-stretch-transform)'); + expect(styleText).not.toContain( + 'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))', + ); +}); + +test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () => { + const runtimeRequestOptions = { + runtimeGuestToken: 'runtime-guest-token', + }; + + render( + {}} + />, + ); + + expect(useJumpHopLeaderboard).not.toHaveBeenCalled(); + expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy(); + expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull(); + expect(screen.queryByRole('button', { name: /重开/ })).toBeNull(); + expect(screen.queryByText('进行中')).toBeNull(); + expect(screen.queryByText('00:00')).toBeNull(); + expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull(); +}); + +test('跳一跳运行态背景和游戏舞台覆盖全部界面且 HUD 使用独立主题按钮和拼图顶部样式', () => { + const backButtonAsset = { + assetId: 'jump-hop-back-button', + imageSrc: '/generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png', + imageObjectKey: + 'generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png', + assetObjectId: 'asset-back-button', + generationProvider: 'vector-engine-gpt-image-2', + prompt: '主题返回按钮', + width: 1024, + height: 1024, + } satisfies NonNullable; + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + expect(stage.className).toContain('absolute'); + expect(stage.className).toContain('inset-0'); + expect(stage.className).toContain('h-full'); + expect(stage.className).toContain('w-full'); + expect(stage.className).not.toContain('rounded-[1.5rem]'); + + const backButton = screen.getByRole('button', { name: '返回' }); + expect(backButton.className).toContain('pointer-events-auto'); + expect(backButton.className).toContain('jump-hop-runtime__back-button'); + expect(backButton.className).toContain('h-14'); + expect(backButton.className).toContain('w-14'); + expect(backButton.className).toContain('sm:h-[3.875rem]'); + expect(backButton.className).toContain('sm:w-[3.875rem]'); + expect(backButton.getAttribute('data-has-asset')).toBe('true'); + expect(backButton.textContent).toBe(''); + expect( + screen + .getByTestId('jump-hop-runtime-back-button-asset') + .getAttribute('src'), + ).toBe(backButtonAsset.imageSrc); + + const header = backButton.closest('header'); + expect(header?.className).toContain('absolute'); + expect(header?.className).toContain('top-0'); + expect(header?.className).toContain('z-[130]'); + expect(header?.querySelector('.puzzle-runtime-header-card')).toBeTruthy(); + const titleCard = header?.querySelector('.puzzle-runtime-level-title-card'); + expect(titleCard).toBeTruthy(); + expect(titleCard?.className).toContain('jump-hop-runtime__score-title-card'); + expect(screen.getByTestId('jump-hop-runtime-level-logo')).toBeTruthy(); + expect(screen.getByText('得分')).toBeTruthy(); + expect(screen.queryByText('跳一跳')).toBeNull(); + + const scoreCard = screen.getByTestId('jump-hop-score-card'); + expect(scoreCard.className).toContain('puzzle-runtime-timer-card'); + expect(scoreCard.className).toContain('puzzle-runtime-timer'); + expect(scoreCard.className).toContain('jump-hop-runtime__score-value-card'); + expect(scoreCard.className).toContain('justify-center'); + expect(scoreCard.className).toContain('text-center'); +}); + +test('跳一跳运行态失败后在弹窗中展示排行榜', () => { + const runtimeRequestOptions = { + runtimeGuestToken: 'runtime-guest-token', + }; + vi.mocked(useJumpHopLeaderboard).mockReturnValue({ + leaderboard: { + profileId: 'jump-hop-profile-test', + items: [ + { + rank: 1, + playerId: 'player-1', + successfulJumpCount: 8, + durationMs: 8123, + updatedAt: '2026-05-27T00:00:00Z', + }, + ], + viewerBest: null, + }, + isLoading: false, + error: null, + refresh: vi.fn(), + }); + + render( + {}} + />, + ); + + expect(useJumpHopLeaderboard).toHaveBeenCalledWith( + 'jump-hop-profile-test', + runtimeRequestOptions, + ); + expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy(); + const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard'); + expect(leaderboard).toBeTruthy(); + expect(within(leaderboard).getByText('player-1')).toBeTruthy(); + expect(within(leaderboard).getByText('8 跳')).toBeTruthy(); + expect(within(leaderboard).getByText('00:08')).toBeTruthy(); +}); + +test('跳一跳草稿运行失败后不请求公开排行榜', () => { + render( + {}} + />, + ); + + expect(useJumpHopLeaderboard).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy(); + expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull(); +}); + +test('跳一跳角色层永远压在地块层之上', () => { + render( + {}} + />, + ); + + const threeScene = screen.getByTestId('jump-hop-three-scene'); + const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0] + ?.parentElement?.parentElement as HTMLElement | undefined; + + expect(threeScene.style.zIndex).toBe('100'); + expect(Number(threeScene.style.zIndex)).toBeGreaterThan( + Number(firstPlatform?.style.zIndex ?? 0), + ); +}); + +test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () => { + const onJump = vi.fn().mockResolvedValue(undefined); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 148, + clientY: 454, + }); + }); + + expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); + + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 112, + clientY: 492, + }); + }); + + expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy(); +}); + +test('跳一跳运行态直接渲染生成的地块切片图片', () => { + render( + {}} + />, + ); + + const tileImages = screen.getAllByTestId('jump-hop-tile-image'); + expect(tileImages).toHaveLength(3); + expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull(); + const generatedReadUrlCalls = vi + .mocked(useResolvedAssetReadUrl) + .mock.calls.filter(([source]) => + source?.includes('/generated-jump-hop-assets/'), + ); + expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3); + for (const [, options] of generatedReadUrlCalls) { + expect(options).toEqual( + expect.objectContaining({ + refreshKey: expect.stringMatching(/^asset-object-/), + }), + ); + } + + for (const image of tileImages) { + expect(image.getAttribute('src')).toContain( + '/generated-jump-hop-assets/jump-hop-profile-test/tile-', + ); + fireEvent.load(image); + expect(image.getAttribute('data-loaded')).toBe('true'); + } +}); + +test('跳一跳运行态提前预加载下一屏地块且不在真实图片加载前露出原型方块', () => { + render( + {}} + />, + ); + + expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(3); + expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull(); + const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image'); + expect(preloadImages.length).toBeGreaterThan(0); + expect(preloadImages[0]?.getAttribute('src')).toContain( + '/generated-jump-hop-assets/jump-hop-profile-test/tile-', + ); +}); + +test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => { + render( + {}} + />, + ); + + const tileImages = screen.getAllByTestId('jump-hop-tile-image'); + expect(tileImages).toHaveLength(3); + const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined; + const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined; + const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined; + expect(first?.style.top).toBe('78%'); + expect(second?.style.top).toBe('50%'); + expect(third?.style.top).toBe('22%'); +}); + +test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => { + render( + {}} + />, + ); + + const firstTile = screen.getAllByTestId('jump-hop-tile-image')[0] + ?.parentElement?.parentElement as HTMLElement | undefined; + + expect(firstTile?.style.width).toBe('116px'); + expect(firstTile?.style.height).toBe('96px'); + expect(firstTile?.style.getPropertyValue('--jump-hop-platform-scale')).toBe( + '1.08', + ); +}); + +test('跳一跳运行态使用陶泥儿透明 logo 作为角色形象', () => { + render( + {}} + />, + ); + + const logo = screen.getByTestId('jump-hop-character-logo'); + expect(logo.getAttribute('src')).toBe( + '/branding/jump-hop-taonier-character.png', + ); + expect( + screen.queryByTestId('jump-hop-character-fallback-shape'), + ).toBeNull(); +}); + +test('跳一跳蓄力和计时刷新不会重建三维画布宿主', async () => { + vi.useFakeTimers(); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + const canvas = screen.getByTestId('jump-hop-three-canvas'); + + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + vi.advanceTimersByTime(520); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 160, + clientY: 460, + }); + }); + + expect(screen.getByTestId('jump-hop-three-canvas')).toBe(canvas); + vi.useRealTimers(); +}); + +test('跳一跳后端回包较慢时角色停在目标点等待推进', async () => { + vi.useFakeTimers(); + const onJump = vi.fn().mockResolvedValue(undefined); + const initialRun = buildRun(); + const nextRun: JumpHopRuntimeRunSnapshotResponse = { + ...buildRun(), + currentPlatformIndex: 1, + successfulJumpCount: 1, + score: 1, + lastJump: { + chargeMs: 150, + jumpDistance: 1.44, + targetPlatformIndex: 1, + landedX: 0.8, + landedY: 1.2, + result: 'hit', + }, + }; + const { rerender } = render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(580); + }); + + const character = screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement; + expect(stage.getAttribute('data-jump-animating')).toBe('true'); + expect(stage.getAttribute('data-platform-advancing')).toBe('false'); + expect(Number.parseFloat(character.style.left)).not.toBeCloseTo(50, 2); + expect(character.style.getPropertyValue('--jump-hop-flight-from-x')).not.toBe( + '0px', + ); + expect(character.style.getPropertyValue('--jump-hop-flight-from-y')).not.toBe( + '0px', + ); + + rerender( + {}} + />, + ); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( + 'false', + ); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'true', + ); + vi.useRealTimers(); +}); + +test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => { + vi.useFakeTimers(); + const onJump = vi.fn().mockResolvedValue(undefined); + const initialRun = buildRun(); + const nextRun: JumpHopRuntimeRunSnapshotResponse = { + ...buildRun(), + currentPlatformIndex: 1, + successfulJumpCount: 1, + score: 1, + lastJump: { + chargeMs: 150, + jumpDistance: 1.44, + targetPlatformIndex: 1, + landedX: 0.8, + landedY: 1.2, + result: 'hit', + }, + }; + const { rerender } = render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointermove', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + await act(async () => { + dispatchPointerEvent(stage, 'pointerup', { + pointerId: 1, + clientX: 132, + clientY: 478, + }); + }); + + expect(onJump).toHaveBeenCalledTimes(1); + expect(stage.getAttribute('data-jump-animating')).toBe('true'); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( + '78%', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( + 'true', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p0', + ); + + rerender( + {}} + />, + ); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( + 'true', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( + '78%', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( + 'true', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p0', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(580); + }); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( + 'false', + ); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'true', + ); + const landedCharacter = screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement; + expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true'); + expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe( + '0px', + ); + expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-y')).not.toBe( + '0px', + ); + const cameraLayer = screen.getByTestId('jump-hop-camera-layer'); + expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true'); + expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe( + '-28%', + ); + expect( + Number.parseFloat( + cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'), + ), + ).toBeCloseTo(12.29, 2); + const styleText = Array.from(document.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n'); + expect(styleText).toContain('@keyframes jump-hop-character-recoil'); + expect(styleText).toMatch( + /data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/, + ); + expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull(); + const advancingCharacterRule = styleText.match( + /\.jump-hop-runtime__stage\[data-platform-advancing='true'\]\s+\.jump-hop-runtime__character\s*\{(?[\s\S]*?)\}/, + )?.groups?.body; + expect(advancingCharacterRule).toContain('transform 120ms ease'); + expect(advancingCharacterRule).toContain('opacity 160ms ease'); + expect(advancingCharacterRule).not.toContain('left'); + expect(advancingCharacterRule).not.toContain('top'); + expect(screen.getByTestId('jump-hop-three-scene').parentElement).toBe( + cameraLayer, + ); + expect( + screen + .getByTestId('jump-hop-stage') + .querySelector("[data-advance-state='settling']"), + ).toBeNull(); + expect( + screen + .getByTestId('jump-hop-stage') + .querySelector("[data-advance-state='entering']"), + ).toBeNull(); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p0', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p1', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe( + '78%', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe( + '1.08', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p2', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe( + '50%', + ); + + await act(async () => { + vi.advanceTimersByTime(720); + }); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'true', + ); + expect( + ( + screen.getByTestId('jump-hop-character-logo') + .parentElement as HTMLElement + ).getAttribute('data-landing-recoil'), + ).toBe('false'); + + await act(async () => { + vi.advanceTimersByTime(660); + }); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'true', + ); + + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'false', + ); + expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe( + 'false', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe( + '78%', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe( + 'true', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( + 'p1', + ); + expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe( + '50%', + ); + + vi.useRealTimers(); +}); + +function buildRun(): JumpHopRuntimeRunSnapshotResponse { + return { + runId: 'jump-hop-run-test', + profileId: 'jump-hop-profile-test', + ownerUserId: 'user-test', + status: 'playing', + currentPlatformIndex: 0, + successfulJumpCount: 0, + durationMs: 0, + score: 0, + combo: 0, + path: { + seed: 'test', + difficulty: 'standard', + finishIndex: 4294967295, + cameraPreset: 'portrait-isometric-9x16', + scoring: { + chargeToDistanceRatio: 0.004, + maxChargeMs: 900, + hitBonus: 20, + perfectBonus: 60, + }, + platforms: [ + { + platformId: 'p0', + tileType: 'start', + x: 0, + y: 0, + width: 1, + height: 1, + landingRadius: 0.5, + perfectRadius: 0.2, + scoreValue: 1, + }, + { + platformId: 'p1', + tileType: 'normal', + x: 0.8, + y: 1.2, + width: 1, + height: 1, + landingRadius: 0.5, + perfectRadius: 0.2, + scoreValue: 1, + }, + { + platformId: 'p2', + tileType: 'target', + x: -0.2, + y: 2.4, + width: 1, + height: 1, + landingRadius: 0.5, + perfectRadius: 0.2, + scoreValue: 1, + }, + ], + }, + lastJump: null, + startedAtMs: 1000, + finishedAtMs: null, + }; +} + +function buildFailedRun(): JumpHopRuntimeRunSnapshotResponse { + return { + ...buildRun(), + status: 'failed', + successfulJumpCount: 8, + durationMs: 8123, + score: 8, + combo: 0, + lastJump: { + chargeMs: 420, + jumpDistance: 1.62, + targetPlatformIndex: 1, + landedX: 0, + landedY: 0, + result: 'miss', + }, + finishedAtMs: 9123, + }; +} + +function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse { + const run = buildRun(); + return { + ...run, + path: { + ...run.path, + platforms: [ + ...run.path.platforms, + { + platformId: 'p3', + tileType: 'normal', + x: 0.5, + y: 3.6, + width: 1, + height: 1, + landingRadius: 0.5, + perfectRadius: 0.2, + scoreValue: 1, + }, + ], + }, + }; +} + +function buildTileAssets() { + return Array.from({ length: 25 }, (_, index) => { + const tileNumber = String(index + 1).padStart(2, '0'); + return { + tileType: index === 0 ? 'start' : 'normal', + tileId: `tile-${tileNumber}`, + imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`, + imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`, + assetObjectId: `asset-object-${tileNumber}`, + sourceAtlasCell: `row-${Math.floor(index / 5) + 1}-col-${(index % 5) + 1}`, + atlasRow: Math.floor(index / 5) + 1, + atlasCol: (index % 5) + 1, + visualWidth: 256, + visualHeight: 192, + topSurfaceRadius: 42, + landingRadius: 34, + } satisfies JumpHopWorkProfileResponse['tileAssets'][number]; + }); +} + +function buildProfile(options: { + tileAssets?: JumpHopWorkProfileResponse['tileAssets']; + coverComposite?: string | null; + coverImageSrc?: string | null; + backButtonAsset?: JumpHopWorkProfileResponse['backButtonAsset']; + publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus']; +} = {}): JumpHopWorkProfileResponse { + const characterAsset = { + assetId: 'builtin', + imageSrc: 'builtin://jump-hop/default-character', + imageObjectKey: '', + assetObjectId: 'builtin', + generationProvider: 'builtin-three', + prompt: '默认角色', + width: 0, + height: 0, + }; + return { + summary: { + runtimeKind: 'jump-hop', + workId: 'jump-hop-profile-test', + profileId: 'jump-hop-profile-test', + ownerUserId: 'user-test', + sourceSessionId: 'jump-hop-session-test', + themeText: '测试', + workTitle: '测试', + workDescription: '测试', + themeTags: ['测试'], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: options.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, + tileAtlasAsset: characterAsset, + tileAssets: options.tileAssets ?? [], + path: buildRun().path, + coverComposite: options.coverComposite ?? null, + backButtonAsset: options.backButtonAsset ?? null, + generationStatus: 'ready', + }, + path: buildRun().path, + defaultCharacter: { + characterId: 'jump-hop-default-runner', + displayName: '默认角色', + modelKind: 'builtin-three', + bodyColor: '#f59e0b', + accentColor: '#2563eb', + }, + characterAsset, + tileAtlasAsset: characterAsset, + tileAssets: options.tileAssets ?? [], + backButtonAsset: options.backButtonAsset ?? null, + }; +} + +test('跳一跳运行态使用 image2 背景底图铺满舞台底层', () => { + const backgroundSource = + '/generated-jump-hop-assets/jump-hop-profile-test/background/image.png'; + + render( + {}} + />, + ); + + const backgroundImage = screen.getByTestId('jump-hop-stage-background-image'); + expect(backgroundImage.getAttribute('src')).toBe(backgroundSource); + const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop'); + expect(backdrop?.getAttribute('data-has-background')).toBe('true'); + expect(useResolvedAssetReadUrl).toHaveBeenCalledWith( + backgroundSource, + expect.objectContaining({ + refreshKey: backgroundSource, + }), + ); +}); + +test('跳一跳运行态忽略旧 cover composite 占位背景', () => { + render( + {}} + />, + ); + + expect(screen.queryByTestId('jump-hop-stage-background-image')).toBeNull(); + const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop'); + expect(backdrop?.getAttribute('data-has-background')).toBe('false'); }); diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 7d99810c..09f47e17 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -1,19 +1,52 @@ -import { ArrowLeft, Hand, Loader2, RotateCcw } from 'lucide-react'; +import { ArrowLeft, Loader2 } from 'lucide-react'; import { type CSSProperties, + type Dispatch, type PointerEvent, + type SetStateAction, + useCallback, useEffect, useMemo, useRef, useState, } from 'react'; +import jumpHopRuntimeLevelLogo from '../../../media/logo.png'; import type { - JumpHopPlatform, JumpHopRuntimeRunSnapshotResponse, JumpHopTileAsset, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import type { JumpHopRuntimeRequestOptions } from '../../services/jump-hop/jumpHopClient'; +import { + buildJumpHopVisiblePlatforms, + formatJumpHopDurationLabel, + getJumpHopBackendDragVector, + getJumpHopCharacterVisualPosition, + getJumpHopJumpFeedbackLabel, + getJumpHopLandingAssistVisualPosition, + getJumpHopPlatformVisualSize, + getJumpHopRunDurationMs, + getJumpHopStatusLabel, + getJumpHopTileTone, + selectJumpHopTileAsset, + type JumpHopCharacterVisualPosition, + type JumpHopVisiblePlatform, + resolveJumpHopCharacterCanvasPosition, +} from '../../services/jump-hop/jumpHopRuntimeModel'; +import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; + +type JumpHopRuntimeJumpPayload = { + dragDistance: number; + dragVectorX: number; + dragVectorY: number; +}; + +type JumpHopVisualJump = { + from: JumpHopCharacterVisualPosition; + to: JumpHopCharacterVisualPosition; +}; type JumpHopRuntimeShellProps = { profile?: JumpHopWorkProfileResponse | null; @@ -21,38 +54,63 @@ type JumpHopRuntimeShellProps = { snapshot?: JumpHopRuntimeRunSnapshotResponse | null; isBusy?: boolean; error?: string | null; - onJump: (payload: { chargeMs: number }) => Promise; + runtimeRequestOptions?: JumpHopRuntimeRequestOptions; + onJump: (payload: JumpHopRuntimeJumpPayload) => Promise; onRestart: () => void; onExit?: () => void; onBack?: () => void; }; -type VisiblePlatform = { - platform: JumpHopPlatform; - index: number; - screenX: number; - screenY: number; - scale: number; - asset: JumpHopTileAsset | null; -}; - const MAX_CHARGE_RATIO = 1; -const DEFAULT_MAX_CHARGE_MS = 1800; -const VISIBLE_FORWARD_COUNT = 6; - -const tileToneByType: Record = { - accent: '#e0f2fe', - bonus: '#fef3c7', - finish: '#dcfce7', - normal: '#f8fafc', - start: '#e0f2fe', - target: '#fee2e2', -}; +const DEFAULT_MAX_DRAG_DISTANCE_PX = 180; +const JUMP_HOP_ANIMATION_DURATION_MS = 560; +const JUMP_HOP_LANDING_RECOIL_DURATION_MS = 560; +const JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS = 1440; +const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC = + '/branding/jump-hop-taonier-character.png'; +const JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT = 3; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +function formatJumpHopCssNumber(value: number) { + if (!Number.isFinite(value)) { + return '0'; + } + return value.toFixed(4).replace(/\.?0+$/, ''); +} + +function buildJumpHopDirectionalScaleMatrix({ + directionX, + directionY, + stretchScale, + crossScale, +}: { + directionX: number; + directionY: number; + stretchScale: number; + crossScale: number; +}) { + const distance = Math.hypot(directionX, directionY); + if (distance < 0.1) { + return 'matrix(1, 0, 0, 1, 0, 0)'; + } + + const unitX = directionX / distance; + const unitY = directionY / distance; + const stretchDelta = stretchScale - crossScale; + const a = crossScale + stretchDelta * unitX * unitX; + const b = stretchDelta * unitX * unitY; + const c = stretchDelta * unitX * unitY; + const d = crossScale + stretchDelta * unitY * unitY; + return `matrix(${formatJumpHopCssNumber(a)}, ${formatJumpHopCssNumber( + b, + )}, ${formatJumpHopCssNumber(c)}, ${formatJumpHopCssNumber( + d, + )}, 0, 0)`; +} + function getRun( run: JumpHopRuntimeRunSnapshotResponse | null | undefined, snapshot: JumpHopRuntimeRunSnapshotResponse | null | undefined, @@ -60,121 +118,66 @@ function getRun( return run ?? snapshot ?? null; } -function buildTileAssetMap(tileAssets: JumpHopTileAsset[] | undefined) { - const map = new Map(); - for (const asset of tileAssets ?? []) { - if (!map.has(asset.tileType)) { - map.set(asset.tileType, asset); - } - } - return map; -} - -function getStatusLabel( - status: JumpHopRuntimeRunSnapshotResponse['status'] | undefined, +function hasJumpHopRunDisplayChange( + current: JumpHopRuntimeRunSnapshotResponse, + next: JumpHopRuntimeRunSnapshotResponse, ) { - if (status === 'cleared') { - return '通关'; - } - if (status === 'failed') { - return '失败'; - } - return '进行中'; -} - -function getJumpFeedback(run: JumpHopRuntimeRunSnapshotResponse | null) { - const result = run?.lastJump?.result; - if (result === 'perfect') { - return 'Perfect'; - } - if (result === 'finish') { - return 'Finish'; - } - if (result === 'hit') { - return 'Hit'; - } - if (result === 'miss') { - return 'Miss'; - } - return null; -} - -function projectPlatformPath( - platforms: JumpHopPlatform[], - currentIndex: number, - tileAssetMap: Map, -) { - const current = platforms[currentIndex] ?? platforms[0]; - if (!current) { - return []; - } - - const start = Math.max(0, currentIndex - 1); - const end = Math.min(platforms.length, currentIndex + VISIBLE_FORWARD_COUNT); - const visible = platforms.slice(start, end); - const worldScale = 0.86; - - return visible.map((platform, offset): VisiblePlatform => { - const index = start + offset; - const dx = platform.x - current.x; - const dy = platform.y - current.y; - const isoX = (dx - dy) * worldScale; - const isoY = (dx + dy) * 0.46 * worldScale; - const depth = index - currentIndex; - - return { - platform, - index, - screenX: 50 + isoX, - screenY: 58 + isoY - depth * 0.8, - scale: clamp(1 - Math.max(0, depth) * 0.035, 0.78, 1.08), - asset: - tileAssetMap.get(platform.tileType) ?? - tileAssetMap.get('normal') ?? - tileAssetMap.get('start') ?? - null, - }; - }); -} - -function getCharacterPosition( - run: JumpHopRuntimeRunSnapshotResponse | null, - platforms: VisiblePlatform[], -) { - if (!run) { - return null; - } - - const landedPlatform = platforms.find( - (item) => item.index === run.currentPlatformIndex, + return ( + current.currentPlatformIndex !== next.currentPlatformIndex || + current.status !== next.status || + current.successfulJumpCount !== next.successfulJumpCount || + current.durationMs !== next.durationMs || + current.score !== next.score || + current.combo !== next.combo || + current.finishedAtMs !== next.finishedAtMs || + current.lastJump?.targetPlatformIndex !== next.lastJump?.targetPlatformIndex || + current.lastJump?.result !== next.lastJump?.result || + current.lastJump?.chargeMs !== next.lastJump?.chargeMs ); - if (landedPlatform) { +} + +function shouldAnimateJumpHopPlatformAdvance( + current: JumpHopRuntimeRunSnapshotResponse, + next: JumpHopRuntimeRunSnapshotResponse, +) { + return ( + current.runId === next.runId && + next.currentPlatformIndex > current.currentPlatformIndex && + next.status === 'playing' + ); +} + +function buildJumpHopCharacterVisualPositionFromPlatform( + platform: JumpHopVisiblePlatform, + isMiss = false, +): JumpHopCharacterVisualPosition { + if (isMiss) { return { - x: landedPlatform.screenX, - y: landedPlatform.screenY - 8, - isMiss: false, + screenX: platform.screenX + 8, + screenY: platform.screenY - 2, + sceneX: platform.sceneX + 0.7, + sceneY: platform.sceneY + 0.48, + sceneZ: platform.sceneZ - 0.4, + isMiss: true, }; } - const lastJump = run.lastJump; - if (lastJump && run.status === 'failed') { - const targetPlatform = platforms.find( - (item) => item.index === lastJump.targetPlatformIndex, - ); - if (targetPlatform) { - return { - x: targetPlatform.screenX + 8, - y: targetPlatform.screenY - 2, - isMiss: true, - }; - } - } - - return null; + return { + screenX: platform.screenX, + screenY: platform.screenY - 3, + sceneX: platform.sceneX, + sceneY: platform.sceneY + 0.84, + sceneZ: platform.sceneZ, + isMiss: false, + }; } -function IsometricFallbackTile({ platform }: { platform: JumpHopPlatform }) { - const tone = tileToneByType[platform.tileType] ?? tileToneByType.normal; +function IsometricFallbackTile({ + platform, +}: { + platform: JumpHopVisiblePlatform['platform']; +}) { + const tone = getJumpHopTileTone(platform.tileType); const style = { '--jump-hop-tile-tone': tone, } as CSSProperties; @@ -192,343 +195,1550 @@ function IsometricFallbackTile({ platform }: { platform: JumpHopPlatform }) { ); } +function getJumpHopTileAssetRefreshKey(asset: JumpHopTileAsset | null) { + return asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null; +} + +function isJumpHopGeneratedBackgroundSource(source: string | null | undefined) { + const value = source?.trim() ?? ''; + if (!value) { + return false; + } + return !( + value.startsWith('/generated-jump-hop-assets/') && + (value.endsWith('/cover-composite.png') || value.includes('/cover-composite-')) + ); +} + +function JumpHopTileImage({ + asset, + platform, +}: { + asset: JumpHopTileAsset | null; + platform: JumpHopVisiblePlatform['platform']; +}) { + const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset); + const { resolvedUrl } = useResolvedAssetReadUrl(asset?.imageSrc, { + refreshKey: assetRefreshKey, + }); + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + setIsLoaded(false); + setHasError(false); + }, [resolvedUrl]); + + const shouldShowImage = Boolean(resolvedUrl && !hasError); + const shouldShowFallback = !shouldShowImage; + + return ( +
+ {shouldShowFallback ? : null} + {shouldShowImage ? ( + { + setIsLoaded(true); + }} + onError={() => { + setHasError(true); + }} + /> + ) : null} +
+ ); +} + +function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) { + const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset); + const { resolvedUrl } = useResolvedAssetReadUrl(asset.imageSrc, { + refreshKey: assetRefreshKey, + }); + + if (!resolvedUrl) { + return null; + } + + return ( + + ); +} + +function hasJumpHopWebGLSupport() { + if (import.meta.env.MODE === 'test') { + return false; + } + + try { + const canvas = document.createElement('canvas'); + return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')); + } catch { + return false; + } +} + +function applyJumpHopCanvasLayout(canvas: HTMLCanvasElement) { + canvas.style.display = 'block'; + canvas.style.height = '100%'; + canvas.style.inset = '0'; + canvas.style.position = 'absolute'; + canvas.style.width = '100%'; +} + +function disposeJumpHopThreeObject(object: import('three').Object3D) { + object.traverse((child) => { + const mesh = child as import('three').Mesh; + mesh.geometry?.dispose(); + const material = mesh.material; + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material?.dispose(); + } + }); +} + +function JumpHopThreeScene({ + characterPosition, + chargeRatio, + isJumpAnimating, + platformCount, + renderCharacter, + onCharacterLayerReadyChange, +}: { + characterPosition: JumpHopCharacterVisualPosition | null; + chargeRatio: number; + isJumpAnimating: boolean; + platformCount: number; + renderCharacter: boolean; + onCharacterLayerReadyChange: Dispatch>; +}) { + const hostRef = useRef(null); + const characterPositionRef = useRef(characterPosition); + const chargeRatioRef = useRef(chargeRatio); + const isJumpAnimatingRef = useRef(isJumpAnimating); + + useEffect(() => { + characterPositionRef.current = characterPosition; + }, [characterPosition]); + + useEffect(() => { + chargeRatioRef.current = chargeRatio; + }, [chargeRatio]); + + useEffect(() => { + isJumpAnimatingRef.current = isJumpAnimating; + }, [isJumpAnimating]); + + useEffect(() => { + const host = hostRef.current; + if (!host) { + return undefined; + } + + onCharacterLayerReadyChange(false); + host.replaceChildren(); + const fallbackCanvas = document.createElement('canvas'); + applyJumpHopCanvasLayout(fallbackCanvas); + fallbackCanvas.setAttribute('data-testid', 'jump-hop-three-canvas'); + host.appendChild(fallbackCanvas); + + if (!renderCharacter || !hasJumpHopWebGLSupport()) { + return () => { + onCharacterLayerReadyChange(false); + fallbackCanvas.remove(); + }; + } + + let disposed = false; + let animationId: number | null = null; + let cleanup: (() => void) | null = null; + + const setup = async () => { + const three = await import('three'); + if (disposed || !hostRef.current) { + return; + } + + const renderer = new three.WebGLRenderer({ + alpha: true, + antialias: true, + canvas: fallbackCanvas, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); + renderer.outputColorSpace = three.SRGBColorSpace; + + const scene = new three.Scene(); + scene.background = null; + + const camera = new three.OrthographicCamera(0, 320, 0, 568, -100, 100); + camera.position.set(0, 0, 50); + camera.lookAt(0, 0, 0); + + scene.add(new three.AmbientLight(0xffffff, 1.45)); + const keyLight = new three.DirectionalLight(0xffffff, 2.2); + keyLight.position.set(-80, 120, 80); + scene.add(keyLight); + const rimLight = new three.DirectionalLight(0xffedd5, 0.8); + rimLight.position.set(120, 80, 60); + scene.add(rimLight); + + const character = new three.Group(); + const body = new three.Mesh( + new three.CapsuleGeometry(10, 22, 8, 18), + new three.MeshStandardMaterial({ + color: 0xdf7f40, + roughness: 0.74, + }), + ); + body.position.y = -28; + const head = new three.Mesh( + new three.SphereGeometry(11, 28, 20), + new three.MeshStandardMaterial({ + color: 0xf59e0b, + roughness: 0.7, + }), + ); + head.position.y = -62; + const accent = new three.Mesh( + new three.BoxGeometry(15, 7, 7), + new three.MeshStandardMaterial({ + color: 0x2563eb, + roughness: 0.64, + }), + ); + accent.position.set(0, -36, 10); + character.add(body, head, accent); + scene.add(character); + + const size = { + width: 320, + height: 568, + }; + const resize = () => { + const rect = host.getBoundingClientRect(); + const width = Math.max(1, rect.width || host.clientWidth || 320); + const height = Math.max(1, rect.height || host.clientHeight || 568); + size.width = width; + size.height = height; + renderer.setSize(width, height, false); + camera.left = 0; + camera.right = width; + camera.top = 0; + camera.bottom = height; + camera.updateProjectionMatrix(); + renderer.render(scene, camera); + }; + + const resizeObserver = window.ResizeObserver + ? new window.ResizeObserver(resize) + : null; + resizeObserver?.observe(host); + resize(); + onCharacterLayerReadyChange(true); + + const animate = () => { + const nextCharacterPosition = characterPositionRef.current; + if (nextCharacterPosition) { + const nextChargeRatio = chargeRatioRef.current; + const canvasPosition = resolveJumpHopCharacterCanvasPosition( + nextCharacterPosition, + size, + ); + character.visible = true; + character.position.set(canvasPosition?.x ?? 0, canvasPosition?.y ?? 0, 0); + if (isJumpAnimatingRef.current) { + const now = window.performance.now(); + character.rotation.z = Math.sin(now / 42) * 1.22; + character.rotation.x = Math.sin(now / 28) * 0.28; + character.rotation.y = Math.sin(now / 34) * 0.2; + character.position.y += Math.sin(now / 26) * 8 - 14; + } else { + character.rotation.z = nextCharacterPosition.isMiss ? -0.32 : 0; + character.rotation.x = 0; + character.rotation.y = 0; + } + character.scale.set( + 1 + nextChargeRatio * 0.08, + 1 - nextChargeRatio * 0.12, + 1 + nextChargeRatio * 0.08, + ); + } else { + character.visible = false; + } + renderer.render(scene, camera); + animationId = window.requestAnimationFrame(animate); + }; + animate(); + + cleanup = () => { + if (animationId != null) { + window.cancelAnimationFrame(animationId); + } + resizeObserver?.disconnect(); + disposeJumpHopThreeObject(scene); + renderer.dispose(); + onCharacterLayerReadyChange(false); + }; + }; + + void setup(); + + return () => { + disposed = true; + cleanup?.(); + fallbackCanvas.remove(); + host.replaceChildren(); + }; + }, [onCharacterLayerReadyChange, renderCharacter]); + + return ( +
+ ); +} + +function JumpHopLeaderboardPanel({ + profileId, + runtimeRequestOptions, +}: { + profileId?: string | null; + runtimeRequestOptions?: JumpHopRuntimeRequestOptions; +}) { + const { leaderboard, isLoading, error } = useJumpHopLeaderboard( + profileId, + runtimeRequestOptions, + ); + const items = leaderboard?.items ?? []; + + return ( + + ); +} + export function JumpHopRuntimeShell({ profile = null, run, snapshot, isBusy = false, error = null, + runtimeRequestOptions, onExit, onBack, onRestart, onJump, }: JumpHopRuntimeShellProps) { const activeRun = getRun(run, snapshot); + const [displayRun, setDisplayRun] = useState(activeRun); + const [isJumpAnimating, setIsJumpAnimating] = useState(false); + const [isLandingRecoilAnimating, setIsLandingRecoilAnimating] = + useState(false); const [isCharging, setIsCharging] = useState(false); - const [chargeMs, setChargeMs] = useState(0); - const chargeStartRef = useRef(null); - - const maxChargeMs = - activeRun?.path.scoring.maxChargeMs && - activeRun.path.scoring.maxChargeMs > 0 - ? activeRun.path.scoring.maxChargeMs - : DEFAULT_MAX_CHARGE_MS; - const chargeRatio = clamp(chargeMs / maxChargeMs, 0, MAX_CHARGE_RATIO); - const canJump = Boolean( - activeRun && activeRun.status === 'playing' && !isBusy, + const [dragDistance, setDragDistance] = useState(0); + const [visualJump, setVisualJump] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [isThreeCharacterLayerReady, setIsThreeCharacterLayerReady] = + useState(false); + const [dragPointerPosition, setDragPointerPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [dragVector, setDragVector] = useState({ x: 0, y: 0 }); + const [jumpAnimationProgress, setJumpAnimationProgress] = useState(0); + const [isPlatformAdvancing, setIsPlatformAdvancing] = useState(false); + const [platformAdvanceExitingPlatforms, setPlatformAdvanceExitingPlatforms] = + useState([]); + const [platformAdvanceCameraOffsetX, setPlatformAdvanceCameraOffsetX] = + useState(0); + const [platformAdvanceCameraOffsetY, setPlatformAdvanceCameraOffsetY] = + useState(0); + const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); + const stageRef = useRef(null); + const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const dragCurrentRef = useRef<{ x: number; y: number } | null>(null); + const animationFrameRef = useRef(null); + const animationEndTimerRef = useRef(null); + const landingRecoilEndTimerRef = useRef(null); + const animationStartAtRef = useRef(0); + const hasJumpAnimationReachedTargetRef = useRef(false); + const platformAdvanceEndTimerRef = useRef(null); + const activeRunRef = useRef(activeRun); + const displayRunRef = useRef(displayRun); + const visiblePlatformsRef = useRef([]); + const tileAssetsRef = useRef(profile?.tileAssets); + const stageBackgroundSource = [ + profile?.draft.coverComposite, + profile?.summary.coverImageSrc, + ].find(isJumpHopGeneratedBackgroundSource); + const { resolvedUrl: stageBackgroundUrl } = useResolvedAssetReadUrl( + stageBackgroundSource, + { + refreshKey: stageBackgroundSource, + }, ); - const exitHandler = onExit ?? onBack; - const tileAssetMap = useMemo( - () => buildTileAssetMap(profile?.tileAssets), - [profile?.tileAssets], + const backButtonAssetSource = + profile?.backButtonAsset?.imageSrc?.trim() || + profile?.draft.backButtonAsset?.imageSrc?.trim() || + null; + const { resolvedUrl: backButtonAssetUrl } = useResolvedAssetReadUrl( + backButtonAssetSource, + { + refreshKey: + profile?.backButtonAsset?.assetObjectId || + profile?.draft.backButtonAsset?.assetObjectId || + backButtonAssetSource || + undefined, + }, ); - const visiblePlatforms = useMemo( - () => - projectPlatformPath( - activeRun?.path.platforms ?? [], - activeRun?.currentPlatformIndex ?? 0, - tileAssetMap, - ), - [activeRun?.currentPlatformIndex, activeRun?.path.platforms, tileAssetMap], - ); - const characterPosition = getCharacterPosition(activeRun, visiblePlatforms); - const jumpFeedback = getJumpFeedback(activeRun); - const isSettled = - activeRun?.status === 'failed' || activeRun?.status === 'cleared'; useEffect(() => { - if (!isCharging) { + activeRunRef.current = activeRun; + }, [activeRun]); + + useEffect(() => { + displayRunRef.current = displayRun; + }, [displayRun]); + + const stageRun = displayRun ?? activeRun; + const maxDragDistancePx = + stageRun?.path.scoring.maxChargeMs && stageRun.path.scoring.maxChargeMs > 0 + ? stageRun.path.scoring.maxChargeMs + : DEFAULT_MAX_DRAG_DISTANCE_PX; + const chargeRatio = clamp( + dragDistance / maxDragDistancePx, + 0, + MAX_CHARGE_RATIO, + ); + const canJump = Boolean( + activeRun && + activeRun.status === 'playing' && + !isBusy && + !isJumpAnimating && + !isPlatformAdvancing, + ); + const exitHandler = onExit ?? onBack; + const visiblePlatforms = useMemo( + () => + buildJumpHopVisiblePlatforms( + stageRun?.path, + stageRun?.currentPlatformIndex ?? 0, + profile?.tileAssets, + ), + [profile?.tileAssets, stageRun?.currentPlatformIndex, stageRun?.path], + ); + const platformRenderItems = useMemo(() => { + const exitingItems = platformAdvanceExitingPlatforms.map((item) => ({ + ...item, + renderKey: item.platform.platformId, + advanceState: 'exiting' as const, + })); + const visibleItems = visiblePlatforms.map((item) => ({ + ...item, + renderKey: item.platform.platformId, + advanceState: isPlatformAdvancing ? ('camera' as const) : ('idle' as const), + })); + + return [...exitingItems, ...visibleItems]; + }, [ + isPlatformAdvancing, + platformAdvanceExitingPlatforms, + visiblePlatforms, + ]); + const preloadTileAssets = useMemo(() => { + const path = stageRun?.path; + const tileAssets = profile?.tileAssets; + const platforms = path?.platforms ?? []; + const startIndex = + (stageRun?.currentPlatformIndex ?? 0) + visiblePlatforms.length; + const assets = new Map(); + + for ( + let index = startIndex; + index < + Math.min( + platforms.length, + startIndex + JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT, + ); + index += 1 + ) { + const platform = platforms[index]; + if (!platform) { + continue; + } + const asset = selectJumpHopTileAsset( + tileAssets, + path?.seed ?? null, + index, + platform.platformId, + ); + if (!asset) { + continue; + } + const key = getJumpHopTileAssetRefreshKey(asset) ?? asset.imageSrc; + assets.set(key, asset); + } + + return [...assets.values()]; + }, [ + profile?.tileAssets, + stageRun?.currentPlatformIndex, + stageRun?.path, + visiblePlatforms.length, + ]); + const characterPosition = getJumpHopCharacterVisualPosition( + stageRun, + visiblePlatforms, + ); + const jumpTargetPlatform = useMemo(() => { + if (!stageRun) { + return null; + } + return ( + visiblePlatforms.find( + (item) => item.index === stageRun.currentPlatformIndex + 1, + ) ?? null + ); + }, [stageRun, visiblePlatforms]); + const visualCharacterPosition = useMemo(() => { + if (!characterPosition) { + return null; + } + if (isJumpAnimating && visualJump) { + return visualJump.to; + } + if (!isJumpAnimating || !jumpTargetPlatform) { + return characterPosition; + } + + const targetCharacterPosition = buildJumpHopCharacterVisualPositionFromPlatform( + jumpTargetPlatform, + false, + ); + const easedProgress = 1 - Math.pow(1 - clamp(jumpAnimationProgress, 0, 1), 3); + const arcOffset = Math.sin(Math.PI * easedProgress) * -24; + + return { + screenX: + characterPosition.screenX + + (targetCharacterPosition.screenX - characterPosition.screenX) * easedProgress, + screenY: + characterPosition.screenY + + (targetCharacterPosition.screenY - characterPosition.screenY) * easedProgress + + arcOffset, + sceneX: + characterPosition.sceneX + + (targetCharacterPosition.sceneX - characterPosition.sceneX) * easedProgress, + sceneY: + characterPosition.sceneY + + (targetCharacterPosition.sceneY - characterPosition.sceneY) * easedProgress, + sceneZ: + characterPosition.sceneZ + + (targetCharacterPosition.sceneZ - characterPosition.sceneZ) * easedProgress, + isMiss: characterPosition.isMiss, + }; + }, [ + characterPosition, + isJumpAnimating, + jumpAnimationProgress, + jumpTargetPlatform, + visualJump, + ]); + const landingAssistStageSize = + stageSize.width > 0 && stageSize.height > 0 + ? stageSize + : { width: 320, height: 568 }; + const characterMotionStyle = useMemo(() => { + const idleTransform = 'matrix(1, 0, 0, 1, 0, 0)'; + const recoilDistance = Math.hypot(dragVector.x, dragVector.y); + const recoilUnitX = recoilDistance > 0 ? dragVector.x / recoilDistance : 0; + const recoilUnitY = recoilDistance > 0 ? dragVector.y / recoilDistance : 0; + let stretchTransform = idleTransform; + + if (isCharging && dragPointerPosition && characterPosition) { + const anchorX = + landingAssistStageSize.width * (characterPosition.screenX / 100); + const anchorY = + landingAssistStageSize.height * (characterPosition.screenY / 100); + stretchTransform = buildJumpHopDirectionalScaleMatrix({ + directionX: dragPointerPosition.x - anchorX, + directionY: dragPointerPosition.y - anchorY, + stretchScale: 1 + chargeRatio * 0.62, + crossScale: 1 - chargeRatio * 0.16, + }); + } + + return { + stretchTransform, + flightFromX: visualJump + ? `${formatJumpHopCssNumber( + ((visualJump.from.screenX - visualJump.to.screenX) / 100) * + landingAssistStageSize.width, + )}px` + : '0px', + flightFromY: visualJump + ? `${formatJumpHopCssNumber( + ((visualJump.from.screenY - visualJump.to.screenY) / 100) * + landingAssistStageSize.height, + )}px` + : '0px', + recoilX: `${formatJumpHopCssNumber(recoilUnitX * 11)}px`, + recoilY: `${formatJumpHopCssNumber(recoilUnitY * 11)}px`, + }; + }, [ + chargeRatio, + characterPosition, + dragPointerPosition, + dragVector.x, + dragVector.y, + isCharging, + landingAssistStageSize.height, + landingAssistStageSize.width, + visualJump, + ]); + const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun); + const isSettled = + stageRun?.status === 'failed' || stageRun?.status === 'cleared'; + const shouldShowFailureLeaderboard = + stageRun?.status === 'failed' && + profile?.summary.publicationStatus === 'published'; + const successfulJumpCount = stageRun?.successfulJumpCount ?? 0; + const durationLabel = formatJumpHopDurationLabel( + getJumpHopRunDurationMs(stageRun, nowMs), + ); + + useEffect(() => { + visiblePlatformsRef.current = visiblePlatforms; + }, [visiblePlatforms]); + + useEffect(() => { + tileAssetsRef.current = profile?.tileAssets; + }, [profile?.tileAssets]); + + const clearPlatformAdvanceState = useCallback(() => { + if (platformAdvanceEndTimerRef.current != null) { + window.clearTimeout(platformAdvanceEndTimerRef.current); + platformAdvanceEndTimerRef.current = null; + } + setIsPlatformAdvancing(false); + setPlatformAdvanceExitingPlatforms([]); + setPlatformAdvanceCameraOffsetX(0); + setPlatformAdvanceCameraOffsetY(0); + }, []); + + const clearLandingRecoilState = useCallback(() => { + if (landingRecoilEndTimerRef.current != null) { + window.clearTimeout(landingRecoilEndTimerRef.current); + landingRecoilEndTimerRef.current = null; + } + setIsLandingRecoilAnimating(false); + }, []); + + const beginPlatformAdvance = useCallback( + ( + fromRun: JumpHopRuntimeRunSnapshotResponse, + toRun: JumpHopRuntimeRunSnapshotResponse, + ) => { + if (!shouldAnimateJumpHopPlatformAdvance(fromRun, toRun)) { + clearPlatformAdvanceState(); + return; + } + + const fromVisiblePlatforms = visiblePlatformsRef.current; + const toVisiblePlatforms = buildJumpHopVisiblePlatforms( + toRun.path, + toRun.currentPlatformIndex, + tileAssetsRef.current, + ); + const toPlatformIds = new Set( + toVisiblePlatforms.map((item) => item.platform.platformId), + ); + const fromLandingPlatform = fromVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); + const toCurrentPlatform = toVisiblePlatforms.find( + (item) => item.index === toRun.currentPlatformIndex, + ); + const cameraOffsetX = + (fromLandingPlatform?.screenX ?? toCurrentPlatform?.screenX ?? 0) - + (toCurrentPlatform?.screenX ?? fromLandingPlatform?.screenX ?? 0); + const cameraOffsetY = Math.max( + 0, + (toCurrentPlatform?.screenY ?? 0) - + (fromLandingPlatform?.screenY ?? 0), + ); + + setPlatformAdvanceExitingPlatforms( + fromVisiblePlatforms + .filter((item) => !toPlatformIds.has(item.platform.platformId)) + .map((item) => ({ + ...item, + screenX: item.screenX - cameraOffsetX, + screenY: item.screenY + cameraOffsetY, + })), + ); + setPlatformAdvanceCameraOffsetX(cameraOffsetX); + setPlatformAdvanceCameraOffsetY(cameraOffsetY); + setIsPlatformAdvancing(true); + + if (platformAdvanceEndTimerRef.current != null) { + window.clearTimeout(platformAdvanceEndTimerRef.current); + } + platformAdvanceEndTimerRef.current = window.setTimeout(() => { + platformAdvanceEndTimerRef.current = null; + setIsPlatformAdvancing(false); + setPlatformAdvanceExitingPlatforms([]); + setPlatformAdvanceCameraOffsetX(0); + setPlatformAdvanceCameraOffsetY(0); + }, JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS); + }, + [clearPlatformAdvanceState], + ); + + const finishJumpHopFlightAnimation = useCallback( + ( + fromRun: JumpHopRuntimeRunSnapshotResponse, + toRun: JumpHopRuntimeRunSnapshotResponse, + ) => { + if ( + fromRun.runId === toRun.runId && + hasJumpHopRunDisplayChange(fromRun, toRun) + ) { + beginPlatformAdvance(fromRun, toRun); + setDisplayRun(toRun); + } + + const shouldPlayLandingRecoil = + toRun.lastJump && toRun.lastJump.result !== 'miss'; + if (shouldPlayLandingRecoil) { + if (landingRecoilEndTimerRef.current != null) { + window.clearTimeout(landingRecoilEndTimerRef.current); + } + setIsLandingRecoilAnimating(true); + landingRecoilEndTimerRef.current = window.setTimeout(() => { + landingRecoilEndTimerRef.current = null; + setIsLandingRecoilAnimating(false); + }, JUMP_HOP_LANDING_RECOIL_DURATION_MS); + } else { + clearLandingRecoilState(); + } + + setIsJumpAnimating(false); + setJumpAnimationProgress(0); + setVisualJump(null); + hasJumpAnimationReachedTargetRef.current = false; + setNowMs(Date.now()); + }, + [beginPlatformAdvance, clearLandingRecoilState], + ); + + useEffect(() => { + if (stageRun?.status !== 'playing') { return undefined; } const timer = window.setInterval(() => { - if (chargeStartRef.current == null) { - return; - } - setChargeMs(clamp(Date.now() - chargeStartRef.current, 0, maxChargeMs)); - }, 16); + setNowMs(Date.now()); + }, 250); return () => window.clearInterval(timer); - }, [isCharging, maxChargeMs]); + }, [stageRun?.runId, stageRun?.status]); useEffect(() => { - setIsCharging(false); - chargeStartRef.current = null; - setChargeMs(0); - }, [activeRun?.runId, activeRun?.currentPlatformIndex, activeRun?.status]); + const stage = stageRef.current; + if (!stage) { + return undefined; + } + + const updateStageSize = () => { + const rect = stage.getBoundingClientRect(); + setStageSize({ + width: rect.width, + height: rect.height, + }); + }; + + updateStageSize(); + const resizeObserver = window.ResizeObserver + ? new window.ResizeObserver(updateStageSize) + : null; + resizeObserver?.observe(stage); + + return () => { + resizeObserver?.disconnect(); + }; + }, []); + + useEffect(() => { + if (!activeRun) { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + animationEndTimerRef.current = null; + } + clearPlatformAdvanceState(); + clearLandingRecoilState(); + hasJumpAnimationReachedTargetRef.current = false; + setDisplayRun(null); + setVisualJump(null); + setIsJumpAnimating(false); + setJumpAnimationProgress(0); + setIsCharging(false); + dragStartRef.current = null; + dragCurrentRef.current = null; + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); + setDragPointerPosition(null); + setNowMs(Date.now()); + return; + } + + if (!displayRun || displayRun.runId !== activeRun.runId) { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + animationEndTimerRef.current = null; + } + clearPlatformAdvanceState(); + clearLandingRecoilState(); + hasJumpAnimationReachedTargetRef.current = false; + setDisplayRun(activeRun); + setVisualJump(null); + setIsJumpAnimating(false); + setJumpAnimationProgress(0); + setIsCharging(false); + dragStartRef.current = null; + dragCurrentRef.current = null; + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); + setDragPointerPosition(null); + setNowMs(Date.now()); + return; + } + + if (isJumpAnimating) { + if ( + (jumpAnimationProgress >= 1 || + hasJumpAnimationReachedTargetRef.current) && + displayRun && + displayRun.runId === activeRun.runId && + hasJumpHopRunDisplayChange(displayRun, activeRun) + ) { + finishJumpHopFlightAnimation(displayRun, activeRun); + } + return; + } + + if (hasJumpHopRunDisplayChange(displayRun, activeRun)) { + clearPlatformAdvanceState(); + clearLandingRecoilState(); + setDisplayRun(activeRun); + } + }, [ + activeRun, + clearLandingRecoilState, + clearPlatformAdvanceState, + displayRun, + finishJumpHopFlightAnimation, + isJumpAnimating, + jumpAnimationProgress, + ]); + + useEffect(() => { + return () => { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + } + if (platformAdvanceEndTimerRef.current != null) { + window.clearTimeout(platformAdvanceEndTimerRef.current); + } + if (landingRecoilEndTimerRef.current != null) { + window.clearTimeout(landingRecoilEndTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isJumpAnimating) { + hasJumpAnimationReachedTargetRef.current = false; + setJumpAnimationProgress(0); + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + animationEndTimerRef.current = null; + } + return undefined; + } + + animationStartAtRef.current = window.performance.now(); + hasJumpAnimationReachedTargetRef.current = false; + animationEndTimerRef.current = window.setTimeout(() => { + animationEndTimerRef.current = null; + hasJumpAnimationReachedTargetRef.current = true; + setJumpAnimationProgress(1); + const latestDisplayRun = displayRunRef.current; + const latestActiveRun = activeRunRef.current; + if ( + latestDisplayRun && + latestActiveRun && + latestDisplayRun.runId === latestActiveRun.runId && + hasJumpHopRunDisplayChange(latestDisplayRun, latestActiveRun) + ) { + finishJumpHopFlightAnimation(latestDisplayRun, latestActiveRun); + } + }, JUMP_HOP_ANIMATION_DURATION_MS); + const tick = (now: number) => { + if (hasJumpAnimationReachedTargetRef.current) { + animationFrameRef.current = null; + return; + } + const elapsed = now - animationStartAtRef.current; + const progress = clamp( + elapsed / JUMP_HOP_ANIMATION_DURATION_MS, + 0, + 1, + ); + setJumpAnimationProgress(progress); + if (progress < 1) { + animationFrameRef.current = window.requestAnimationFrame(tick); + } else { + animationFrameRef.current = null; + } + }; + + animationFrameRef.current = window.requestAnimationFrame(tick); + + return () => { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (animationEndTimerRef.current != null) { + window.clearTimeout(animationEndTimerRef.current); + animationEndTimerRef.current = null; + } + }; + }, [finishJumpHopFlightAnimation, isJumpAnimating]); + + const getStageLocalPoint = (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + }; + + const updateDragState = (x: number, y: number) => { + const dragStart = dragStartRef.current; + dragCurrentRef.current = { x, y }; + setDragPointerPosition({ x, y }); + if (!dragStart) { + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); + return; + } + setDragVector({ + x: x - dragStart.x, + y: y - dragStart.y, + }); + setDragDistance(Math.hypot(x - dragStart.x, y - dragStart.y)); + }; const beginCharge = (event: PointerEvent) => { if (!canJump) { return; } event.currentTarget.setPointerCapture?.(event.pointerId); - chargeStartRef.current = Date.now(); + const dragPoint = getStageLocalPoint(event); + dragStartRef.current = dragPoint; + dragCurrentRef.current = dragPoint; + setDragPointerPosition(dragPoint); setIsCharging(true); - setChargeMs(0); + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); }; - const finishCharge = async () => { + const updateDragVector = (event: PointerEvent) => { if (!isCharging) { return; } + const dragPoint = getStageLocalPoint(event); + updateDragState(dragPoint.x, dragPoint.y); + }; - const nextChargeMs = clamp( - chargeStartRef.current ? Date.now() - chargeStartRef.current : chargeMs, - 0, - maxChargeMs, + const finishCharge = async (event?: PointerEvent) => { + if (!isCharging) { + return; + } + if (event) { + const dragPoint = getStageLocalPoint(event); + updateDragState(dragPoint.x, dragPoint.y); + } + + const dragStart = dragStartRef.current; + const dragCurrent = dragCurrentRef.current ?? dragStart; + const dragVectorX = + dragStart && dragCurrent ? dragCurrent.x - dragStart.x : 0; + const dragVectorY = + dragStart && dragCurrent ? dragCurrent.y - dragStart.y : 0; + const nextDragDistance = Math.hypot(dragVectorX, dragVectorY); + const backendDragVector = getJumpHopBackendDragVector( + activeRun, + visiblePlatforms, + landingAssistStageSize, + dragVectorX, + dragVectorY, ); - chargeStartRef.current = null; + const predictedLandingPosition = + activeRun && characterPosition + ? getJumpHopLandingAssistVisualPosition( + activeRun, + visiblePlatforms, + characterPosition, + landingAssistStageSize, + nextDragDistance, + dragVectorX, + dragVectorY, + ) + : null; + const fallbackLandingPosition = jumpTargetPlatform + ? buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform) + : characterPosition; + if (characterPosition && (predictedLandingPosition || fallbackLandingPosition)) { + setVisualJump({ + from: characterPosition, + to: predictedLandingPosition + ? { + ...characterPosition, + screenX: predictedLandingPosition.screenX, + screenY: predictedLandingPosition.screenY, + isMiss: false, + } + : fallbackLandingPosition!, + }); + } else { + setVisualJump(null); + } + dragStartRef.current = null; + dragCurrentRef.current = null; + clearLandingRecoilState(); setIsCharging(false); - setChargeMs(nextChargeMs); - await onJump({ chargeMs: nextChargeMs }); + setJumpAnimationProgress(0); + hasJumpAnimationReachedTargetRef.current = false; + setIsJumpAnimating(true); + setDragDistance(nextDragDistance); + setDragVector({ + x: dragVectorX, + y: dragVectorY, + }); + setDragPointerPosition(null); + await onJump({ + dragDistance: nextDragDistance, + dragVectorX: backendDragVector.dragVectorX, + dragVectorY: backendDragVector.dragVectorY, + }); }; const cancelCharge = () => { - chargeStartRef.current = null; + dragStartRef.current = null; + dragCurrentRef.current = null; + clearLandingRecoilState(); + hasJumpAnimationReachedTargetRef.current = false; + setVisualJump(null); setIsCharging(false); - setChargeMs(0); + setDragDistance(0); + setDragVector({ x: 0, y: 0 }); + setDragPointerPosition(null); }; return ( -
-
-
- -
- -
- {activeRun?.score ?? 0} - - {activeRun?.combo ?? 0}x + {stageBackgroundUrl ? ( + + ) : null}
- -
+ -
-
void finishCharge()} - onPointerCancel={cancelCharge} - onPointerLeave={() => { - if (isCharging) { - void finishCharge(); - } - }} - > -
-
- - {visiblePlatforms.map((item) => { - const width = - clamp(item.platform.width * 0.92, 58, 112) * item.scale; - const height = - clamp(item.platform.height * 0.72, 46, 86) * item.scale; + {platformRenderItems.map((item) => { + const { width, height } = getJumpHopPlatformVisualSize( + item.platform, + 1, + ); const style = { left: `${item.screenX}%`, top: `${item.screenY}%`, width, height, - zIndex: 20 + item.index, + '--jump-hop-platform-scale': item.scale, + zIndex: + item.advanceState === 'exiting' ? 12 + item.index : 20 + item.index, } as CSSProperties; - const isCurrent = item.index === activeRun?.currentPlatformIndex; + const isCurrent = + item.advanceState !== 'exiting' && + item.index === stageRun?.currentPlatformIndex; return (
- {item.asset?.imageSrc ? ( - - ) : ( - - )} +
); })} - {characterPosition ? ( + {preloadTileAssets.length > 0 ? ( + + ) : null} + + {visualCharacterPosition && !isThreeCharacterLayerReady ? (
-
- {profile?.characterAsset?.imageSrc ? ( - - ) : ( -
- )} -
- ) : null} - - {jumpFeedback ? ( -
- {jumpFeedback} -
- ) : null} - - {isCharging ? ( -
- -
-
- {error ? ( - - {error} - - ) : ( - {getStatusLabel(activeRun?.status)} - )}
- +
+
+ + + 得分 + +
+
- {isBusy ? ( - - ) : ( - - )} - 起跳 - + {successfulJumpCount} +
+
+
+ ) : null}