feat: polish jump hop themed runtime assets

This commit is contained in:
2026-06-05 22:55:40 +08:00
parent a215852381
commit cd8088d1fd
22 changed files with 719 additions and 354 deletions

View File

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

View File

@@ -1660,6 +1660,14 @@
- 验证:`jump_hop.rs` 不应再调用通用物品行数模型处理地块图集;公开结果里应能拿到 25 个独立 `JumpHopTileAsset`,运行态无限路径从地块池随机取材。
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 跳一跳宝可梦主题地块图集 safety rejection 只做专项改写
- 现象:跳一跳草稿使用“宝可梦 / Pokemon / 皮卡丘 / 精灵球”等主题时,背景底图和返回按钮可能已生成成功,但地块图集的 VectorEngine 请求返回 `Your request was rejected by the safety system`,日志里 `failure_context="跳一跳地块图集生成失败"``status=429``code="invalid_prompt"`
- 原因25 个落点图集 prompt 会把这些词放进“主题物体图集”语境,容易被上游理解为要求生成具体宝可梦角色或标志道具,触发安全拦截;这不是普通平台造型词、抠图或超时问题。
- 处理:仅在跳一跳图片生成 prompt 文本命中宝可梦相关词时做生成侧替换,把 `宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon` 改为“原创幻想萌宠冒险道具”,把 `精灵球` 改为“彩色冒险能量球”,把 `皮卡丘 / Pikachu` 改为“黄色闪电萌宠符号”;不要把所有主题都加全局 IP 禁止约束,用户草稿标题和主题展示也不改。
- 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml` 应覆盖宝可梦词专项替换;真实联调时同一草稿重试后,地块图集请求的 prompt 不再包含宝可梦相关词。
- 关联:`server-rs/crates/api-server/src/jump_hop.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 跳一跳地块切片不要按 tileType 复用资产槽位
- 现象:跳一跳生成完成后,运行态看起来仍像在显示默认几何地块,或者地块图片在加载时频闪;结果页地块池也可能只看到少量重复素材。

View File

@@ -423,7 +423,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`JumpHopWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs`
- 说明:作品投影持久化独立 `theme_text`,用于生成主题和公开卡片主题展示;历史行为空时按 `work_title` 兜底。
- 说明:作品投影持久化独立 `theme_text`,用于生成主题和公开卡片主题展示;历史行为空时按 `work_title` 兜底。`back_button_asset_json` 保存 image2 单独生成并去绿后的 1:1 左上角返回按钮资产快照;旧迁移数据按 `None` 兼容,运行态缺失该字段时使用同尺寸 CSS 主题按钮兜底。
### SpacetimeDB view`jump_hop_gallery_card_view`

View File

