feat(jump-hop): optimize generated assets and runtime background

This commit is contained in:
2026-06-04 22:34:19 +08:00
parent c442c3c3f0
commit 0041b95f72
17 changed files with 1160 additions and 200 deletions
+18 -2
View File
@@ -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` 且未错误时直接保留真实 `<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、建筑海报或其它随机内容,容易误判为模型不服从提示词。
@@ -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 失败、刷新回首页。
@@ -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
@@ -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,
@@ -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,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
}
@@ -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]));
@@ -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,
@@ -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"
);
}
}
@@ -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(),
}
}
@@ -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(
<JumpHopRuntimeShell
profile={buildProfile()}
run={buildRun()}
runtimeRequestOptions={runtimeRequestOptions}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
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(
<JumpHopRuntimeShell
profile={buildProfile()}
run={buildRun()}
run={buildFailedRun()}
runtimeRequestOptions={runtimeRequestOptions}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
@@ -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(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRunWithExtraPreviewPlatform()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
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(
<JumpHopRuntimeShell
@@ -679,6 +723,14 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
expect(styleText).toMatch(
/data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
);
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
const advancingCharacterRule = styleText.match(
/\.jump-hop-runtime__stage\[data-platform-advancing='true'\]\s+\.jump-hop-runtime__character\s*\{(?<body>[\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(
<JumpHopRuntimeShell
profile={buildProfile({ coverComposite: backgroundSource })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
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(
<JumpHopRuntimeShell
profile={buildProfile({
coverComposite:
'/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png',
})}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
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');
});
@@ -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 (
<div className="jump-hop-runtime__tile-image-stack">
{shouldShowFallback ? <IsometricFallbackTile platform={platform} /> : null}
{resolvedUrl && !hasError ? (
{shouldShowImage ? (
<img
src={resolvedUrl}
alt=""
@@ -225,7 +242,7 @@ function JumpHopTileImage({
data-testid="jump-hop-tile-image"
data-tile-id={asset?.tileId ?? asset?.sourceAtlasCell}
className="jump-hop-runtime__tile-image"
data-loaded={isLoaded ? 'true' : 'false'}
data-loaded={isLoaded || shouldShowImage ? 'true' : 'false'}
onLoad={() => {
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 (
<img
src={resolvedUrl}
alt=""
aria-hidden="true"
draggable={false}
data-testid="jump-hop-tile-preload-image"
className="jump-hop-runtime__tile-preload-image"
/>
);
}
function hasJumpHopWebGLSupport() {
if (import.meta.env.MODE === 'test') {
return false;
@@ -573,6 +612,16 @@ export function JumpHopRuntimeShell({
const displayRunRef = useRef(displayRun);
const visiblePlatformsRef = useRef<JumpHopVisiblePlatform[]>([]);
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<string, JumpHopTileAsset>();
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({
<div className="jump-hop-runtime__sky" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.82),transparent_30%),linear-gradient(180deg,rgba(255,255,255,0.18),rgba(234,204,179,0.24))]" />
<header className="relative z-20 flex items-center justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4">
<header className="relative z-20 grid grid-cols-[1fr_auto_1fr] items-center gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4">
<button
type="button"
onClick={exitHandler}
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
className="platform-button platform-button--ghost min-h-0 justify-self-start rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2 rounded-full border border-white/70 bg-white/82 px-3 py-2 text-sm font-black shadow-sm backdrop-blur">
<span>{successfulJumpCount}</span>
<span className="h-1 w-1 rounded-full bg-slate-300" />
<span>{durationLabel}</span>
</div>
<button
type="button"
onClick={onRestart}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<RotateCcw className="h-4 w-4" />
</button>
<div aria-hidden="true" />
</header>
<main className="relative z-10 mx-auto flex w-full max-w-[30rem] flex-1 flex-col px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-4">
@@ -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({
<div
aria-hidden="true"
className="jump-hop-runtime__scene-backdrop"
/>
data-has-background={stageBackgroundUrl ? 'true' : 'false'}
>
{stageBackgroundUrl ? (
<img
src={stageBackgroundUrl}
alt=""
draggable={false}
data-testid="jump-hop-stage-background-image"
className="jump-hop-runtime__scene-background-image"
/>
) : null}
</div>
<div
data-testid="jump-hop-camera-layer"
data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'}
@@ -1318,6 +1410,17 @@ export function JumpHopRuntimeShell({
);
})}
{preloadTileAssets.length > 0 ? (
<div className="jump-hop-runtime__tile-preload" aria-hidden="true">
{preloadTileAssets.map((asset) => (
<JumpHopTilePreloadImage
key={getJumpHopTileAssetRefreshKey(asset) ?? asset.imageSrc}
asset={asset}
/>
))}
</div>
) : null}
{visualCharacterPosition && !isThreeCharacterLayerReady ? (
<div
className="jump-hop-runtime__character"
@@ -1426,14 +1529,27 @@ export function JumpHopRuntimeShell({
{isSettled ? (
<div className="absolute inset-0 z-[120] grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
<div className="w-full max-w-[18rem] rounded-[1.25rem] border border-white/70 bg-white/90 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]">
<div
role="dialog"
aria-modal="true"
aria-labelledby="jump-hop-result-title"
className="w-full max-w-[24rem] rounded-[1.25rem] border border-white/70 bg-white/90 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]"
>
<div className="text-2xl font-black">
{getJumpHopStatusLabel(stageRun?.status)}
<span id="jump-hop-result-title">
{getJumpHopStatusLabel(stageRun?.status)}
</span>
</div>
<div className="mt-2 flex justify-center gap-4 text-sm font-bold text-slate-600">
<span>{successfulJumpCount} </span>
<span>{durationLabel}</span>
</div>
{shouldShowFailureLeaderboard ? (
<JumpHopLeaderboardPanel
profileId={profile?.summary.profileId}
runtimeRequestOptions={runtimeRequestOptions}
/>
) : null}
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
@@ -1456,21 +1572,15 @@ export function JumpHopRuntimeShell({
) : null}
</section>
<footer className="relative z-20 mt-3 flex items-center justify-between gap-3">
<div className="min-w-0 text-sm font-bold text-slate-700">
{error ? (
{error ? (
<footer className="relative z-20 mt-3 flex items-center justify-between gap-3">
<div className="min-w-0 text-sm font-bold text-slate-700">
<span className="text-[var(--platform-button-danger-text)]">
{error}
</span>
) : (
<span>{getJumpHopStatusLabel(stageRun?.status)}</span>
)}
</div>
</footer>
<JumpHopLeaderboardPanel
profileId={profile?.summary.profileId}
runtimeRequestOptions={runtimeRequestOptions}
/>
</div>
</footer>
) : null}
</main>
<style>{`
@@ -1498,12 +1608,38 @@ export function JumpHopRuntimeShell({
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
background:
radial-gradient(circle at 18% 18%, rgba(253, 230, 138, 0.36), transparent 24%),
radial-gradient(circle at 82% 22%, rgba(226, 171, 134, 0.34), transparent 28%),
linear-gradient(180deg, #fffdf9 0%, #f8efe7 52%, #f4e5d7 100%);
}
.jump-hop-runtime__scene-backdrop[data-has-background='true'] {
background: #f8efe7;
}
.jump-hop-runtime__scene-backdrop[data-has-background='true']::after {
content: '';
position: absolute;
inset: 0;
z-index: 2;
background:
radial-gradient(circle at 50% 20%, rgba(255, 255, 255, 0.16), transparent 34%),
linear-gradient(180deg, rgba(255, 253, 249, 0.08), rgba(50, 34, 24, 0.08));
}
.jump-hop-runtime__scene-background-image {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 50%;
user-select: none;
}
.jump-hop-runtime__camera-layer {
position: absolute;
inset: 0;
@@ -1550,6 +1686,12 @@ export function JumpHopRuntimeShell({
opacity 220ms ease;
}
.jump-hop-runtime__stage[data-platform-advancing='true'] .jump-hop-runtime__character {
transition:
transform 120ms ease,
opacity 160ms ease;
}
.jump-hop-runtime__platform-shadow {
position: absolute;
left: 8%;
@@ -1583,6 +1725,22 @@ export function JumpHopRuntimeShell({
overflow: hidden;
}
.jump-hop-runtime__tile-preload {
position: absolute;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.jump-hop-runtime__tile-preload-image {
width: 1px;
height: 1px;
}
.jump-hop-runtime__landing-assist {
position: absolute;
z-index: 110;
@@ -60,12 +60,12 @@ test('jump hop workspace submits theme payload after required field is filled',
templateId: 'jump-hop',
themeText: '云朵跳台',
workTitle: '云朵跳台跳一跳',
workDescription: '云朵跳台主题的俯视角平台跳跃作品',
workDescription: '云朵跳台主题的俯视角跳跃作品',
themeTags: ['云朵跳台', '跳一跳', '休闲'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: '云朵跳台主题的俯视角清爽游戏化立体感平台素材',
tilePrompt: '云朵跳台主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
endMoodPrompt: null,
});
});
@@ -35,12 +35,12 @@ function buildJumpHopWorkspacePayload(
templateId: 'jump-hop',
themeText,
workTitle: `${themeText}跳一跳`,
workDescription: `${themeText}主题的俯视角平台跳跃作品`,
workDescription: `${themeText}主题的俯视角跳跃作品`,
themeTags: [themeText, '跳一跳', '休闲'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: `${themeText}主题的俯视角清爽游戏化立体感平台素材`,
tilePrompt: `${themeText}主题的正面30度视角主题物体图集,物体本身作为跳跃落点`,
endMoodPrompt: null,
};
}
@@ -512,7 +512,7 @@ describe('miniGameDraftGenerationProgress', () => {
const entries = buildJumpHopGenerationAnchorEntries(null, {
themeText: '云端糖果塔',
templateId: 'jump-hop',
tilePrompt: '云端糖果塔主题的俯视角清爽游戏化立体感平台素材',
tilePrompt: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
});
expect(entries).toEqual([
@@ -524,7 +524,7 @@ describe('miniGameDraftGenerationProgress', () => {
{
id: 'jump-hop-tile-style',
label: '地块图集',
value: '云端糖果塔主题的俯视角清爽游戏化立体感平台素材',
value: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
},
]);
});