diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 81b5c42a..f1bca0e8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -40,6 +40,14 @@ - 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实联调时按 `provider=aliyun-oss` 与 `operation` 过滤日志,确认只出现对象定位和状态字段,不出现签名材料。 - 关联文档:`server-rs/crates/platform-oss/README.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-06-05 跳一跳返回按钮改为独立主题资产 + +- 背景:跳一跳运行态曾把左上角返回按钮视觉锚点写进背景 image2 prompt,导致返回按钮像静态背景元素,不能替代真实可点击按钮。 +- 决策:跳一跳背景 prompt 禁止生成任何 UI 或左上角图标;返回按钮由 `backButtonAsset` 单独生成 1:1 纯绿 key 图,后端去绿后作为透明 PNG 持久化到作品 profile,运行态左上角真实按钮优先渲染该资产。顶部得分 HUD 复用拼图模板结构,包含陶泥儿 IP logo、标题牌和下挂数字卡。 +- 影响范围:`packages/shared/src/contracts/jumpHop.ts`、`shared-contracts`、`spacetime-module` / `spacetime-client` bindings、`api-server` 跳一跳生成链路、`JumpHopRuntimeShell`、玩法链路文档和后端数据契约文档。 +- 验证方式:`npm run spacetime:generate`、`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`npm run check:spacetime-schema`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-03 创作入口关闭不下架已发布作品 - 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index d6bae5a0..feb5b666 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -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 复用资产槽位 - 现象:跳一跳生成完成后,运行态看起来仍像在显示默认几何地块,或者地块图片在加载时频闪;结果页地块池也可能只看到少量重复素材。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 530b7179..a5fc8c2b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index cebf146d..6c68b970 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -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` 透明 PNG,OSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile; 5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; 6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽; 7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。 运行态规则真相必须沉到 `module-jump-hop`,前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。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 失败、刷新回首页。 diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 2127fd7f..a5b6d9e9 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -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 { diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index dcc06949..762f7c24 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -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 { + 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( diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index 0684a314..cbad6f68 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -121,6 +121,8 @@ pub struct JumpHopActionRequest { pub tile_assets: Option>, #[serde(default)] pub cover_composite: Option, + #[serde(default)] + pub back_button_asset: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -240,6 +242,8 @@ pub struct JumpHopDraftResponse { pub path: Option, #[serde(default)] pub cover_composite: Option, + #[serde(default)] + pub back_button_asset: Option, pub generation_status: JumpHopGenerationStatus, } @@ -308,6 +312,8 @@ pub struct JumpHopWorkProfileResponse { pub character_asset: JumpHopCharacterAsset, pub tile_atlas_asset: JumpHopCharacterAsset, pub tile_assets: Vec, + #[serde(default)] + pub back_button_asset: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 4f3ed703..8470345b 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -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, } } diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs index 6836d6e2..eec6ba97 100644 --- a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -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), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs index 519e5acd..93176d49 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_leaderboard_procedure.rs @@ -31,10 +31,10 @@ pub trait get_jump_hop_leaderboard { input: JumpHopLeaderboardGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl get_jump_hop_leaderboard for super::RemoteProcedures { input: JumpHopLeaderboardGetInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, JumpHopLeaderboardProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs index d8f3e7f5..df9575f0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs @@ -25,6 +25,7 @@ pub struct JumpHopDraftCompileInput { pub tile_atlas_asset_json: Option, pub tile_assets_json: Option, pub cover_composite: Option, + pub back_button_asset_json: Option, pub generation_status: Option, pub compiled_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs index cc2f6d8d..adfeae68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs @@ -28,6 +28,7 @@ pub struct JumpHopDraftSnapshot { pub tile_assets: Vec, pub path: Option, pub cover_composite: Option, + pub back_button_asset: Option, pub generation_status: String, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs index c95dc8c9..3a8b9e68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs @@ -33,6 +33,7 @@ pub struct JumpHopWorkProfileRow { pub published_at: Option<__sdk::Timestamp>, pub visible: bool, pub theme_text: Option, + pub back_button_asset_json: Option, } impl __sdk::InModule for JumpHopWorkProfileRow { @@ -69,6 +70,7 @@ pub struct JumpHopWorkProfileRowCols { pub published_at: __sdk::__query_builder::Col>, pub visible: __sdk::__query_builder::Col, pub theme_text: __sdk::__query_builder::Col>, + pub back_button_asset_json: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow { @@ -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", + ), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs index d72083f8..969a4297 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs @@ -31,6 +31,7 @@ pub struct JumpHopWorkSnapshot { pub path: JumpHopPath, pub cover_image_src: String, pub cover_composite: Option, + pub back_button_asset: Option, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index 7eb34301..0ff204da 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -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 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(), } } diff --git a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs index 4806fbac..c5b66771 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs @@ -59,6 +59,9 @@ pub struct JumpHopWorkProfileRow { // 跳一跳生成主题独立于作品标题;旧行按 work_title 兜底。 #[default(None::)] pub(crate) theme_text: Option, + // 跳一跳左上角真实可点击返回按钮的独立透明资产快照;旧行为空时运行态使用样式兜底。 + #[default(None::)] + pub(crate) back_button_asset_json: Option, } #[spacetimedb::table( diff --git a/server-rs/crates/spacetime-module/src/jump_hop/types.rs b/server-rs/crates/spacetime-module/src/jump_hop/types.rs index 6edb7312..45441a3d 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/types.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/types.rs @@ -56,6 +56,7 @@ pub struct JumpHopDraftCompileInput { pub tile_atlas_asset_json: Option, pub tile_assets_json: Option, pub cover_composite: Option, + pub back_button_asset_json: Option, pub generation_status: Option, pub compiled_at_micros: i64, } @@ -248,6 +249,7 @@ pub struct JumpHopDraftSnapshot { pub tile_assets: Vec, pub path: Option, pub cover_composite: Option, + pub back_button_asset: Option, 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, + pub back_button_asset: Option, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index dc95fad8..dfd882fb 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -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" { diff --git a/src/components/jump-hop-result/JumpHopResultView.test.tsx b/src/components/jump-hop-result/JumpHopResultView.test.tsx index 07287372..f19ec837 100644 --- a/src/components/jump-hop-result/JumpHopResultView.test.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.test.tsx @@ -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, }; } diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index e882b62e..c6332727 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -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; + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + expect(stage.className).toContain('absolute'); + expect(stage.className).toContain('inset-0'); + expect(stage.className).toContain('h-full'); + expect(stage.className).toContain('w-full'); + expect(stage.className).not.toContain('rounded-[1.5rem]'); + + const backButton = screen.getByRole('button', { name: '返回' }); + expect(backButton.className).toContain('pointer-events-auto'); + expect(backButton.className).toContain('jump-hop-runtime__back-button'); + expect(backButton.className).toContain('h-14'); + expect(backButton.className).toContain('w-14'); + expect(backButton.className).toContain('sm:h-[3.875rem]'); + expect(backButton.className).toContain('sm:w-[3.875rem]'); + expect(backButton.getAttribute('data-has-asset')).toBe('true'); + expect(backButton.textContent).toBe(''); + expect( + screen + .getByTestId('jump-hop-runtime-back-button-asset') + .getAttribute('src'), + ).toBe(backButtonAsset.imageSrc); + + const header = backButton.closest('header'); + expect(header?.className).toContain('absolute'); + expect(header?.className).toContain('top-0'); + expect(header?.className).toContain('z-[130]'); + expect(header?.querySelector('.puzzle-runtime-header-card')).toBeTruthy(); + const titleCard = header?.querySelector('.puzzle-runtime-level-title-card'); + expect(titleCard).toBeTruthy(); + expect(titleCard?.className).toContain('jump-hop-runtime__score-title-card'); + expect(screen.getByTestId('jump-hop-runtime-level-logo')).toBeTruthy(); + expect(screen.getByText('得分')).toBeTruthy(); + expect(screen.queryByText('跳一跳')).toBeNull(); + + const scoreCard = screen.getByTestId('jump-hop-score-card'); + expect(scoreCard.className).toContain('puzzle-runtime-timer-card'); + expect(scoreCard.className).toContain('puzzle-runtime-timer'); + expect(scoreCard.className).toContain('jump-hop-runtime__score-value-card'); + expect(scoreCard.className).toContain('justify-center'); + expect(scoreCard.className).toContain('text-center'); +}); + test('跳一跳运行态失败后在弹窗中展示排行榜', () => { const runtimeRequestOptions = { runtimeGuestToken: 'runtime-guest-token', @@ -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, }; } diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 86dba72d..09f47e17 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -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,299 +1310,344 @@ export function JumpHopRuntimeShell({ }; return ( -
-
-
+
+
void finishCharge(event)} + onPointerCancel={cancelCharge} + > + +
+ -
+ {platformRenderItems.map((item) => { + const { width, height } = getJumpHopPlatformVisualSize( + item.platform, + 1, + ); + const style = { + left: `${item.screenX}%`, + top: `${item.screenY}%`, + width, + height, + '--jump-hop-platform-scale': item.scale, + zIndex: + item.advanceState === 'exiting' ? 12 + item.index : 20 + item.index, + } as CSSProperties; + const isCurrent = + item.advanceState !== 'exiting' && + item.index === stageRun?.currentPlatformIndex; + + return ( +
+
+ +
+ ); + })} + + {preloadTileAssets.length > 0 ? ( + + ) : null} + + {visualCharacterPosition && !isThreeCharacterLayerReady ? ( +
+
+ +
+ ) : null} +
+ + {isCharging && dragPointerPosition && characterPosition ? ( +
+ +
-
- {successfulJumpCount} +
+
+ + + 得分 + +
+
+ {successfulJumpCount} +
-
-
void finishCharge(event)} - onPointerCancel={cancelCharge} - > -
- - {error ? ( -
-
- - {error} - -
-
- ) : null} -
+ + ) : null}