diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 9b4f695e..a92c08d3 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1804,10 +1804,26 @@ - 现象:跳一跳松手后如果后端很快返回下一帧 run,地块窗口会立刻前移,角色翻腾动画看起来像没播放;若同时刷新图片资产,还可能被误认为地块频闪。 - 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。 -- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前窗口内插值飞向目标地块;约 `300ms` 后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。推进期间地块 DOM 层和 Three.js 角色层必须统一包在同一个 camera layer 下移动,旧当前地块用相机偏移自然离开视野,新预览地块从上方露出;不要再让 p1/p2 各自 top/left 过渡。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,从旧目标地块位置斜向滑到新当前地块聚焦位置,避免先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。 -- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,动画结束后进入 `data-platform-advancing=true`,Three 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`。 +- 处理:前端保留独立 `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、建筑海报或其它随机内容,容易误判为模型不服从提示词。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index a1fe2e14..5d879b8e 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -42,7 +42,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` 和汪汪声浪生成页都必须遵循这一口径。 @@ -146,17 +146,18 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生; 2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色; -3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、纯绿色绿幕背景的主题地块图集; -4. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; -5. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽。 +3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key,不启用近白底扣除,也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;地块造型提示词要求以主题物体本身外轮廓为准,允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异,只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装;所有地块素材必须保持统一正面30度视角,相机位于物体正前方略高位置、镜头向下约30度,必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面,主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶;避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标;主题物体本身必须是唯一可落脚体,只能用自身切面、边缘厚度、花瓣层或果皮边表现承重,禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板;前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集,物体本身作为跳跃落点”的口径,不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词; +4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段,OSS 槽位固定为 `background/image.png`,不新增 SpacetimeDB 字段;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感,两侧允许更强立体层次和行进感;背景只作为底图,禁止生成跳板、地块、落脚物、角色、UI、文字、路径箭头或海报排版; +5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; +6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽。 运行态规则真相必须沉到 `module-jump-hop`,前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 -每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长;每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`。 +每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数,不展示计时器或右上角重开按钮;舞台区域不得再表现为带边框卡片,游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长;每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。 -运行态渲染分层固定为:DOM 平台层直接使用 `tileAssets[]` 的生成切片图片显示地块,图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存;DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持最高层级;Three.js 透明画布仅作为后续扩展层。拖拽蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布或平台图片层,否则会造成地块和角色层频闪。 +运行态渲染分层固定为:舞台底层 `.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。 +跳一跳当前拖拽手感统一采用 `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 失败、刷新回首页。 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 6a80629b..69ccf710 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -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, @@ -56,6 +57,10 @@ 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 = 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; #[derive(Clone, Debug, PartialEq, Eq)] struct JumpHopTileAtlasSlice { @@ -428,12 +433,19 @@ async fn maybe_generate_jump_hop_assets( ) { return Ok(()); } - if 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.len() >= JUMP_HOP_TILE_ITEM_COUNT) - { + .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)); + + if has_complete_tile_assets && has_real_background { return Ok(()); } let profile_id = payload @@ -464,78 +476,151 @@ async fn maybe_generate_jump_hop_assets( .theme_text .as_deref() .or(payload.work_title.as_deref()) - .unwrap_or("跳一跳"); - let tile_prompt = payload.tile_prompt.as_deref().unwrap_or(theme_text); + .unwrap_or("跳一跳") + .to_string(); + let tile_prompt = payload + .tile_prompt + .clone() + .unwrap_or_else(|| theme_text.clone()); - let sheet_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); - let tile_generated = create_openai_image_generation( - &http_client, - &settings, - sheet_prompt.as_str(), - Some(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": "跳一跳地块图集生成成功但未返回图片。", - })), + 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, + &[], + "跳一跳背景底图生成失败", ) - })?; - 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, + .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_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": "跳一跳地块图集生成成功但未返回图片。", + })), ) - .await?, - ); + })?; + 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, + ) + .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, + theme_text.as_str(), )); } - 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 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 build_jump_hop_background_prompt(theme_text: &str) -> String { + let theme_text = theme_text.trim(); + let theme_text = if theme_text.is_empty() { + "跳一跳" + } else { + theme_text + }; + + format!( + "生成一张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 text, consistent 2D/2.5D front-facing 30-degree game perspective." + ) +} + +fn build_jump_hop_background_negative_prompt() -> &'static str { + "文字、Logo、水印、UI按钮、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感" +} + fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String { let theme_text = theme_text.trim(); let theme_text = if theme_text.is_empty() { @@ -543,20 +628,57 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri } else { theme_text }; - let subject_text = tile_prompt.trim(); - let subject_text = if subject_text.is_empty() { + let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt); + let subject_text = if sanitized_tile_prompt.is_empty() { theme_text } else { - subject_text + sanitized_tile_prompt.as_str() }; format!( - "生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳一跳可落脚平台素材,按五行五列均匀摆放在纯绿色绿幕画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为俯视角平台跳跃游戏,画面内容是{subject_text}。\n每一块平台都必须直接使用主题元素做主体造型,主题要一眼可见;例如主题为水果时,应是苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台,不得变成石板、金属按钮、徽章或装备。\n只画平台裸素材,不画外层面板、棋盘底座、菜单、按钮、标题、文字、角标、装饰边框、工具栏、装备、武器、徽章、道具或角色。\n整体风格为清爽自然的休闲手游平台素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每格一个完整平台,是符合主题且有设计感的立体感平台,有顶面和清晰轮廓;不要默认生成灰色石板或金属地砖,除非主题本身就是石头或金属。\n每格主体必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯绿色绿幕安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个平台只保留主体内部明暗和外轮廓,不绘制落地投影、接触阴影、方形阴影、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个平台同一材质体系、同一光向,但形状和细节有变化;每个平台之间只能是纯绿色空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是接近 #00FF00 的纯绿色绿幕,背景平整无纹理、无渐变、无阴影、无黑底;主体自身不得使用接近 #00FF00 的纯绿。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板或道具界面。\nEnglish guardrail: isolated top-down fruit-shaped jump pad assets only, green screen background, no text, no poster, no architecture, no building, no UI screen, no inventory icons." + "生成一张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, 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 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、灰色石板、金属地砖、建筑、楼房、海报、装备、武器、徽章、道具图标、UI图标卡、标题、说明文字、装饰边框、落地投影、接触阴影、方形阴影、方形底板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界" + "文字、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; + } + + 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( @@ -568,7 +690,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; @@ -596,9 +719,11 @@ 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(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 @@ -997,7 +1122,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"), tile_prompt: clean_or_default( &payload.tile_prompt, - &format!("{theme_text}主题的俯视角清爽游戏化立体感平台素材"), + &format!("{theme_text}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"), ), end_mood_prompt: payload .end_mood_prompt @@ -1164,17 +1289,67 @@ mod tests { let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台"); assert!(prompt.contains("五行五列")); - assert!(prompt.contains("共25个")); - 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("四周至少保留18%纯绿色绿幕安全留白")); + 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("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("可落脚平台素材")); + 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("清爽自然的游戏图标")); @@ -1184,6 +1359,91 @@ mod tests { assert!(!prompt.contains("不同视图")); } + #[test] + 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("视角保持正面约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 platforms")); + assert!(prompt.contains("no landing objects")); + } + + #[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("平铺俯拍")); + } + + #[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_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(); @@ -1192,9 +1452,28 @@ mod tests { 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("楼房")); } #[test] @@ -1283,6 +1562,62 @@ mod tests { } } + #[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) 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..782460ca 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 @@ -308,23 +392,23 @@ fn remove_generated_asset_sheet_green_screen_background( 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); } } 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(); @@ -336,7 +420,7 @@ fn remove_generated_asset_sheet_green_screen_background( } let mut next_alpha = alpha; - let edge_fade = (green_score * 0.35).max(white_score * 0.28); + let edge_fade = (key_score * 0.35).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 +448,35 @@ fn remove_generated_asset_sheet_green_screen_background( changed } +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..6bfbf96f 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,6 +1,9 @@ -use super::alpha::apply_generated_asset_sheet_green_screen_alpha; +use super::alpha::{ + GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha, +}; use super::color::{ - is_generated_asset_sheet_foreground_pixel, + 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, @@ -130,10 +133,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 +377,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 +422,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 +453,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 +471,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 +548,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 +561,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,6 +581,7 @@ fn remove_generated_asset_sheet_view_edge_matte( y, &background_mask, &visible_mask, + options, ) .unwrap_or(( pixels[offset], @@ -605,6 +628,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 +662,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 +695,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..6473f756 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,68 @@ 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_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/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 0ba46095..d4b84451 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -836,7 +836,7 @@ fn default_draft() -> JumpHopDraftResponse { style_preset: JumpHopStylePreset::MinimalBlocks, default_character: Some(default_jump_hop_default_character()), character_prompt: "内置默认 3D 角色".to_string(), - tile_prompt: "跳一跳主题的俯视角清爽游戏化立体感平台素材".to_string(), + tile_prompt: "跳一跳主题的正面30度视角主题物体图集,物体本身作为跳跃落点".to_string(), end_mood_prompt: None, character_asset: None, tile_atlas_asset: None, diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs index a821a317..47efee78 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -431,6 +431,7 @@ mod tests { event_prize_pool_mud_points: 0, event_starts_at_text: None, event_ends_at_text: None, + event_banners_json: None, } } @@ -448,6 +449,7 @@ mod tests { category_id: Some("recommended".to_string()), category_label: Some("热门推荐".to_string()), category_sort_order: 20, + unified_creation_spec_json: None, } } @@ -465,7 +467,10 @@ mod tests { .expect("should contain jump-hop"); assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃"); - assert_eq!(jump_hop.image_src, "/creation-type-references/jump-hop.webp"); + assert_eq!( + jump_hop.image_src, + "/creation-type-references/jump-hop.webp" + ); } #[test] @@ -489,7 +494,10 @@ mod tests { 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(), @@ -503,6 +511,7 @@ mod tests { 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, }); @@ -514,7 +523,10 @@ mod tests { .expect("should contain jump-hop"); assert_eq!(jump_hop.subtitle, "主题驱动平台跳跃"); - assert_eq!(jump_hop.image_src, "/creation-type-references/jump-hop.webp"); + assert_eq!( + jump_hop.image_src, + "/creation-type-references/jump-hop.webp" + ); } } diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index e78e5dd7..4f53d9df 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -1179,7 +1179,7 @@ fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot { difficulty: JumpHopDifficulty::Standard.as_str().to_string(), style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(), character_prompt: "内置默认 3D 角色".to_string(), - tile_prompt: format!("{seed}主题的俯视角清爽游戏化立体感平台素材"), + tile_prompt: format!("{seed}主题的正面30度视角主题物体图集,物体本身作为跳跃落点"), end_mood_prompt: String::new(), } } diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index 7b16d9a1..1a574ed4 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; import type { @@ -229,7 +229,31 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => { ); }); -test('跳一跳运行态需要三维场景宿主和排行榜面板', () => { +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('跳一跳运行态失败后在弹窗中展示排行榜', () => { const runtimeRequestOptions = { runtimeGuestToken: 'runtime-guest-token', }; @@ -255,7 +279,7 @@ test('跳一跳运行态需要三维场景宿主和排行榜面板', () => { render( {}} @@ -266,12 +290,12 @@ test('跳一跳运行态需要三维场景宿主和排行榜面板', () => { 'jump-hop-profile-test', runtimeRequestOptions, ); - expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy(); - expect(screen.getByTestId('jump-hop-runtime-leaderboard')).toBeTruthy(); - expect(screen.getByText('player-1')).toBeTruthy(); - expect(screen.getByText('8 跳')).toBeTruthy(); - expect(screen.getByText('00:08')).toBeTruthy(); - expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull(); + 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('跳一跳角色层永远压在地块层之上', () => { @@ -356,6 +380,7 @@ test('跳一跳运行态直接渲染生成的地块切片图片', () => { 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]) => @@ -379,6 +404,25 @@ test('跳一跳运行态直接渲染生成的地块切片图片', () => { } }); +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( [\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, ); @@ -823,6 +875,50 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse { }; } +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'); @@ -845,6 +941,8 @@ function buildTileAssets() { function buildProfile(options: { tileAssets?: JumpHopWorkProfileResponse['tileAssets']; + coverComposite?: string | null; + coverImageSrc?: string | null; } = {}): JumpHopWorkProfileResponse { const characterAsset = { assetId: 'builtin', @@ -869,7 +967,7 @@ function buildProfile(options: { themeTags: ['测试'], difficulty: 'standard', stylePreset: 'minimal-blocks', - coverImageSrc: null, + coverImageSrc: options.coverImageSrc ?? null, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-27T00:00:00Z', @@ -901,7 +999,7 @@ function buildProfile(options: { tileAtlasAsset: characterAsset, tileAssets: options.tileAssets ?? [], path: buildRun().path, - coverComposite: null, + coverComposite: options.coverComposite ?? null, generationStatus: 'ready', }, path: buildRun().path, @@ -917,3 +1015,46 @@ function buildProfile(options: { tileAssets: options.tileAssets ?? [], }; } + +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 8393f75d..fdc1413d 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Loader2, RotateCcw } from 'lucide-react'; +import { ArrowLeft, Loader2 } from 'lucide-react'; import { type CSSProperties, type Dispatch, @@ -29,6 +29,7 @@ import { getJumpHopRunDurationMs, getJumpHopStatusLabel, getJumpHopTileTone, + selectJumpHopTileAsset, type JumpHopCharacterVisualPosition, type JumpHopVisiblePlatform, resolveJumpHopCharacterCanvasPosition, @@ -66,6 +67,7 @@ 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)); @@ -192,6 +194,21 @@ function IsometricFallbackTile({ ); } +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, @@ -199,8 +216,7 @@ function JumpHopTileImage({ asset: JumpHopTileAsset | null; platform: JumpHopVisiblePlatform['platform']; }) { - const assetRefreshKey = - asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null; + const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset); const { resolvedUrl } = useResolvedAssetReadUrl(asset?.imageSrc, { refreshKey: assetRefreshKey, }); @@ -212,12 +228,13 @@ function JumpHopTileImage({ setHasError(false); }, [resolvedUrl]); - const shouldShowFallback = !resolvedUrl || !isLoaded || hasError; + const shouldShowImage = Boolean(resolvedUrl && !hasError); + const shouldShowFallback = !shouldShowImage; return (
{shouldShowFallback ? : null} - {resolvedUrl && !hasError ? ( + {shouldShowImage ? ( { setIsLoaded(true); }} @@ -238,6 +255,28 @@ function JumpHopTileImage({ ); } +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; @@ -573,6 +612,16 @@ export function JumpHopRuntimeShell({ 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, + }, + ); useEffect(() => { activeRunRef.current = activeRun; @@ -612,7 +661,7 @@ export function JumpHopRuntimeShell({ const platformRenderItems = useMemo(() => { const exitingItems = platformAdvanceExitingPlatforms.map((item) => ({ ...item, - renderKey: `${item.platform.platformId}-exiting`, + renderKey: item.platform.platformId, advanceState: 'exiting' as const, })); const visibleItems = visiblePlatforms.map((item) => ({ @@ -627,6 +676,47 @@ export function JumpHopRuntimeShell({ 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 showLandingAssist = import.meta.env.MODE !== 'production' && isCharging && !isJumpAnimating; const characterPosition = getJumpHopCharacterVisualPosition( @@ -753,6 +843,7 @@ export function JumpHopRuntimeShell({ const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun); const isSettled = stageRun?.status === 'failed' || stageRun?.status === 'cleared'; + const shouldShowFailureLeaderboard = stageRun?.status === 'failed'; const successfulJumpCount = stageRun?.successfulJumpCount ?? 0; const durationLabel = formatJumpHopDurationLabel( getJumpHopRunDurationMs(stageRun, nowMs), @@ -1219,29 +1310,19 @@ export function JumpHopRuntimeShell({
-
+
{successfulJumpCount} - - {durationLabel}
- +
@@ -1251,7 +1332,7 @@ export function JumpHopRuntimeShell({ data-charging={isCharging ? 'true' : 'false'} data-jump-animating={isJumpAnimating ? 'true' : 'false'} data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'} - className="jump-hop-runtime__stage relative min-h-0 flex-1 touch-none select-none overflow-hidden rounded-[1.5rem] border border-white/70 bg-white/40 shadow-[0_24px_70px_rgba(44,125,182,0.2)]" + className="jump-hop-runtime__stage relative min-h-0 flex-1 touch-none select-none overflow-hidden rounded-[1.5rem]" onPointerDown={beginCharge} onPointerMove={updateDragVector} onPointerUp={(event) => void finishCharge(event)} @@ -1260,7 +1341,18 @@ export function JumpHopRuntimeShell({
0 ? ( + + ) : null} + {visualCharacterPosition && !isThreeCharacterLayerReady ? (
-
+
- {getJumpHopStatusLabel(stageRun?.status)} + + {getJumpHopStatusLabel(stageRun?.status)} +
{successfulJumpCount} 跳 {durationLabel}
+ {shouldShowFailureLeaderboard ? ( + + ) : null}