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

# Conflicts:
#	.hermes/shared-memory/pitfalls.md
#	server-rs/crates/api-server/src/modules/jump_hop.rs
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/jump-hop/jumpHopClient.test.ts
This commit is contained in:
2026-06-05 23:59:40 +08:00
67 changed files with 8713 additions and 2537 deletions
+39 -6
View File
@@ -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 陶泥儿主视觉配色回收为暖白/陶土橙
+92 -5
View File
@@ -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/<profile>/<slot>/image.png`,后上传的切片覆盖先上传的切片,前端换签缓存也会读到重复或旧对象。
- 处理:后端切图后必须按 atlas 单元格写入 `tile-01``tile-25` 的唯一 slot/path;前端结果页和运行态展示生成图时用 `assetObjectId` 作为 `refreshKey`,避免重生成后复用旧签名或旧图片缓存。
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 应包含 `jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices`;前端运行态测试应断言地块换签带 `assetObjectId` 刷新键。
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-result/JumpHopResultView.tsx`
## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影
- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。
- 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。
- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。
- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`
## 跳一跳落点辅助和后端裁决必须统一坐标换算
- 现象:落点辅助标识已经压在目标地块图片上,松手后后端仍判定失败,玩家看到的是“明明瞄准了却没落上去”。
- 原因:前端辅助标识使用屏幕像素坐标绘制,而后端裁决使用世界坐标。屏幕 y 轴向下为正、世界 y 轴向上为正;同时屏幕 x/y 每个世界单位对应的像素比例不同。若前端直接把屏幕像素拖拽向量发给后端,辅助点和后端落点方向会不一致。
- 处理:前端运行态保留原始屏幕拖拽向量用于画弹弓和辅助点,但提交后端前必须按当前地块到目标地块的屏幕跨度 / 世界跨度把 x、y 分别换算成世界尺度一致的向量;后端继续只负责反向弹射和落点裁决。
- 验证:前端回归测试要同时覆盖辅助点完整拖拽到目标地块,以及提交给后端的向量已完成世界尺度换算;后端领域测试覆盖屏幕向后下拉时应向世界 y 正方向跳出并命中。
- 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts``src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``server-rs/crates/module-jump-hop/src/application.rs`
## 跳一跳创作入口旧文案先查 SpacetimeDB 配置
- 现象:`JumpHopWorkspace` 已只剩主题输入,但创作 Tab 的跳一跳模板卡仍显示旧的“俯视角跳跃闯关”或拼图参考图。
- 原因:创作入口卡片事实源是 SpacetimeDB `creation_entry_type_config``/api/creation-entry/config`,前端只做展示派生;如果只改工作台、PRD 或前端组件,已有库里的旧入口行不会自动变化。当前 `api-server` 读取入口配置时优先订阅缓存,缓存命中后不会再走 procedure 播种,所以只把迁移写在 `get_creation_entry_config` 里不够。
- 处理:同步更新 `module-runtime` 默认入口种子,并在 `spacetime-module/src/runtime/creation_entry_config.rs` 加只命中旧系统默认值的迁移;同时在 `spacetime-client` 的入口配置读模型里做同一条旧系统默认行的读路径纠偏。跳一跳当前默认值为 `subtitle=主题驱动平台跳跃``image_src=/creation-type-references/jump-hop.webp`
- 验证:本地 `GET /api/creation-entry/config``jump-hop` 项应返回新 subtitle 和新 imageSrc;若仍旧,检查本地 SpacetimeDB 是否已发布当前 `spacetime-module`,以及后台是否手动覆盖过入口配置。若缓存路径和 procedure 路径返回不一致,优先怀疑读模型映射没做纠偏,而不是前端展示层。
## image2 dry-run 带参考图时不要直接打印 data URL
- 现象:使用 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:<templateId>`;新增玩法公开推荐流时先补这个共享 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` 且未错误时直接保留真实 `<img>`,只在无 URL 或加载失败时显示 fallback;当前 3 块之外的后续地块通过隐藏预加载图片提前解析签名 URL 和浏览器缓存。
- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖真实 tile URL 不露出 `.jump-hop-runtime__fallback-tile`,并存在 `jump-hop-tile-preload-image`
- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx``src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`
## 跳一跳地块抠图不要用绿幕或近白底识别
- 现象:跳一跳生成草地、花、雪地、白石或云朵地块时,透明化会把绿色 / 白色主体局部扣掉,运行态看到平台缺口、变薄或主体消失。
- 原因:通用图集默认按绿幕和近白底做透明化,适合 UI / 普通物品,但跳一跳地块天然高频包含绿色和白色;如果继续用 `#00FF00` 绿幕或近白背景识别,素材本体会落入背景分数。旧逻辑还会清理非边缘连通的高置信 key 色块,遇到主体内部撞色时也可能误伤。
- 处理:跳一跳地块图集 prompt 固定要求单一纯洋红 `#FF00FF` key 背景;切片前后透明化调用 `GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen()`,只扣洋红 key,关闭近白扣除,并且不清理非边缘连通 key 色像素。通用绿幕函数保持默认绿幕 / 近白兼容,避免影响拼图、抓大鹅和敲木鱼。
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet -- --nocapture` 覆盖洋红 key 保留绿色、白色和非边缘连通 key 色主体;`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳洋红 prompt 与绿 / 白地块切片。
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs``server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs``server-rs/crates/api-server/src/jump_hop.rs`
## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码
- 现象:本地用 `@'...'@ | node -` 跑 VectorEngine / gpt-image-2 live 验证时,`request.json` 里的中文 prompt 可能全部变成 `????`,生成图会变成完全不相关的 UI、建筑海报或其它随机内容,容易误判为模型不服从提示词。
- 原因:Windows PowerShell 管道到 Node stdin 时可能按本机非 UTF-8 编码传输脚本文本,JS 源码里的中文字符串在进入 Node 前已经损坏;Rust 后端真实请求不会走这条编码路径。
- 处理:含中文提示词的 live 验证优先写成 UTF-8 `.mjs` 文件再执行,或使用能确认 UTF-8 的运行入口;执行后先检查本次 `request.json` 是否保留真实中文,再判断生图质量。不要基于 `????` prompt 生成的图片调整项目提示词。
- 验证:生成前后检查 `request.json`,其中 `prompt` 字段应显示中文而不是问号;同一提示词在 UTF-8 文件脚本下应能得到符合主题的图。
- 关联:`.codex/skills/gpt-image-2-apimart/SKILL.md``server-rs/crates/api-server/src/jump_hop.rs`
File diff suppressed because it is too large Load Diff
@@ -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`
@@ -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` 透明 PNGOSS 槽位固定为 `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。
+48 -9
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -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();
@@ -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(
File diff suppressed because one or more lines are too long
@@ -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<AppState> {
"/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(
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -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);
@@ -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,
@@ -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;
@@ -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,
};
@@ -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
}
@@ -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]));
@@ -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<String>,
#[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<String>,
@@ -89,6 +96,8 @@ pub struct JumpHopActionRequest {
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub theme_text: Option<String>,
#[serde(default)]
pub work_title: Option<String>,
#[serde(default)]
pub work_description: Option<String>,
@@ -112,6 +121,8 @@ pub struct JumpHopActionRequest {
pub tile_assets: Option<Vec<JumpHopTileAsset>>,
#[serde(default)]
pub cover_composite: Option<String>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
}
#[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<String>,
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<u32>,
#[serde(default)]
pub atlas_col: Option<u32>,
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<String>,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
pub difficulty: JumpHopDifficulty,
pub style_preset: JumpHopStylePreset,
#[serde(default)]
pub default_character: Option<JumpHopDefaultCharacter>,
pub character_prompt: String,
pub tile_prompt: String,
#[serde(default)]
@@ -212,6 +242,8 @@ pub struct JumpHopDraftResponse {
pub path: Option<JumpHopPath>,
#[serde(default)]
pub cover_composite: Option<String>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
pub generation_status: JumpHopGenerationStatus,
}
@@ -251,6 +283,7 @@ pub struct JumpHopWorkSummaryResponse {
pub owner_user_id: String,
#[serde(default)]
pub source_session_id: Option<String>,
pub theme_text: String,
pub work_title: String,
pub work_description: String,
pub theme_tags: Vec<String>,
@@ -274,9 +307,13 @@ pub struct JumpHopWorkProfileResponse {
pub summary: JumpHopWorkSummaryResponse,
pub draft: JumpHopDraftResponse,
pub path: JumpHopPath,
#[serde(default)]
pub default_character: Option<JumpHopDefaultCharacter>,
pub character_asset: JumpHopCharacterAsset,
pub tile_atlas_asset: JumpHopCharacterAsset,
pub tile_assets: Vec<JumpHopTileAsset>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
}
#[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<String>,
}
#[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<f32>,
#[serde(default)]
pub drag_vector_y: Option<f32>,
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<JumpHopLeaderboardEntry>,
#[serde(default)]
pub viewer_best: Option<JumpHopLeaderboardEntry>,
}
#[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()],
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -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<JumpHopLeaderboardResponse, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(JumpHopLeaderboardResponse {
profile_id: result.profile_id,
items: result
.items
.into_iter()
.map(map_jump_hop_leaderboard_entry_snapshot)
.collect(),
viewer_best: result
.viewer_best
.map(map_jump_hop_leaderboard_entry_snapshot),
})
}
pub(crate) fn map_jump_hop_gallery_card_view_row(
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<JumpHopWorkProfileResponse, SpacetimeClientError> {
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>) -> 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,
@@ -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<String>, 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<RuntimeSettingsRecord, SpacetimeClientError> {

Some files were not shown because too many files have changed in this diff Show More