@@ -146,19 +146,19 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生;
2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色;
3. 地块只调用一次 image2输出一张 `5行*5列``1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key不启用近白底扣除也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制地块造型提示词要求以主题物体本身外轮廓为准允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装所有地块素材必须保持统一正面30度视角相机位于物体正前方略高位置、镜头向下约30度必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面主体正面或侧壁可见面积必须接近或大于顶面面积顶面只能作为辅助可见面水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标主题物体本身必须是唯一可落脚体只能用自身切面、边缘厚度、花瓣层或果皮边表现承重禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集物体本身作为跳跃落点”的口径不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;
4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段OSS 槽位固定为 `background/image.png`,不新增 SpacetimeDB 字段;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感两侧允许更强立体层次和行进感背景只作为底图禁止生成跳板、地块、落脚物、角色、UI、文字、路径箭头或海报排版
3. 地块只调用一次 image2输出一张 `5行*5列``1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key不启用近白底扣除也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制地块造型提示词要求以主题物体本身外轮廓为准允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装所有地块素材必须保持统一正面30度视角相机位于物体正前方略高位置、镜头向下约30度必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面主体正面或侧壁可见面积必须接近或大于顶面面积顶面只能作为辅助可见面水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标主题物体本身必须是唯一可落脚体只能用自身切面、边缘厚度、花瓣层或果皮边表现承重禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集物体本身作为跳跃落点”的口径不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改;
4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感两侧允许更强立体层次和行进感背景只作为底图禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版;左上角返回按钮不允许画进背景,而是单独生成 `backButtonAsset` 透明 PNGOSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile
5. 后端按从上到下、从左到右均匀切分为 `tile-01``tile-25` 的透明 PNG每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位;
6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;
7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。
运行态规则真相必须沉到 `module-jump-hop`前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。
每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数,不展示计时器或右上角重开按钮;舞台区域不得再表现为带边框卡片,游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。
每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数,不展示计时器或右上角重开按钮;生成背景和游戏舞台必须覆盖整个运行态视口HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。
运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图图片读取继续走平台资产换签没有背景时才回退到内置渐变DOM 平台层直接使用 `tileAssets[]` 的生成切片图片显示地块,图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存;每个地块下方的统一软椭圆阴影来自运行态 DOM 的 `.jump-hop-runtime__platform-shadow`,不是 image2 地块切片的必需内容,调整阴影优先改运行态 CSS有真实地块图片 URL 时不得在加载空档显示 fallback 原型地块下一屏预览地块必须在进入相机视野前隐藏预加载DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持最高层级Three.js 透明画布仅作为后续扩展层。拖拽蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景或平台图片层,否则会造成背景、地块和角色层频闪。
跳一跳当前拖拽手感统一采用 `chargeToDistanceRatio=0.008`,用于把同等跳跃距离所需拖拽距离缩短到旧 `0.004` 的一半;如果历史路径仍保存旧系数,`start_run` 会在开局归一化到新系数。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的角色飞行动画:蓄力时角色沿拖拽方向明显拉长,角色弹向预测落点,落地后向反方向回弹两次;动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测落点等待,直到新 run 到达后再把显示态切到后端最新 run并用约 `1440ms` 的相机层推进过渡承接新窗口。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。相机推进期间角色自身必须禁用 `left/top` transition只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。
跳一跳当前拖拽手感统一采用 `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 失败、刷新回首页。

View File

@@ -61,6 +61,7 @@ export interface JumpHopActionRequest {
tileAtlasAsset?: JumpHopCharacterAsset | null;
tileAssets?: JumpHopTileAsset[] | null;
coverComposite?: string | null;
backButtonAsset?: JumpHopCharacterAsset | null;
}
export interface JumpHopCharacterAsset {
@@ -153,6 +154,7 @@ export interface JumpHopDraftResponse {
tileAssets: JumpHopTileAsset[];
path: JumpHopPath | null;
coverComposite: string | null;
backButtonAsset?: JumpHopCharacterAsset | null;
generationStatus: JumpHopGenerationStatus;
}
@@ -204,6 +206,7 @@ export interface JumpHopWorkProfileResponse {
characterAsset: JumpHopCharacterAsset;
tileAtlasAsset: JumpHopCharacterAsset;
tileAssets: JumpHopTileAsset[];
backButtonAsset?: JumpHopCharacterAsset | null;
}
export interface JumpHopWorksResponse {

View File

@@ -61,6 +61,9 @@ const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF";
const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536";
const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024;
const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536;
const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024";
const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024;
const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
struct JumpHopTileAtlasSlice {
@@ -444,8 +447,12 @@ async fn maybe_generate_jump_hop_assets(
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value));
let has_back_button_asset = payload
.back_button_asset
.as_ref()
.is_some_and(is_jump_hop_image_asset_usable);
if has_complete_tile_assets && has_real_background {
if has_complete_tile_assets && has_real_background && has_back_button_asset {
return Ok(());
}
let profile_id = payload
@@ -529,6 +536,58 @@ async fn maybe_generate_jump_hop_assets(
payload.cover_composite = Some(background_asset.image_src);
}
if !has_back_button_asset {
let back_button_prompt = build_jump_hop_back_button_prompt(theme_text.as_str());
let back_button_generated = create_openai_image_generation(
&http_client,
&settings,
back_button_prompt.as_str(),
Some(build_jump_hop_back_button_negative_prompt()),
JUMP_HOP_BACK_BUTTON_IMAGE_SIZE,
1,
&[],
"跳一跳返回按钮图生成失败",
)
.await
.map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let back_button_image =
back_button_generated
.images
.into_iter()
.next()
.ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳返回按钮图生成成功但未返回图片。",
})),
)
})?;
let back_button_image =
prepare_jump_hop_green_screen_image_for_persist(back_button_image, "跳一跳返回按钮图")
.map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let back_button_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"back-button",
back_button_prompt.as_str(),
back_button_image,
LegacyAssetPrefix::JumpHopAssets,
JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH,
JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT,
request_context,
)
.await?;
payload.back_button_asset = Some(back_button_asset);
}
if !has_complete_tile_assets {
let sheet_prompt =
build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str());
@@ -604,33 +663,110 @@ fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool {
&& (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-"))
}
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
fn is_jump_hop_image_asset_usable(asset: &JumpHopCharacterAsset) -> bool {
!asset.image_src.trim().is_empty()
&& !asset.image_object_key.trim().is_empty()
&& !asset.asset_object_id.trim().is_empty()
&& !asset.generation_provider.trim().is_empty()
}
fn prepare_jump_hop_green_screen_image_for_persist(
image: crate::openai_image_generation::DownloadedOpenAiImage,
failure_label: &str,
) -> Result<crate::openai_image_generation::DownloadedOpenAiImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": JUMP_HOP_CREATION_PROVIDER,
"message": format!("{failure_label}解码失败:{error}"),
}))
})?;
let mut encoded = std::io::Cursor::new(Vec::new());
crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source)
.write_to(&mut encoded, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": JUMP_HOP_CREATION_PROVIDER,
"message": format!("{failure_label}绿幕去背失败:{error}"),
}))
})?;
Ok(crate::openai_image_generation::DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String {
let theme_text = theme_text.trim();
let theme_text = if theme_text.is_empty() {
"跳一跳"
} else {
theme_text
};
if theme_text.is_empty() {
return "跳一跳".to_string();
}
replace_jump_hop_pokemon_prompt_terms(theme_text)
}
fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String {
let mut value = value.trim().to_string();
if value.is_empty() {
return value;
}
// 中文注释:仅对宝可梦相关词做生成侧脱敏,避免地块图集触发上游安全拦截。
const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [
("宝可梦", "原创幻想萌宠冒险道具"),
("神奇宝贝", "原创幻想萌宠冒险道具"),
("口袋妖怪", "原创幻想萌宠冒险道具"),
("精灵球", "彩色冒险能量球"),
("皮卡丘", "黄色闪电萌宠符号"),
("Pokémon", "原创幻想萌宠冒险道具"),
("Pokemon", "原创幻想萌宠冒险道具"),
("POKEMON", "原创幻想萌宠冒险道具"),
("pokemon", "原创幻想萌宠冒险道具"),
("Pikachu", "黄色闪电萌宠符号"),
("PIKACHU", "黄色闪电萌宠符号"),
("pikachu", "黄色闪电萌宠符号"),
("Poké Ball", "彩色冒险能量球"),
("Poke Ball", "彩色冒险能量球"),
("pokeball", "彩色冒险能量球"),
];
for (from, to) in POKEMON_REPLACEMENTS {
value = value.replace(from, to);
}
value
}
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
format!(
"生成一张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."
"生成一张9:16竖版跳一跳游戏背景底图主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊从画面底部延伸到上方该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感但元素数量必须少不能抢跳板、角色和交互层的视觉两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印;左上角也不要画返回按钮或任何固定图标,运行态会叠加独立可点击按钮资产\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然哑光手绘质感柔和光照主体背景不油亮、不厚重CG、不暗黑中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no top-left back button, no score UI, no other UI panels, consistent 2D/2.5D front-facing 30-degree game perspective."
)
}
fn build_jump_hop_background_negative_prompt() -> &'static str {
"文字、Logo、水印、UI按钮、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
"文字、Logo、水印、UI按钮、返回按钮、左上角图标、右上角按钮、底部按钮、UI面板、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
}
fn build_jump_hop_back_button_prompt(theme_text: &str) -> String {
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
format!(
"生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标圆心居中主体视觉尺寸占画布约72%-82%外沿有一圈干净描边内部只有一个居中的向左箭头不要写“返回”文字不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1输出绿色背景主体图背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal."
)
}
fn build_jump_hop_back_button_negative_prompt() -> &'static str {
"文字、返回文字、Logo、水印、数字、多个按钮、UI面板、海报排版、复杂徽章、花盘、浮雕边、异形外框、主题物体主体、木槌、角色、跳板、地块、落脚物、平台、透明棋盘格、白底、黑底、灰底、真实摄影、厚重CG、暗黑幻想风、油亮塑料、纯绿色按钮主体、与绿幕混在一起"
}
fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String {
let theme_text = theme_text.trim();
let theme_text = if theme_text.is_empty() {
"跳一跳"
} else {
theme_text
};
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt);
let subject_text = if sanitized_tile_prompt.is_empty() {
theme_text
theme_text.as_str()
} else {
sanitized_tile_prompt.as_str()
};
@@ -649,6 +785,7 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String {
if value.is_empty() {
return value;
}
value = replace_jump_hop_pokemon_prompt_terms(value.as_str());
const REPLACEMENTS: [(&str, &str); 18] = [
("俯视角", "正面30度视角"),
@@ -1134,6 +1271,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft
tile_assets: Vec::new(),
path: None,
cover_composite: None,
back_button_asset: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}
@@ -1376,13 +1514,32 @@ mod tests {
assert!(prompt.contains("中央纵轴1/2区域要有明显纵深感"));
assert!(prompt.contains("两侧可以更有立体感、空间层次和主题氛围"));
assert!(prompt.contains("不画任何跳板、地块、落脚物、角色、UI按钮"));
assert!(prompt.contains("左上角也不要画返回按钮或任何固定图标"));
assert!(prompt.contains("运行态会叠加独立可点击按钮资产"));
assert!(prompt.contains("视角保持正面约30度"));
assert!(prompt.contains("中央区域需要给运行态地块和陶泥儿角色留出干净可读空间"));
assert!(prompt.contains("English guardrail"));
assert!(prompt.contains("left and right sides carry the atmosphere"));
assert!(prompt.contains("central vertical half-width corridor stays simple"));
assert!(prompt.contains("no top-left back button"));
assert!(prompt.contains("no platforms"));
assert!(prompt.contains("no landing objects"));
assert!(prompt.contains("no other UI panels"));
}
#[test]
fn jump_hop_back_button_prompt_builds_standalone_green_screen_asset() {
let prompt = build_jump_hop_back_button_prompt("水果");
assert!(prompt.contains("独立透明素材"));
assert!(prompt.contains("主题关键词严格只使用“水果”"));
assert!(prompt.contains("单个标准圆形图标"));
assert!(prompt.contains("内部只有一个居中的向左箭头"));
assert!(prompt.contains("不要写“返回”文字"));
assert!(prompt.contains("背景必须是单一纯绿色 #00FF00"));
assert!(prompt.contains("后续由服务端扣除绿色背景"));
assert!(prompt.contains("one standalone circular mobile game back button asset only"));
assert!(prompt.contains("solid #00FF00 green-screen background"));
}
#[test]
@@ -1394,6 +1551,11 @@ mod tests {
assert!(negative_prompt.contains("落脚物"));
assert!(negative_prompt.contains("角色"));
assert!(negative_prompt.contains("UI按钮"));
assert!(negative_prompt.contains("返回按钮"));
assert!(negative_prompt.contains("左上角图标"));
assert!(negative_prompt.contains("右上角按钮"));
assert!(negative_prompt.contains("底部按钮"));
assert!(negative_prompt.contains("UI面板"));
assert!(negative_prompt.contains("中央堆满元素"));
assert!(negative_prompt.contains("中央遮挡"));
assert!(negative_prompt.contains("纯俯视地图"));
@@ -1416,6 +1578,34 @@ mod tests {
));
}
#[test]
fn jump_hop_generation_prompt_only_rewrites_pokemon_terms() {
let background_prompt = build_jump_hop_background_prompt("宝可梦");
assert!(background_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
assert!(!background_prompt.contains("宝可梦"));
let back_button_prompt = build_jump_hop_back_button_prompt("Pokemon");
assert!(back_button_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
assert!(!back_button_prompt.contains("Pokemon"));
let tile_prompt = build_jump_hop_tile_atlas_prompt(
"宝可梦",
"宝可梦主题的正面30度视角主题物体图集包含皮卡丘和精灵球装饰",
);
assert!(tile_prompt.contains("主题为“原创幻想萌宠冒险道具”"));
assert!(tile_prompt.contains("画面内容是原创幻想萌宠冒险道具主题"));
assert!(tile_prompt.contains("黄色闪电萌宠符号"));
assert!(tile_prompt.contains("彩色冒险能量球"));
assert!(!tile_prompt.contains("宝可梦"));
assert!(!tile_prompt.contains("皮卡丘"));
assert!(!tile_prompt.contains("精灵球"));
let normal_prompt =
build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集");
assert!(normal_prompt.contains("主题为“水果”"));
assert!(normal_prompt.contains("画面内容是水果主题的正面30度视角主题物体图集"));
}
#[test]
fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() {
let prompt = build_jump_hop_tile_atlas_prompt(

View File

@@ -121,6 +121,8 @@ pub struct JumpHopActionRequest {
pub tile_assets: Option<Vec<JumpHopTileAsset>>,
#[serde(default)]
pub cover_composite: Option<String>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -240,6 +242,8 @@ pub struct JumpHopDraftResponse {
pub path: Option<JumpHopPath>,
#[serde(default)]
pub cover_composite: Option<String>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
pub generation_status: JumpHopGenerationStatus,
}
@@ -308,6 +312,8 @@ pub struct JumpHopWorkProfileResponse {
pub character_asset: JumpHopCharacterAsset,
pub tile_atlas_asset: JumpHopCharacterAsset,
pub tile_assets: Vec<JumpHopTileAsset>,
#[serde(default)]
pub back_button_asset: Option<JumpHopCharacterAsset>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -706,6 +706,14 @@ fn merge_action_into_draft(
{
draft.cover_composite = Some(value.to_string());
}
if matches!(
scope,
JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles
) {
if let Some(asset) = payload.back_button_asset.clone() {
draft.back_button_asset = Some(asset);
}
}
if draft.work_title.trim().is_empty() {
return Err(SpacetimeClientError::validation_failed(
"jump-hop work_title 不能为空",
@@ -763,6 +771,11 @@ fn build_compile_input(
tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?),
tile_assets_json: Some(json_string(&tile_assets)?),
cover_composite,
back_button_asset_json: draft
.back_button_asset
.as_ref()
.map(json_string)
.transpose()?,
generation_status: Some("ready".to_string()),
compiled_at_micros: now_micros,
})
@@ -848,6 +861,7 @@ fn default_draft() -> JumpHopDraftResponse {
tile_assets: Vec::new(),
path: None,
cover_composite: None,
back_button_asset: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}

View File

@@ -159,6 +159,7 @@ fn map_jump_hop_work_snapshot(
.collect(),
path: Some(map_jump_hop_path(snapshot.path.clone())),
cover_composite: snapshot.cover_composite.clone(),
back_button_asset: snapshot.back_button_asset.clone().map(map_character_asset),
generation_status: parse_generation_status(&snapshot.generation_status),
};
let character_asset = draft
@@ -201,6 +202,7 @@ fn map_jump_hop_work_snapshot(
.into_iter()
.map(map_tile_asset)
.collect(),
back_button_asset: snapshot.back_button_asset.map(map_character_asset),
})
}
@@ -233,6 +235,7 @@ fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftRe
.collect(),
path: snapshot.path.map(map_jump_hop_path),
cover_composite: snapshot.cover_composite,
back_button_asset: snapshot.back_button_asset.map(map_character_asset),
generation_status: parse_generation_status(&snapshot.generation_status),
}
}

View File

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

View File

@@ -28,6 +28,7 @@ pub struct JumpHopDraftSnapshot {
pub tile_assets: Vec<JumpHopTileAssetSnapshot>,
pub path: Option<JumpHopPath>,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub generation_status: String,
}

View File

@@ -33,6 +33,7 @@ pub struct JumpHopWorkProfileRow {
pub published_at: Option<__sdk::Timestamp>,
pub visible: bool,
pub theme_text: Option<String>,
pub back_button_asset_json: Option<String>,
}
impl __sdk::InModule for JumpHopWorkProfileRow {
@@ -69,6 +70,7 @@ pub struct JumpHopWorkProfileRowCols {
pub published_at: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<__sdk::Timestamp>>,
pub visible: __sdk::__query_builder::Col<JumpHopWorkProfileRow, bool>,
pub theme_text: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<String>>,
pub back_button_asset_json: __sdk::__query_builder::Col<JumpHopWorkProfileRow, Option<String>>,
}
impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow {
@@ -110,6 +112,10 @@ impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow {
published_at: __sdk::__query_builder::Col::new(table_name, "published_at"),
visible: __sdk::__query_builder::Col::new(table_name, "visible"),
theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"),
back_button_asset_json: __sdk::__query_builder::Col::new(
table_name,
"back_button_asset_json",
),
}
}
}

View File

@@ -31,6 +31,7 @@ pub struct JumpHopWorkSnapshot {
pub path: JumpHopPath,
pub cover_image_src: String,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,

View File

@@ -312,6 +312,7 @@ fn create_jump_hop_agent_session_tx(
tile_assets: Vec::new(),
path: None,
cover_composite: None,
back_button_asset: None,
generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(),
};
ctx.db
@@ -391,6 +392,11 @@ fn compile_jump_hop_draft_tx(
.unwrap_or_default(),
path: Some(path.clone()),
cover_composite: input.cover_composite.as_deref().and_then(clean_optional),
back_button_asset: input
.back_button_asset_json
.as_deref()
.map(parse_json)
.transpose()?,
generation_status: input
.generation_status
.clone()
@@ -425,6 +431,7 @@ fn compile_jump_hop_draft_tx(
path_json: to_json_string(&path),
cover_image_src: draft.cover_composite.clone().unwrap_or_default(),
cover_composite: draft.cover_composite.clone().unwrap_or_default(),
back_button_asset_json: draft.back_button_asset.as_ref().map(to_json_string),
generation_status: draft.generation_status.clone(),
publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(),
play_count: 0,
@@ -830,6 +837,12 @@ fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result<JumpHopWorkSnapsho
path,
cover_image_src: row.cover_image_src.clone(),
cover_composite: clean_optional(&row.cover_composite),
back_button_asset: row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.map(|value| parse_json(&value))
.transpose()?,
publication_status: row.publication_status.clone(),
publish_ready: is_publish_ready(row),
play_count: row.play_count,
@@ -889,6 +902,12 @@ fn sync_session_from_work_update(
tile_assets: parse_json_or_default(&work.tile_assets_json),
path: Some(parse_json(&work.path_json)?),
cover_composite: clean_optional(&work.cover_composite),
back_button_asset: work
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.map(|value| parse_json(&value))
.transpose()?,
generation_status: work.generation_status.clone(),
};
@@ -1209,6 +1228,11 @@ fn is_publish_ready(row: &JumpHopWorkProfileRow) -> bool {
&& !row.tile_atlas_asset_json.trim().is_empty()
&& !row.tile_assets_json.trim().is_empty()
&& !row.path_json.trim().is_empty()
&& row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.is_some()
}
fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot {
@@ -1399,6 +1423,7 @@ fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow {
published_at: row.published_at,
visible: row.visible,
theme_text: row.theme_text.clone(),
back_button_asset_json: row.back_button_asset_json.clone(),
}
}

View File

@@ -59,6 +59,9 @@ pub struct JumpHopWorkProfileRow {
// 跳一跳生成主题独立于作品标题;旧行按 work_title 兜底。
#[default(None::<String>)]
pub(crate) theme_text: Option<String>,
// 跳一跳左上角真实可点击返回按钮的独立透明资产快照;旧行为空时运行态使用样式兜底。
#[default(None::<String>)]
pub(crate) back_button_asset_json: Option<String>,
}
#[spacetimedb::table(

View File

@@ -56,6 +56,7 @@ pub struct JumpHopDraftCompileInput {
pub tile_atlas_asset_json: Option<String>,
pub tile_assets_json: Option<String>,
pub cover_composite: Option<String>,
pub back_button_asset_json: Option<String>,
pub generation_status: Option<String>,
pub compiled_at_micros: i64,
}
@@ -248,6 +249,7 @@ pub struct JumpHopDraftSnapshot {
pub tile_assets: Vec<JumpHopTileAssetSnapshot>,
pub path: Option<module_jump_hop::JumpHopPath>,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub generation_status: String,
}
@@ -291,6 +293,7 @@ pub struct JumpHopWorkSnapshot {
pub path: module_jump_hop::JumpHopPath,
pub cover_image_src: String,
pub cover_composite: Option<String>,
pub back_button_asset: Option<JumpHopCharacterAssetSnapshot>,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,

View File

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

View File

@@ -168,6 +168,7 @@ function buildProfile(
tileAssets: [],
path: null,
coverComposite: null,
backButtonAsset: null,
generationStatus: 'ready',
},
path: null as never,
@@ -199,5 +200,6 @@ function buildProfile(
height: 0,
},
tileAssets: [],
backButtonAsset: null,
};
}

View File

@@ -253,6 +253,70 @@ test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () =>
expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull();
});
test('跳一跳运行态背景和游戏舞台覆盖全部界面且 HUD 使用独立主题按钮和拼图顶部样式', () => {
const backButtonAsset = {
assetId: 'jump-hop-back-button',
imageSrc: '/generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png',
imageObjectKey:
'generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png',
assetObjectId: 'asset-back-button',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '主题返回按钮',
width: 1024,
height: 1024,
} satisfies NonNullable<JumpHopWorkProfileResponse['backButtonAsset']>;
render(
<JumpHopRuntimeShell
profile={buildProfile({ backButtonAsset })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
expect(stage.className).toContain('absolute');
expect(stage.className).toContain('inset-0');
expect(stage.className).toContain('h-full');
expect(stage.className).toContain('w-full');
expect(stage.className).not.toContain('rounded-[1.5rem]');
const backButton = screen.getByRole('button', { name: '返回' });
expect(backButton.className).toContain('pointer-events-auto');
expect(backButton.className).toContain('jump-hop-runtime__back-button');
expect(backButton.className).toContain('h-14');
expect(backButton.className).toContain('w-14');
expect(backButton.className).toContain('sm:h-[3.875rem]');
expect(backButton.className).toContain('sm:w-[3.875rem]');
expect(backButton.getAttribute('data-has-asset')).toBe('true');
expect(backButton.textContent).toBe('');
expect(
screen
.getByTestId('jump-hop-runtime-back-button-asset')
.getAttribute('src'),
).toBe(backButtonAsset.imageSrc);
const header = backButton.closest('header');
expect(header?.className).toContain('absolute');
expect(header?.className).toContain('top-0');
expect(header?.className).toContain('z-[130]');
expect(header?.querySelector('.puzzle-runtime-header-card')).toBeTruthy();
const titleCard = header?.querySelector('.puzzle-runtime-level-title-card');
expect(titleCard).toBeTruthy();
expect(titleCard?.className).toContain('jump-hop-runtime__score-title-card');
expect(screen.getByTestId('jump-hop-runtime-level-logo')).toBeTruthy();
expect(screen.getByText('得分')).toBeTruthy();
expect(screen.queryByText('跳一跳')).toBeNull();
const scoreCard = screen.getByTestId('jump-hop-score-card');
expect(scoreCard.className).toContain('puzzle-runtime-timer-card');
expect(scoreCard.className).toContain('puzzle-runtime-timer');
expect(scoreCard.className).toContain('jump-hop-runtime__score-value-card');
expect(scoreCard.className).toContain('justify-center');
expect(scoreCard.className).toContain('text-center');
});
test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
const runtimeRequestOptions = {
runtimeGuestToken: 'runtime-guest-token',
@@ -333,7 +397,7 @@ test('跳一跳角色层永远压在地块层之上', () => {
);
});
test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', async () => {
test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () => {
const onJump = vi.fn().mockResolvedValue(undefined);
render(
@@ -354,8 +418,6 @@ test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', as
});
});
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
@@ -364,11 +426,8 @@ test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', as
});
});
const firstAssist = screen.getByTestId('jump-hop-landing-assist');
const firstLeft = firstAssist.style.left;
const firstTop = firstAssist.style.top;
expect(firstAssist.getAttribute('data-target-index')).toBe('1');
expect(firstLeft).not.toBe('62.288%');
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
@@ -378,9 +437,8 @@ test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', as
});
});
const secondAssist = screen.getByTestId('jump-hop-landing-assist');
expect(secondAssist.style.left).not.toBe(firstLeft);
expect(secondAssist.style.top).not.toBe(firstTop);
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
});
test('跳一跳运行态直接渲染生成的地块切片图片', () => {
@@ -958,6 +1016,7 @@ function buildProfile(options: {
tileAssets?: JumpHopWorkProfileResponse['tileAssets'];
coverComposite?: string | null;
coverImageSrc?: string | null;
backButtonAsset?: JumpHopWorkProfileResponse['backButtonAsset'];
publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
} = {}): JumpHopWorkProfileResponse {
const characterAsset = {
@@ -1016,6 +1075,7 @@ function buildProfile(options: {
tileAssets: options.tileAssets ?? [],
path: buildRun().path,
coverComposite: options.coverComposite ?? null,
backButtonAsset: options.backButtonAsset ?? null,
generationStatus: 'ready',
},
path: buildRun().path,
@@ -1029,6 +1089,7 @@ function buildProfile(options: {
characterAsset,
tileAtlasAsset: characterAsset,
tileAssets: options.tileAssets ?? [],
backButtonAsset: options.backButtonAsset ?? null,
};
}

View File

@@ -11,6 +11,7 @@ import {
useState,
} from 'react';
import jumpHopRuntimeLevelLogo from '../../../media/logo.png';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopTileAsset,
@@ -622,6 +623,20 @@ export function JumpHopRuntimeShell({
refreshKey: stageBackgroundSource,
},
);
const backButtonAssetSource =
profile?.backButtonAsset?.imageSrc?.trim() ||
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
null;
const { resolvedUrl: backButtonAssetUrl } = useResolvedAssetReadUrl(
backButtonAssetSource,
{
refreshKey:
profile?.backButtonAsset?.assetObjectId ||
profile?.draft.backButtonAsset?.assetObjectId ||
backButtonAssetSource ||
undefined,
},
);
useEffect(() => {
activeRunRef.current = activeRun;
@@ -717,8 +732,6 @@ export function JumpHopRuntimeShell({
stageRun?.path,
visiblePlatforms.length,
]);
const showLandingAssist =
import.meta.env.MODE !== 'production' && isCharging && !isJumpAnimating;
const characterPosition = getJumpHopCharacterVisualPosition(
stageRun,
visiblePlatforms,
@@ -829,17 +842,6 @@ export function JumpHopRuntimeShell({
landingAssistStageSize.width,
visualJump,
]);
const landingAssistPosition = showLandingAssist
? getJumpHopLandingAssistVisualPosition(
stageRun,
visiblePlatforms,
visualCharacterPosition,
landingAssistStageSize,
dragDistance,
dragVector.x,
dragVector.y,
)
: null;
const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun);
const isSettled =
stageRun?.status === 'failed' || stageRun?.status === 'cleared';
@@ -1308,33 +1310,14 @@ export function JumpHopRuntimeShell({
};
return (
<div className="platform-remap-surface jump-hop-runtime relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#fffdf9] text-slate-950">
<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 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 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>
</div>
<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">
<div className="platform-remap-surface jump-hop-runtime relative h-full min-h-dvh w-full overflow-hidden bg-[#fffdf9] text-slate-950">
<section
ref={stageRef}
data-testid="jump-hop-stage"
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]"
className="jump-hop-runtime__stage absolute inset-0 h-full w-full touch-none select-none overflow-hidden"
onPointerDown={beginCharge}
onPointerMove={updateDragVector}
onPointerUp={(event) => void finishCharge(event)}
@@ -1460,29 +1443,6 @@ export function JumpHopRuntimeShell({
) : null}
</div>
{landingAssistPosition ? (
(() => {
return (
<div
aria-hidden="true"
data-testid="jump-hop-landing-assist"
data-target-index={landingAssistPosition.targetPlatformIndex}
className="jump-hop-runtime__landing-assist"
style={
{
left: `${landingAssistPosition.screenX}%`,
top: `${landingAssistPosition.screenY}%`,
width: 28,
height: 28,
} as CSSProperties
}
>
<div className="jump-hop-runtime__landing-assist-core" />
</div>
);
})()
) : null}
{isCharging && dragPointerPosition && characterPosition ? (
<div
aria-hidden="true"
@@ -1574,33 +1534,120 @@ export function JumpHopRuntimeShell({
) : null}
</section>
{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}
<header className="pointer-events-none absolute inset-x-0 top-0 z-[130] grid grid-cols-[3.5rem_minmax(0,1fr)_3.5rem] items-start gap-2 px-3 pt-[calc(env(safe-area-inset-top,0px)+0.65rem)] sm:grid-cols-[3.875rem_minmax(0,1fr)_3.875rem] sm:px-4">
<button
type="button"
onClick={exitHandler}
aria-label="返回"
title="返回"
data-has-asset={backButtonAssetUrl ? 'true' : 'false'}
className="jump-hop-runtime__back-button pointer-events-auto -mt-0.5 inline-flex h-14 w-14 items-center justify-center justify-self-start rounded-full transition hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 sm:-mt-1 sm:h-[3.875rem] sm:w-[3.875rem]"
>
{backButtonAssetUrl ? (
<img
src={backButtonAssetUrl}
alt=""
aria-hidden="true"
draggable={false}
data-testid="jump-hop-runtime-back-button-asset"
className="jump-hop-runtime__back-button-image"
/>
) : (
<ArrowLeft className="h-7 w-7 drop-shadow-[0_1px_2px_rgba(255,255,255,0.74)] sm:h-[2.1rem] sm:w-[2.1rem]" />
)}
</button>
<div className="puzzle-runtime-header-card pointer-events-auto mx-auto flex max-w-[min(18.5rem,calc(100vw_-_8rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
<div className="puzzle-runtime-level-title-card jump-hop-runtime__score-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 sm:px-4">
<span
aria-hidden="true"
className="puzzle-runtime-level-logo jump-hop-runtime__score-title-logo"
>
<img
src={jumpHopRuntimeLevelLogo}
alt=""
data-testid="jump-hop-runtime-level-logo"
className="puzzle-runtime-level-logo__image"
draggable={false}
/>
</span>
<span className="puzzle-runtime-level-badge jump-hop-runtime__score-title-text text-[0.92rem] font-black sm:text-base">
</span>
</div>
<div
data-testid="jump-hop-score-card"
className="puzzle-runtime-timer-card puzzle-runtime-timer jump-hop-runtime__score-value-card -mt-px inline-flex items-center justify-center gap-1.5 px-3.5 py-1.5 text-center font-mono text-lg font-black leading-none sm:text-xl"
>
<span>{successfulJumpCount}</span>
</div>
</div>
<div aria-hidden="true" />
</header>
{error ? (
<footer className="pointer-events-none absolute inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-[130] flex items-center justify-center px-3">
<div className="pointer-events-auto min-w-0 rounded-full bg-white/82 px-3 py-2 text-sm font-bold text-[var(--platform-button-danger-text)] shadow-sm backdrop-blur">
{error}
</div>
</footer>
) : null}
</main>
<style>{`
.jump-hop-runtime__sky {
position: absolute;
inset: 0;
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__stage {
min-height: 31rem;
isolation: isolate;
cursor: pointer;
}
.jump-hop-runtime__back-button {
border: 2px solid rgba(255, 216, 173, 0.72);
background:
radial-gradient(circle at 38% 26%, rgba(255, 242, 216, 0.9), transparent 28%),
linear-gradient(135deg, #d7803f 0%, #b95527 58%, #8e3e22 100%);
color: #fffaf2;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.42),
0 8px 18px rgba(86, 43, 18, 0.18);
}
.jump-hop-runtime__back-button[data-has-asset='true'] {
border-color: transparent;
background: transparent;
box-shadow: none;
}
.jump-hop-runtime__back-button-image {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
user-select: none;
filter: drop-shadow(0 8px 14px rgba(63, 36, 18, 0.22));
}
.jump-hop-runtime__score-title-card {
padding-left: 3.35rem;
padding-right: 3.35rem;
}
.jump-hop-runtime__score-title-logo {
position: absolute;
left: 0.45rem;
top: 50%;
margin: 0;
transform: translateY(-50%);
}
.jump-hop-runtime__score-title-text {
display: block;
min-width: 2.5rem;
text-align: center;
}
.jump-hop-runtime__score-value-card {
justify-content: center;
text-align: center;
}
.jump-hop-runtime__stage[data-charging='true'] {
cursor: grabbing;
}
@@ -1743,30 +1790,6 @@ export function JumpHopRuntimeShell({
height: 1px;
}
.jump-hop-runtime__landing-assist {
position: absolute;
z-index: 110;
display: grid;
place-items: center;
transform: translate(-50%, -50%);
border: 2px dashed rgba(17, 94, 89, 0.82);
border-radius: 999px;
background:
radial-gradient(circle, rgba(16, 185, 129, 0.28) 0 20%, rgba(167, 243, 208, 0.16) 21% 48%, transparent 49%);
box-shadow:
0 0 0 4px rgba(255, 255, 255, 0.56),
0 0 18px rgba(17, 94, 89, 0.18);
pointer-events: none;
}
.jump-hop-runtime__landing-assist-core {
width: 0.68rem;
height: 0.68rem;
border-radius: 999px;
background: #0f766e;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.78);
}
.jump-hop-runtime__fallback-tile {
position: relative;
width: 100%;
@@ -2070,8 +2093,7 @@ export function JumpHopRuntimeShell({
@media (max-width: 430px) {
.jump-hop-runtime__stage {
min-height: min(68vh, 36rem);
border-radius: 1.25rem;
min-height: 100%;
}
.jump-hop-runtime__character {

View File

@@ -1634,6 +1634,7 @@ function buildMockJumpHopWork(
tileAssets,
path,
coverComposite: 'data:image/png;base64,cover',
backButtonAsset: null,
generationStatus: 'ready' as const,
};
@@ -1664,6 +1665,7 @@ function buildMockJumpHopWork(
characterAsset,
tileAtlasAsset,
tileAssets,
backButtonAsset: null,
};
}