feat(jump-hop): 优化跳一跳素材生成与背景底图 #55
@@ -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 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。
|
||||
|
||||
@@ -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 复用资产槽位
|
||||
|
||||
- 现象:跳一跳生成完成后,运行态看起来仍像在显示默认几何地块,或者地块图片在加载时频闪;结果页地块池也可能只看到少量重复素材。
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 失败、刷新回首页。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ pub trait get_jump_hop_leaderboard {
|
||||
input: JumpHopLeaderboardGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopLeaderboardProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopLeaderboardProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ impl get_jump_hop_leaderboard for super::RemoteProcedures {
|
||||
input: JumpHopLeaderboardGetInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopLeaderboardProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
&super::ProcedureEventContext,
|
||||
Result<JumpHopLeaderboardProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, JumpHopLeaderboardProcedureResult>(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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))]" />
|
||||
<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 absolute inset-0 h-full w-full touch-none select-none overflow-hidden"
|
||||
onPointerDown={beginCharge}
|
||||
onPointerMove={updateDragVector}
|
||||
onPointerUp={(event) => void finishCharge(event)}
|
||||
onPointerCancel={cancelCharge}
|
||||
>
|
||||
<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'}
|
||||
className="jump-hop-runtime__camera-layer"
|
||||
style={
|
||||
{
|
||||
'--jump-hop-camera-shift-x': `${platformAdvanceCameraOffsetX}%`,
|
||||
'--jump-hop-camera-shift-y': `${-platformAdvanceCameraOffsetY}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<JumpHopThreeScene
|
||||
characterPosition={visualCharacterPosition}
|
||||
chargeRatio={chargeRatio}
|
||||
isJumpAnimating={isJumpAnimating}
|
||||
platformCount={platformRenderItems.length}
|
||||
renderCharacter={false}
|
||||
onCharacterLayerReadyChange={setIsThreeCharacterLayerReady}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{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 (
|
||||
<div
|
||||
key={item.renderKey}
|
||||
className="jump-hop-runtime__platform"
|
||||
style={style}
|
||||
data-current={isCurrent ? 'true' : 'false'}
|
||||
data-advance-state={item.advanceState}
|
||||
data-platform-id={item.platform.platformId}
|
||||
data-platform-index={item.index}
|
||||
>
|
||||
<div className="jump-hop-runtime__platform-shadow" />
|
||||
<JumpHopTileImage
|
||||
asset={item.asset}
|
||||
platform={item.platform}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{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"
|
||||
data-charging={isCharging ? 'true' : 'false'}
|
||||
data-jump-animating={isJumpAnimating ? 'true' : 'false'}
|
||||
data-landing-recoil={
|
||||
isLandingRecoilAnimating ? 'true' : 'false'
|
||||
}
|
||||
data-miss={visualCharacterPosition.isMiss ? 'true' : 'false'}
|
||||
style={
|
||||
{
|
||||
left: `${visualCharacterPosition.screenX}%`,
|
||||
top: `${visualCharacterPosition.screenY}%`,
|
||||
'--jump-hop-charge': chargeRatio,
|
||||
'--jump-hop-character-stretch-transform':
|
||||
characterMotionStyle.stretchTransform,
|
||||
'--jump-hop-flight-from-x':
|
||||
characterMotionStyle.flightFromX,
|
||||
'--jump-hop-flight-from-y':
|
||||
characterMotionStyle.flightFromY,
|
||||
'--jump-hop-recoil-x': characterMotionStyle.recoilX,
|
||||
'--jump-hop-recoil-y': characterMotionStyle.recoilY,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__character-shadow" />
|
||||
<img
|
||||
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="jump-hop-runtime__character-image"
|
||||
data-testid="jump-hop-character-logo"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isCharging && dragPointerPosition && characterPosition ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="jump-hop-runtime__slingshot-guide"
|
||||
style={
|
||||
(() => {
|
||||
const anchorX =
|
||||
stageSize.width * (characterPosition.screenX / 100);
|
||||
const anchorY =
|
||||
stageSize.height * (characterPosition.screenY / 100);
|
||||
const deltaX = dragPointerPosition.x - anchorX;
|
||||
const deltaY = dragPointerPosition.y - anchorY;
|
||||
const distance = Math.max(48, Math.hypot(deltaX, deltaY));
|
||||
const angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
return {
|
||||
'--jump-hop-anchor-x': `${anchorX}px`,
|
||||
'--jump-hop-anchor-y': `${anchorY}px`,
|
||||
'--jump-hop-aim-x': `${dragPointerPosition.x}px`,
|
||||
'--jump-hop-aim-y': `${dragPointerPosition.y}px`,
|
||||
'--jump-hop-line-angle': `${angle}deg`,
|
||||
'--jump-hop-line-length': `${distance}px`,
|
||||
} as CSSProperties;
|
||||
})()
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__slingshot-line" />
|
||||
<div className="jump-hop-runtime__slingshot-anchor" />
|
||||
<div className="jump-hop-runtime__slingshot-aim" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{jumpFeedbackForDisplay ? (
|
||||
<div
|
||||
key={`${stageRun?.currentPlatformIndex}-${stageRun?.lastJump?.result}`}
|
||||
className="jump-hop-runtime__feedback"
|
||||
>
|
||||
{jumpFeedbackForDisplay}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!stageRun ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-white/35 text-sm font-black text-slate-600 backdrop-blur-sm">
|
||||
等待开局
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSettled ? (
|
||||
<div className="absolute inset-0 z-[120] grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
||||
<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">
|
||||
<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"
|
||||
onClick={onRestart}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--primary min-h-11 px-3 py-2 text-sm"
|
||||
>
|
||||
重开
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
className="platform-button platform-button--ghost min-h-11 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<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}
|
||||
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"
|
||||
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]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
{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="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 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>
|
||||
|
||||
<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">
|
||||
<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]"
|
||||
onPointerDown={beginCharge}
|
||||
onPointerMove={updateDragVector}
|
||||
onPointerUp={(event) => void finishCharge(event)}
|
||||
onPointerCancel={cancelCharge}
|
||||
>
|
||||
<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}
|
||||
{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>
|
||||
<div
|
||||
data-testid="jump-hop-camera-layer"
|
||||
data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'}
|
||||
className="jump-hop-runtime__camera-layer"
|
||||
style={
|
||||
{
|
||||
'--jump-hop-camera-shift-x': `${platformAdvanceCameraOffsetX}%`,
|
||||
'--jump-hop-camera-shift-y': `${-platformAdvanceCameraOffsetY}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<JumpHopThreeScene
|
||||
characterPosition={visualCharacterPosition}
|
||||
chargeRatio={chargeRatio}
|
||||
isJumpAnimating={isJumpAnimating}
|
||||
platformCount={platformRenderItems.length}
|
||||
renderCharacter={false}
|
||||
onCharacterLayerReadyChange={setIsThreeCharacterLayerReady}
|
||||
/>
|
||||
|
||||
{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 (
|
||||
<div
|
||||
key={item.renderKey}
|
||||
className="jump-hop-runtime__platform"
|
||||
style={style}
|
||||
data-current={isCurrent ? 'true' : 'false'}
|
||||
data-advance-state={item.advanceState}
|
||||
data-platform-id={item.platform.platformId}
|
||||
data-platform-index={item.index}
|
||||
>
|
||||
<div className="jump-hop-runtime__platform-shadow" />
|
||||
<JumpHopTileImage
|
||||
asset={item.asset}
|
||||
platform={item.platform}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{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"
|
||||
data-charging={isCharging ? 'true' : 'false'}
|
||||
data-jump-animating={isJumpAnimating ? 'true' : 'false'}
|
||||
data-landing-recoil={
|
||||
isLandingRecoilAnimating ? 'true' : 'false'
|
||||
}
|
||||
data-miss={visualCharacterPosition.isMiss ? 'true' : 'false'}
|
||||
style={
|
||||
{
|
||||
left: `${visualCharacterPosition.screenX}%`,
|
||||
top: `${visualCharacterPosition.screenY}%`,
|
||||
'--jump-hop-charge': chargeRatio,
|
||||
'--jump-hop-character-stretch-transform':
|
||||
characterMotionStyle.stretchTransform,
|
||||
'--jump-hop-flight-from-x':
|
||||
characterMotionStyle.flightFromX,
|
||||
'--jump-hop-flight-from-y':
|
||||
characterMotionStyle.flightFromY,
|
||||
'--jump-hop-recoil-x': characterMotionStyle.recoilX,
|
||||
'--jump-hop-recoil-y': characterMotionStyle.recoilY,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__character-shadow" />
|
||||
<img
|
||||
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="jump-hop-runtime__character-image"
|
||||
data-testid="jump-hop-character-logo"
|
||||
/>
|
||||
</div>
|
||||
) : 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"
|
||||
className="jump-hop-runtime__slingshot-guide"
|
||||
style={
|
||||
(() => {
|
||||
const anchorX =
|
||||
stageSize.width * (characterPosition.screenX / 100);
|
||||
const anchorY =
|
||||
stageSize.height * (characterPosition.screenY / 100);
|
||||
const deltaX = dragPointerPosition.x - anchorX;
|
||||
const deltaY = dragPointerPosition.y - anchorY;
|
||||
const distance = Math.max(48, Math.hypot(deltaX, deltaY));
|
||||
const angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
return {
|
||||
'--jump-hop-anchor-x': `${anchorX}px`,
|
||||
'--jump-hop-anchor-y': `${anchorY}px`,
|
||||
'--jump-hop-aim-x': `${dragPointerPosition.x}px`,
|
||||
'--jump-hop-aim-y': `${dragPointerPosition.y}px`,
|
||||
'--jump-hop-line-angle': `${angle}deg`,
|
||||
'--jump-hop-line-length': `${distance}px`,
|
||||
} as CSSProperties;
|
||||
})()
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__slingshot-line" />
|
||||
<div className="jump-hop-runtime__slingshot-anchor" />
|
||||
<div className="jump-hop-runtime__slingshot-aim" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{jumpFeedbackForDisplay ? (
|
||||
<div
|
||||
key={`${stageRun?.currentPlatformIndex}-${stageRun?.lastJump?.result}`}
|
||||
className="jump-hop-runtime__feedback"
|
||||
>
|
||||
{jumpFeedbackForDisplay}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!stageRun ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-white/35 text-sm font-black text-slate-600 backdrop-blur-sm">
|
||||
等待开局
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSettled ? (
|
||||
<div className="absolute inset-0 z-[120] grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
||||
<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">
|
||||
<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"
|
||||
onClick={onRestart}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--primary min-h-11 px-3 py-2 text-sm"
|
||||
>
|
||||
重开
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
className="platform-button platform-button--ghost min-h-11 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</main>
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
<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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user