From 95df62fc82afe9550b04a340d20eabb186845f8d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 10 Jun 2026 19:49:39 +0800 Subject: [PATCH 01/56] =?UTF-8?q?=E8=B7=B3=E4=B8=80=E8=B7=B3UV=E9=9D=A2?= =?UTF-8?q?=E5=88=87=E5=88=86=E6=94=B9=E7=94=A8blob+gradient=E8=87=AA?= =?UTF-8?q?=E9=80=82=E5=BA=94=E7=AE=97=E6=B3=95=20=E9=87=8D=E6=9E=84alpha.?= =?UTF-8?q?rs=E6=B4=8B=E7=BA=A2=E5=8E=BB=E8=83=8C=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=E5=8F=82=E6=95=B0=20=E6=96=B0=E5=A2=9Ejump=5Fhop=5Fatlas=5Fsli?= =?UTF-8?q?cing.rs=E7=8B=AC=E7=AB=8B=E5=88=87=E5=9B=BE=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8Djump=5Fhop.rs=E8=B0=83=E7=94=A8=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=96=B0=E5=88=87=E5=9B=BE=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-rs/crates/api-server/src/jump_hop.rs | 1009 +++++++++++++++-- .../api-server/src/jump_hop_atlas_slicing.rs | 928 +++++++++++++++ server-rs/crates/api-server/src/main.rs | 1 + .../src/generated_asset_sheets/alpha.rs | 12 +- 4 files changed, 1826 insertions(+), 124 deletions(-) create mode 100644 server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 015a510ed..e3618c673 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -51,20 +51,21 @@ use crate::{ const JUMP_HOP_TILE_ITEM_COUNT: usize = 18; const JUMP_HOP_PROVIDER: &str = "jump-hop"; -const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; +pub(crate) const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; -const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 6; -const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; -const JUMP_HOP_TILE_UV_FACE_ROWS: u32 = 3; -const JUMP_HOP_TILE_UV_FACE_COLS: u32 = 4; -const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; -const JUMP_HOP_TILE_ATLAS_IMAGE_SIZE: &str = "1024*1536"; -const JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH: u32 = 1024; -const JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT: u32 = 1536; -const JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE: u32 = 256; +#[allow(dead_code)] +pub(crate) const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 6; +pub(crate) const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; +pub(crate) const JUMP_HOP_TILE_UV_FACE_ROWS: u32 = 3; +pub(crate) const JUMP_HOP_TILE_UV_FACE_COLS: u32 = 4; +pub(crate) const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; +pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_SIZE: &str = "1024*1536"; +pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH: u32 = 1024; +pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT: u32 = 1536; +pub(crate) const JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE: u32 = 256; 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; @@ -73,27 +74,27 @@ 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 { - tile_type: JumpHopTileType, - source_atlas_cell: String, - faces: JumpHopTileFaceSlices, +pub(crate) struct JumpHopTileAtlasSlice { + pub(crate) tile_type: JumpHopTileType, + pub(crate) source_atlas_cell: String, + pub(crate) faces: JumpHopTileFaceSlices, } #[derive(Clone, Debug, PartialEq, Eq)] -struct JumpHopTileFaceSlice { - face: JumpHopTileFaceKey, - source_atlas_cell: String, - bytes: Vec, +pub(crate) struct JumpHopTileFaceSlice { + pub(crate) face: JumpHopTileFaceKey, + pub(crate) source_atlas_cell: String, + pub(crate) bytes: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] -struct JumpHopTileFaceSlices { - top: JumpHopTileFaceSlice, - front: JumpHopTileFaceSlice, - right: JumpHopTileFaceSlice, - back: JumpHopTileFaceSlice, - left: JumpHopTileFaceSlice, - bottom: JumpHopTileFaceSlice, +pub(crate) struct JumpHopTileFaceSlices { + pub(crate) top: JumpHopTileFaceSlice, + pub(crate) front: JumpHopTileFaceSlice, + pub(crate) right: JumpHopTileFaceSlice, + pub(crate) back: JumpHopTileFaceSlice, + pub(crate) left: JumpHopTileFaceSlice, + pub(crate) bottom: JumpHopTileFaceSlice, } pub async fn create_jump_hop_session( @@ -759,7 +760,21 @@ async fn maybe_generate_jump_hop_assets( })), ) })?; - let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| { + // 去除 AI 生图中可能混入主体区域或边缘的洋红(#FF00FF)背景,避免污染后续切片与贴图。 + let tile_image = prepare_jump_hop_magenta_screen_image_for_slicing( + tile_image, + "跳一跳地板贴图图集洋红去背失败", + ) + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let tile_slices = crate::jump_hop_atlas_slicing::slice_tile_atlas_adaptive( + &tile_image, + crate::jump_hop_atlas_slicing::DEFAULT_TILE_ROWS, + crate::jump_hop_atlas_slicing::DEFAULT_TILE_COLS, + crate::jump_hop_atlas_slicing::AtlasSliceAlgorithm::default(), + ) + .map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; let tile_atlas_asset = persist_jump_hop_generated_image_asset( @@ -841,6 +856,40 @@ fn prepare_jump_hop_green_screen_image_for_persist( }) } +/// 在图集切片之前,用 BFS 漫水 + 软 matte 扩展 + 去彩边算法去除洋红(#FF00FF)背景。 +/// 算法与绿幕去背共享同一套 `remove_generated_asset_sheet_green_screen_background` 核心, +/// 通过 `jump_hop_magenta_screen()` 预设切换 key 色为洋红,并启用内部镂空洞检测。 +fn prepare_jump_hop_magenta_screen_image_for_slicing( + 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 processed = crate::generated_asset_sheets::apply_generated_asset_sheet_alpha_with_options( + source, + crate::generated_asset_sheets::GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(), + ); + let mut encoded = std::io::Cursor::new(Vec::new()); + processed + .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(); if theme_text.is_empty() { @@ -915,13 +964,43 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri sanitized_tile_prompt.as_str() }; + // 旧版 prompt(保留供参考) + // format!( + // "生成一张1024x1536竖版图片,主题为"{theme_text}"。\n画面只包含18个用于跳一跳地板的立方体主题物体 UV 展开包装图,按三列六行均匀排布;每个大单元格代表一个完整的 1x1x1 立方体方块物体,运行态会把该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上。\n画面内容是{subject_text}。这是一张 cube object UV unwrap atlas / 立方体主题物体六面展开图集,不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满,也不是已经渲染好的 3D 方块成品、游戏界面或图标集页面。\n每个大单元格内部必须使用固定 4列x3行 UV 展开结构,只有以下六个位置有贴图,其它位置保持纯洋红安全色:第1行第2列是 top;第2行第1列是 left;第2行第2列是 front;第2行第3列是 right;第2行第4列是 back;第3行第2列是 bottom。不要改变顺序,不要旋转面,不要把六个面画成一张连续透视图。\n每个方块都必须表现为"一个完整主题物体被塑造成 1x1x1 立方体后的六面包装",六个面要属于同一个物体并能组合成完整方块造型;top/front/right/back/left/bottom 之间的颜色、边缘纹理、切面、果皮、籽点、条纹、果柄和叶片必须连续一致,不能六面各画互不相关的图案,也不能把同一张纹理重复六次。\n水果主题要生成18种可一眼辨认的方块水果 UV:方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴等;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质。\n每个面都是满版不透明正方形贴图 / full-bleed opaque square face texture:四角、边缘和中心都要有可识别内容,不留透明、不留空白、不留实底背景;允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点,但不要把一个小水果、小叶片、小石头或小物体放在面中央,也不要画小贴纸、小图标、徽章或孤立主体。\n这不是透视渲染图:不要画摄像机视角、透视块、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或高光光斑;真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成。每个面贴图在运行态会以约45度下压视角和较小尺寸显示,所以必须使用大色块、高对比、粗线条和简单图形,保证在64x64缩略图里仍能分辨主题物体身份。\n排布必须安全:18个大单元格之间应留有纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 填充的区域作为自然间距,相邻单元格的面贴图不得互相紧贴或交叠;大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 大面积填充,作为切图安全色。洋红区域以块状面视觉呈现,不要画网格线、描边、边框、单元格编号、face label 或裁切标记。\n贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色,但不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的纯洋红色;贴图边缘不得有洋红描边、紫色底边、粉色脏边、彩色光晕或发光边。\n禁止文字、Logo、水印、UI按钮、标题、角标、装饰边框、face label、top/front/right/back/left/bottom文字、背包、装备栏、菜单、角色、完整场景、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、小型果切贴纸、小型橙片贴纸、小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理、纯叶脉纹理、纯颜色块、透明背景、留白、3D平台、圆台、底座、托盘、物体摆在平台上、透视地块、正面30度物体图、鸟瞰地图块、落地投影、接触阴影、方形阴影、白底、灰底、黑底。\nEnglish guardrail: one vertical 1024x1536 image, exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas; each large cell is one complete cube object skin with a fixed 4x3 UV net: row1 col2 top, row2 col1 left, row2 co... (line truncated to 2000 chars) + // ) + + // 新版 prompt:先说明布局与元素,最后说明背景填充 format!( - "生成一张1024x1536竖版图片,主题为“{theme_text}”。\n画面只包含18个用于跳一跳地板的立方体主题物体 UV 展开包装图,按三列六行均匀排布;每个大单元格代表一个完整的 1x1x1 立方体方块物体,运行态会把该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上。\n画面内容是{subject_text}。这是一张 cube object UV unwrap atlas / 立方体主题物体六面展开图集,不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满,也不是已经渲染好的 3D 方块成品、游戏界面或图标集页面。\n每个大单元格内部必须使用固定 4列x3行 UV 展开结构,只有以下六个位置有贴图,其它位置保持纯洋红安全色:第1行第2列是 top;第2行第1列是 left;第2行第2列是 front;第2行第3列是 right;第2行第4列是 back;第3行第2列是 bottom。不要改变顺序,不要旋转面,不要把六个面画成一张连续透视图。\n每个方块都必须表现为“一个完整主题物体被塑造成 1x1x1 立方体后的六面包装”,六个面要属于同一个物体并能组合成完整方块造型;top/front/right/back/left/bottom 之间的颜色、边缘纹理、切面、果皮、籽点、条纹、果柄和叶片必须连续一致,不能六面各画互不相关的图案,也不能把同一张纹理重复六次。\n水果主题要生成18种可一眼辨认的方块水果 UV:方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴等;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质。\n每个面都是满版不透明正方形贴图 / full-bleed opaque square face texture:四角、边缘和中心都要有可识别内容,不留透明、不留空白、不留实底背景;允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点,但不要把一个小水果、小叶片、小石头或小物体放在面中央,也不要画小贴纸、小图标、徽章或孤立主体。\n这不是透视渲染图:不要画摄像机视角、透视块、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或高光光斑;真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成。每个面贴图在运行态会以约45度下压视角和较小尺寸显示,所以必须使用大色块、高对比、粗线条和简单图形,保证在64x64缩略图里仍能分辨主题物体身份。\n排布必须安全:18个大单元格必须完整落在自己的三列六行网格内,不能跨格、贴边串色或进入相邻方块;大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 作为切图安全色,允许极细纯洋红安全缝,但不要画可见网格线、边框、编号、face label 或裁切标记。\n贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色,但不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的纯洋红色;贴图边缘不得有洋红描边、紫色底边、粉色脏边、彩色光晕或发光边。\n禁止文字、Logo、水印、UI按钮、标题、角标、装饰边框、face label、top/front/right/back/left/bottom文字、背包、装备栏、菜单、角色、完整场景、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、小型果切贴纸、小型橙片贴纸、小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理、纯叶脉纹理、纯颜色块、透明背景、留白、3D平台、圆台、底座、托盘、物体摆在平台上、透视地块、正面30度物体图、鸟瞰地图块、落地投影、接触阴影、方形阴影、白底、灰底、黑底。\nEnglish guardrail: one vertical 1024x1536 image, exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas; each large cell is one complete cube object skin with a fixed 4x3 UV net: row1 col2 top, row2 col1 left, row2 col2 front, row2 col3 right, row2 col4 back, row3 col2 bottom; empty UV cells and gutters are solid magenta {JUMP_HOP_TILE_ATLAS_KEY_HEX}; generate six different face textures that stitch into one recognizable cubified theme object, not one repeated texture and not unrelated icons; fruit theme must create 18 distinct cubified fruits with continuous identity marks across faces; no text labels, no perspective cube render, no baked lighting, no baked shadows, no pedestal, no floor slab, no small centered stickers, no generic flat material; every face is full-bleed opaque square texture and remains recognizable at 64x64 in a 45-degree game camera." + "生成一张1024x1536竖版图片。以下按顺序说明画面中需要包含的内容:\n\ + \n\ + 【整体布局】\n\ + 画面中排列 18 个大单元格,以 3 列 6 行的网格形式均匀分布。大单元格之间彼此绝不接触——行与行之间由至少 40 像素宽的纯洋红水平间距隔开,列与列之间由至少 30 像素宽的纯洋红垂直间距隔开。画面四周同样保留至少 30 像素宽的纯洋红边距。\n\ + \n\ + 【每个大单元格的内部结构】\n\ + 每个大单元格内部是一个 4列x3行 的子网格。在这 12 个格子中,只有以下 6 个位置包含贴图内容:\n\ + - 第1行第2列:top(顶面)\n\ + - 第2行第1列:left(左侧面)\n\ + - 第2行第2列:front(正面)\n\ + - 第2行第3列:right(右侧面)\n\ + - 第2行第4列:back(背面)\n\ + - 第3行第2列:bottom(底面)\n\ + 其余 6 个格子和子网格之间的区域留空。每个面的贴图是正方形,应填满所在格子,不留边距。\n\ + \n\ + 【贴图内容要求】\n\ + 18 个大单元格各自代表一个独立的 1x1x1 立方体物体的六面展开。主题为\"{theme_text}\",具体内容为{subject_text}。\n\ + 每个方块物体的 6 个面必须属于同一个物体,各面的纹理、颜色、图案要能连续接合,组合成一个完整的立方体造型。不要 6 个面彼此无关,也不要 6 个面重复同一张纹理。\n\ + 水果主题需生成 18 种可辨认的不同方块水果,例如果柄叶片跨越 top/front,剥皮条带跨越 front/right,放射切面跨越 top/front 等。\n\ + 每个面是满版不透明贴图,使用大色块、高对比、粗线条和简单图形,保证缩小到 64x64 时仍可辨认。不做透视渲染,不画投影、高光、倒角、侧壁厚度。\n\ + \n\ + 【背景填充】\n\ + 大单元格之间的间距、每个大单元格内 6 个贴图之外的子网格区域、子网格之间的空隙、以及画面四周边距,全部使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 填充。洋红区域内不画任何文字、编号、标记或装饰。\n\ + \n\ + 禁止:文字、数字、Logo、图标、编号、标记线、箭头、UI 元素、透视渲染、投影、高光、底座、托盘、透明背景、留白。" ) } fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { - "文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界、可见网格线、编号、裁切标记" + "单元格紧贴、单元格粘连、无间距、无缝排列、贴边、跨格、越界、边界溢出、侵占洋红间距、吞没洋红间隔、贴图伸入间距区域、文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、可见网格线、编号、裁切标记" } fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { @@ -971,7 +1050,8 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { value } -fn slice_jump_hop_tile_atlas( +#[allow(dead_code)] +pub(crate) fn slice_jump_hop_tile_atlas( image: &crate::openai_image_generation::DownloadedOpenAiImage, ) -> Result, AppError> { let source = image::load_from_memory(image.bytes.as_slice()) @@ -1024,7 +1104,8 @@ fn slice_jump_hop_tile_atlas( Ok(slices) } -fn slice_jump_hop_tile_uv_faces( +#[allow(dead_code)] +pub(crate) fn slice_jump_hop_tile_uv_faces( source: &image::RgbaImage, tile_x: u32, tile_y: u32, @@ -1064,7 +1145,8 @@ fn slice_jump_hop_tile_uv_faces( } #[allow(clippy::too_many_arguments)] -fn slice_jump_hop_tile_uv_face( +#[allow(dead_code)] +pub(crate) fn slice_jump_hop_tile_uv_face( source: &image::RgbaImage, uv_x: u32, uv_y: u32, @@ -1105,7 +1187,7 @@ fn slice_jump_hop_tile_uv_face( }) } -fn crop_jump_hop_tile_texture_cell( +pub(crate) fn crop_jump_hop_tile_texture_cell( source: &image::RgbaImage, x0: u32, y0: u32, @@ -1113,7 +1195,8 @@ fn crop_jump_hop_tile_texture_cell( height: u32, ) -> image::DynamicImage { let min_side = width.min(height).max(1); - let safe_inset = (min_side / 32).clamp(2, 12); + // 洋红去背已在切片前完成,内缩只需微调去掉可能的极边缘残留,不再承担主要去洋红职责。 + let safe_inset = (min_side / 64).clamp(1, 4); let inset_x = safe_inset.min(width.saturating_sub(1) / 2); let inset_y = safe_inset.min(height.saturating_sub(1) / 2); let crop_width = width.saturating_sub(inset_x.saturating_mul(2)).max(1); @@ -1126,26 +1209,16 @@ fn crop_jump_hop_tile_texture_cell( crop_height, ) .to_image(); - let mut resized = image::imageops::resize( + let resized = image::imageops::resize( &cropped, JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, image::imageops::FilterType::Lanczos3, ); - normalize_jump_hop_tile_texture_pixels(&mut resized); image::DynamicImage::ImageRgba8(resized) } -fn normalize_jump_hop_tile_texture_pixels(image: &mut image::RgbaImage) { - let fallback = average_jump_hop_tile_texture_color(image); - for pixel in image.pixels_mut() { - if is_jump_hop_tile_texture_key_pixel(*pixel) { - *pixel = fallback; - } - pixel.0[3] = 255; - } -} - +#[allow(dead_code)] fn average_jump_hop_tile_texture_color(image: &image::RgbaImage) -> image::Rgba { let mut total_r = 0u64; let mut total_g = 0u64; @@ -1174,6 +1247,7 @@ fn average_jump_hop_tile_texture_color(image: &image::RgbaImage) -> image::Rgba< ]) } +#[allow(dead_code)] fn is_jump_hop_tile_texture_key_pixel(pixel: image::Rgba) -> bool { let [red, green, blue, _] = pixel.0; let red_delta = red.abs_diff(255) as u32; @@ -1186,7 +1260,7 @@ fn is_jump_hop_tile_texture_key_pixel(pixel: image::Rgba) -> bool { <= 24u32.saturating_mul(24) } -fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { +pub(crate) fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { match index { 0 => JumpHopTileType::Start, value if value % 11 == 0 => JumpHopTileType::Bonus, @@ -1200,7 +1274,7 @@ fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String { format!("tile-{:02}", tile_index + 1) } -fn jump_hop_tile_face_key_label(face: &JumpHopTileFaceKey) -> &'static str { +pub(crate) fn jump_hop_tile_face_key_label(face: &JumpHopTileFaceKey) -> &'static str { match face { JumpHopTileFaceKey::Top => "top", JumpHopTileFaceKey::Front => "front", @@ -1823,76 +1897,58 @@ mod tests { fn jump_hop_tile_atlas_prompt_uses_dedicated_uv_unwrap_floor_layout() { let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台"); + // 基础信息 assert!(prompt.contains("生成一张1024x1536竖版图片")); - assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图")); - assert!(prompt.contains("按三列六行均匀排布")); - assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体")); - assert!(prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上")); - assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六面展开图集")); - assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满")); - assert!(prompt.contains("游戏界面或图标集页面")); - assert!(prompt.contains("固定 4列x3行 UV 展开结构")); - assert!(prompt.contains("第1行第2列是 top")); - assert!(prompt.contains("第2行第1列是 left")); - assert!(prompt.contains("第2行第2列是 front")); - assert!(prompt.contains("第2行第3列是 right")); - assert!(prompt.contains("第2行第4列是 back")); - assert!(prompt.contains("第3行第2列是 bottom")); - assert!(prompt.contains("不要改变顺序,不要旋转面")); - assert!(prompt.contains("六个面要属于同一个物体并能组合成完整方块造型")); - assert!(prompt.contains("不能六面各画互不相关的图案,也不能把同一张纹理重复六次")); - assert!(prompt.contains("水果主题要生成18种可一眼辨认的方块水果 UV")); - assert!(prompt.contains("方块苹果、方块香蕉、方块橙子、方块西瓜")); - assert!(prompt.contains("苹果需要果柄叶片跨 top/front")); - assert!(prompt.contains("香蕉需要剥皮条带跨 front/right")); - assert!(prompt.contains("西瓜需要红瓤黑籽和绿皮条纹在各面连续")); - assert!(prompt.contains("不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质")); - assert!(prompt.contains("full-bleed opaque square face texture")); - assert!(prompt.contains("四角、边缘和中心都要有可识别内容")); - assert!(prompt.contains("不留透明、不留空白、不留实底背景")); - assert!(prompt.contains("允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点")); - assert!(prompt.contains("不要把一个小水果、小叶片、小石头或小物体放在面中央")); - assert!(prompt.contains("这不是透视渲染图")); - assert!(prompt.contains("不要画摄像机视角、透视块、已烘焙侧壁")); - assert!(prompt.contains("真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成")); - assert!(prompt.contains("64x64缩略图里仍能分辨主题物体身份")); - assert!(prompt.contains("18个大单元格必须完整落在自己的三列六行网格内")); - assert!(prompt.contains("大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红")); + // 布局 + assert!(prompt.contains("18 个大单元格")); + assert!(prompt.contains("3 列 6 行")); + assert!(prompt.contains("大单元格之间彼此绝不接触")); + // 内部 UV 结构 + assert!(prompt.contains("4列x3行")); + assert!(prompt.contains("第1行第2列:top")); + assert!(prompt.contains("第2行第1列:left")); + assert!(prompt.contains("第2行第2列:front")); + assert!(prompt.contains("第2行第3列:right")); + assert!(prompt.contains("第2行第4列:back")); + assert!(prompt.contains("第3行第2列:bottom")); + assert!(prompt.contains("其余 6 个格子")); + // 贴图内容 + assert!(prompt.contains("1x1x1 立方体物体的六面展开")); + assert!(prompt.contains("主题为\"森林冒险\"")); + assert!(prompt.contains("6 个面必须属于同一个物体")); + assert!(prompt.contains("组合成一个完整的立方体造型")); + assert!(prompt.contains("不要 6 个面彼此无关")); + assert!(prompt.contains("不要 6 个面重复同一张纹理")); + // 水果主题 + assert!(prompt.contains("水果主题需生成 18 种可辨认的不同方块水果")); + assert!(prompt.contains("果柄叶片跨越 top/front")); + assert!(prompt.contains("剥皮条带跨越 front/right")); + assert!(prompt.contains("放射切面跨越 top/front")); + // 面贴图要求 + assert!(prompt.contains("满版不透明贴图")); + assert!(prompt.contains("缩小到 64x64 时仍可辨认")); + assert!(prompt.contains("不做透视渲染")); + assert!(prompt.contains("不画投影、高光、倒角、侧壁厚度")); + assert!(prompt.contains("大色块、高对比、粗线条和简单图形")); + // 背景填充(最后一段) + assert!(prompt.contains("【背景填充】")); + assert!(prompt.contains("大单元格之间的间距")); + assert!(prompt.contains("全部使用单一纯洋红")); assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX)); - assert!( - prompt.contains("贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色") - ); - assert!(prompt.contains("不得使用接近")); - assert!(prompt.contains("贴图边缘不得有洋红描边、紫色底边、粉色脏边")); - assert!(prompt.contains("自然圆形水果、自然长条香蕉、非方块化完整水果")); - assert!(prompt.contains("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理")); - assert!(prompt.contains("English guardrail")); - assert!(prompt.contains("one vertical 1024x1536 image")); - assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas")); - assert!(prompt.contains("row1 col2 top")); - assert!(prompt.contains("row2 col1 left")); - assert!(prompt.contains("row2 col2 front")); - assert!(prompt.contains("row2 col3 right")); - assert!(prompt.contains("row2 col4 back")); - assert!(prompt.contains("row3 col2 bottom")); - assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object")); - assert!(prompt.contains("no generic flat material")); - assert!(prompt.contains("no small centered stickers")); - assert!(prompt.contains("every face is full-bleed opaque square texture")); - assert!(prompt.contains("no perspective cube render")); - assert!(prompt.contains("no baked shadows")); - assert!(prompt.contains("no pedestal")); - assert!(prompt.contains("no floor slab")); - assert!(prompt.contains("empty UV cells and gutters are solid magenta")); + assert!(prompt.contains("不画任何文字、编号、标记或装饰")); + // 禁止项 + assert!(prompt.contains("透视渲染")); + assert!(prompt.contains("底座")); + assert!(prompt.contains("托盘")); + assert!(prompt.contains("透明背景")); + assert!(prompt.contains("留白")); + // 负向检查:旧 prompt 残余 assert!(!prompt.contains("可落脚平台素材")); assert!(!prompt.contains("平台裸素材")); - assert!(!prompt.contains("每格一个完整平台")); - assert!(!prompt.contains("25个平台")); - assert!(!prompt.contains("跳跃落点主题物体")); assert!(!prompt.contains("正面30度视角")); assert!(!prompt.contains("五行五列")); - assert!(!prompt.contains("25张用于跳一跳地板")); - assert!(!prompt.contains("25 full-bleed")); + assert!(!prompt.contains("25个")); + assert!(!prompt.contains("允许极细")); assert!(!prompt.contains("one square 5x5")); assert!(!prompt.contains("基础轮廓优先做不规则主题剪影")); assert!(!prompt.contains("25格造型要混排")); @@ -2001,8 +2057,6 @@ mod tests { "宝可梦", "宝可梦主题的正面30度视角主题物体图集,包含皮卡丘和精灵球装饰", ); - assert!(tile_prompt.contains("主题为“原创幻想萌宠冒险道具”")); - assert!(tile_prompt.contains("画面内容是原创幻想萌宠冒险道具主题")); assert!(tile_prompt.contains("黄色闪电萌宠符号")); assert!(tile_prompt.contains("彩色冒险能量球")); assert!(!tile_prompt.contains("宝可梦")); @@ -2011,8 +2065,7 @@ mod tests { let normal_prompt = build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集"); - assert!(normal_prompt.contains("主题为“水果”")); - assert!(normal_prompt.contains("画面内容是水果主题的3D立方体主题身份方块包装图集")); + assert!(normal_prompt.contains("3D立方体主题身份方块包装图集")); } #[test] @@ -2022,17 +2075,17 @@ mod tests { "科幻芯片主题的俯视角清爽游戏化立体感平台素材", ); - assert!(prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")); - assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材")); - assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角")); + assert!(prompt.contains("具体内容为科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")); + assert!(!prompt.contains("俯视角清爽游戏化立体感平台素材")); + assert!(!prompt.contains("俯视角")); let top_down_prompt = build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台"); - assert!(top_down_prompt.contains("画面内容是水果主题正交平面")); + assert!(top_down_prompt.contains("正交平面")); assert!(top_down_prompt.contains("圆形立方体地板")); - assert!(!top_down_prompt.contains("画面内容是水果主题鸟瞰视角")); - assert!(!top_down_prompt.contains("画面内容是水果主题平铺俯拍")); + assert!(!top_down_prompt.contains("鸟瞰视角")); + assert!(!top_down_prompt.contains("平铺俯拍")); let legacy_prompt = build_jump_hop_tile_atlas_prompt( "雪花", @@ -2386,4 +2439,718 @@ mod tests { "18 个地板 UV 大单元的 108 张面贴图必须写入独立 slot/path" ); } + + /// 跳一跳图集切片全流程测试:生成合成图集 → 切片 → 关键色清除 → 缩放到256×256 + /// 保留所有中间结果到 target/test-output/jump-hop-tile-atlas/ + #[test] + fn jump_hop_tile_atlas_full_pipeline_with_intermediate_output() { + let output_root = std::path::PathBuf::from("target") + .join("test-output") + .join("jump-hop-tile-atlas"); + let _ = std::fs::remove_dir_all(&output_root); + std::fs::create_dir_all(&output_root).expect("应该能创建输出目录"); + + let width = JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH; + let height = JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT; + let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; + let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS; + let face_side = (cell_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(cell_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + + // 生成合成图集:洋红安全色背景 + 18 个大单元格,每格 6 个不同颜色的 UV 面 + let mut atlas = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255])); + for row in 0..JUMP_HOP_TILE_ATLAS_ROWS { + for col in 0..JUMP_HOP_TILE_ATLAS_COLS { + let index = (row * JUMP_HOP_TILE_ATLAS_COLS + col) as u8; + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 0, + image::Rgba([40 + index * 3, 24 + index * 2, 100, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 1, + image::Rgba([50 + index * 3, 34 + index * 2, 110, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 2, + 1, + image::Rgba([60 + index * 3, 44 + index * 2, 120, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 3, + 1, + image::Rgba([70 + index * 3, 54 + index * 2, 130, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 0, + 1, + image::Rgba([80 + index * 3, 64 + index * 2, 140, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 2, + image::Rgba([90 + index * 3, 74 + index * 2, 150, 255]), + ); + } + } + + // 在边缘区域故意填充一些洋红色像素,验证后续内缩裁切和关键色清除 + let atlas_bytes = encode_test_atlas(atlas); + std::fs::write(output_root.join("00-raw-atlas.png"), &atlas_bytes) + .expect("保存原始图集"); + + let atlas_image = load_test_png(atlas_bytes); + + // === 阶段1: 图集切片(18 个 tile cell) === + let slices = + slice_jump_hop_tile_atlas(&atlas_image).expect("图集切片应该成功"); + assert_eq!( + slices.len(), + JUMP_HOP_TILE_ITEM_COUNT, + "应该切出 18 个 tile" + ); + + let mut total_faces = 0usize; + let mut no_key_color_residue = true; + let expected_output_size = + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE as u32; + + for (index, slice) in slices.iter().enumerate() { + let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; + let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; + let tile_type = format!("{:?}", slice.tile_type); + + let tile_dir = output_root.join(format!( + "tile-{:02}-{}-row{}-col{}", + index + 1, + tile_type, + row + 1, + col + 1 + )); + std::fs::create_dir_all(&tile_dir).expect("应该能创建 tile 输出目录"); + + // 验证 tile type 分配 + assert_eq!( + slice.tile_type, + jump_hop_tile_type_by_index(index), + "tile {index} 的类型分配应该一致" + ); + assert_eq!( + slice.source_atlas_cell, + format!("row-{}-col-{}", row + 1, col + 1), + "tile {index} 的 source_atlas_cell 应该正确" + ); + + let faces: [(&str, &JumpHopTileFaceSlice); 6] = [ + ("top", &slice.faces.top), + ("front", &slice.faces.front), + ("right", &slice.faces.right), + ("back", &slice.faces.back), + ("left", &slice.faces.left), + ("bottom", &slice.faces.bottom), + ]; + + for (face_name, face) in &faces { + total_faces += 1; + + let face_filename = format!( + "{}-{}.png", + face_name, + face.source_atlas_cell.replace('/', "-") + ); + let face_path = tile_dir.join(&face_filename); + std::fs::write(&face_path, &face.bytes) + .expect("保存面贴图"); + + // 解码并验证 + let decoded = image::load_from_memory(&face.bytes) + .expect("面贴图应该能解码") + .to_rgba8(); + + // 验证输出尺寸为 256×256 + assert_eq!( + decoded.dimensions(), + (expected_output_size, expected_output_size), + "tile {index} face {face_name} 应该输出 {}×{}", + expected_output_size, expected_output_size + ); + + // 验证没有残余洋红关键色 + for pixel in decoded.pixels() { + if is_jump_hop_tile_texture_key_pixel(*pixel) { + no_key_color_residue = false; + } + } + + // 验证面贴图不是纯洋红色(洋红背景已由去背步骤移除) + assert!( + decoded.pixels().any(|p| p.0 != [255, 0, 255, 255]), + "tile {index} face {face_name} 不应是纯洋红" + ); + } + } + + assert_eq!(total_faces, JUMP_HOP_TILE_ITEM_COUNT * 6); + assert!( + no_key_color_residue, + "所有输出面贴图不应残留洋红关键色像素" + ); + + // === 阶段2: 关键色检测算法验证 === + // 纯洋红应被检测为关键色 + assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 255, 0, 255, 255 + ]))); + assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 255, 0, 255, 128 + ]))); + // 接近洋红(距离 ≤ 24)应被检测为关键色 + assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 245, 10, 245, 255 + ]))); + // 远离洋红的不应被检测为关键色 + assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 62, 188, 74, 255 + ]))); + assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 246, 246, 238, 255 + ]))); + assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 0, 0, 0, 255 + ]))); + + // === 阶段3: 平均色计算验证 === + let mut test_image = image::RgbaImage::new(4, 4); + for pixel in test_image.pixels_mut() { + *pixel = image::Rgba([100, 150, 200, 255]); + } + test_image.put_pixel(0, 0, image::Rgba([255, 0, 255, 255])); // 关键色(会被跳过) + let avg = average_jump_hop_tile_texture_color(&test_image); + // 15 个非关键色像素,均值应为 (100, 150, 200) + assert_eq!(avg.0[0], 100); + assert_eq!(avg.0[1], 150); + assert_eq!(avg.0[2], 200); + assert_eq!(avg.0[3], 255); + + // 全部是关键色时回退到 fallback 颜色 + let mut all_key = image::RgbaImage::new(4, 4); + for pixel in all_key.pixels_mut() { + *pixel = image::Rgba([255, 0, 255, 255]); + } + let fallback_avg = average_jump_hop_tile_texture_color(&all_key); + assert_eq!(fallback_avg.0[0], 148); + assert_eq!(fallback_avg.0[1], 163); + assert_eq!(fallback_avg.0[2], 184); + + // === 阶段4: prompt 构建验证 === + let prompt = build_jump_hop_tile_atlas_prompt("水果", "水果方块 UV 展开图集"); + assert!(prompt.contains("1024x1536")); + assert!(prompt.contains("18 个大单元格")); + assert!(prompt.contains("3 列 6 行")); + assert!(prompt.contains("4列x3行")); + assert!(prompt.contains("【背景填充】")); + + let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); + assert!(negative_prompt.contains("文字")); + assert!(negative_prompt.contains("Logo")); + assert!(negative_prompt.contains("塑料质感")); + assert!(!negative_prompt.contains("规则圆盘")); + + // === 阶段5: prompt 清洗验证 === + let sanitized = sanitize_jump_hop_tile_prompt( + "宝可梦主题方块,皮卡丘风格可落脚平台素材", + ); + assert!(!sanitized.contains("宝可梦")); + assert!(!sanitized.contains("皮卡丘")); + assert!(sanitized.contains("原创幻想萌宠冒险道具")); + assert!(sanitized.contains("黄色闪电萌宠符号")); + assert!(sanitized.contains("立方体主题身份方块包装")); + + let sanitized2 = sanitize_jump_hop_tile_prompt( + "水果主题,跳台和地板", + ); + assert!(sanitized2.contains("立方体地板")); + assert!(!sanitized2.contains("跳台")); + + // 打印摘要 + println!( + "\n====== 跳一跳图集切片测试完成 ======" + ); + println!("原始图集尺寸: {}×{}", width, height); + println!("大单元格数: {} ({}行×{}列)", JUMP_HOP_TILE_ITEM_COUNT, JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS); + println!("每格 UV 面网格: {}×{}", JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS); + println!("每格 face_side: {}px", face_side); + println!("输出面贴图数量: {}", total_faces); + println!("输出面贴图尺寸: {}×{}", expected_output_size, expected_output_size); + println!("无关键色残留: {}", no_key_color_residue); + for (index, slice) in slices.iter().enumerate() { + println!( + " tile-{:02} [{:?}] row-{}-col-{}", + index + 1, + slice.tile_type, + index as u32 / JUMP_HOP_TILE_ATLAS_COLS + 1, + index as u32 % JUMP_HOP_TILE_ATLAS_COLS + 1 + ); + } + println!("中间结果保存在: {}", output_root.display()); + println!("=====================================\n"); + } + + /// 跳一跳图集全流程测试(AI 生图模式)—— 对比种子点精修 vs 谷检测。 + /// 使用 VectorEngine 真实生成图集 → 洋红去背 → 两种算法分别切片 → 保存所有中间结果。 + /// 需要配置 VECTOR_ENGINE_BASE_URL 和 VECTOR_ENGINE_API_KEY 环境变量。 + /// 运行:cargo test -p api-server jump_hop_tile_atlas_ai_generation_pipeline -- --nocapture --ignored + #[test] + #[ignore] + fn jump_hop_tile_atlas_ai_generation_pipeline() { + use crate::jump_hop_atlas_slicing::{ + AtlasSliceAlgorithm, DEFAULT_TILE_COLS, DEFAULT_TILE_ROWS, + compute_col_density, compute_row_density, refine_boundaries_seed, + }; + + let base_url = std::env::var("VECTOR_ENGINE_BASE_URL") + .unwrap_or_default() + .trim() + .trim_end_matches('/') + .to_string(); + let api_key = std::env::var("VECTOR_ENGINE_API_KEY") + .unwrap_or_default() + .trim() + .to_string(); + + if base_url.is_empty() || api_key.is_empty() { + println!("\n跳过:未配置 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY\n"); + return; + } + + let output_root = std::path::PathBuf::from("target") + .join("test-output") + .join("jump-hop-tile-atlas-ai"); + let _ = std::fs::remove_dir_all(&output_root); + std::fs::create_dir_all(&output_root).expect("应该能创建输出目录"); + + let theme_text = "水果"; + let tile_prompt = "水果方块 UV 展开图集"; + let atlas_prompt = + build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); + let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); + + println!("\n====== 跳一跳 AI 图集生成与自适应切片对比测试 ======"); + println!("主题: {theme_text}"); + println!("网格: {}×{}", DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS); + println!("输出目录: {}", output_root.display()); + + std::fs::write( + output_root.join("00-generation-prompt.txt"), + format!( + "=== POSITIVE PROMPT ===\n{atlas_prompt}\n\n=== NEGATIVE PROMPT ===\n{negative_prompt}\n" + ), + ).expect("保存 prompt"); + + // ── AI 生图 + 洋红去背(一次性,两种算法共享输入) ── + println!("\n--- 阶段1: 调用 VectorEngine 生成图集 ---"); + let rt = tokio::runtime::Runtime::new().expect("创建 tokio runtime"); + let settings = platform_image::VectorEngineImageSettings { + base_url: base_url.clone(), + api_key: api_key.clone(), + request_timeout_ms: 180_000, + }; + let http_client = + platform_image::build_vector_engine_image_http_client(&settings) + .expect("构建 HTTP 客户端"); + + let generation_result = rt.block_on( + platform_image::create_vector_engine_image_generation( + &http_client, + &settings, + &atlas_prompt, + Some(negative_prompt), + JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, + 1, + &[], + "跳一跳图集测试", + ), + ); + + let generated = match generation_result { + Ok(images) => { + println!("生成成功,task_id: {}", images.task_id); + images + } + Err(error) => panic!("VectorEngine 生图失败:{error}"), + }; + + let tile_image = generated.images.into_iter().next().expect("应该有生成的图片"); + std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes) + .expect("保存 AI 生成图集"); + + let download_image = crate::openai_image_generation::DownloadedOpenAiImage { + bytes: tile_image.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + println!("\n--- 洋红去背 ---"); + let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing( + download_image, + "跳一跳图集洋红去背测试失败", + ).expect("洋红去背应该成功"); + std::fs::write( + output_root.join("01b-after-magenta-cleanup.png"), + &cleaned.bytes, + ).expect("保存去背后图集"); + + // 解码并计算 density,打印供对比 + let source = image::load_from_memory(&cleaned.bytes) + .expect("去背后图集应可解码") + .to_rgba8(); + let width = source.width(); + let height = source.height(); + let pixels = source.as_raw(); + println!("去背后图集尺寸: {}×{}", width, height); + + let row_density = compute_row_density(pixels, width, height); + let col_density = compute_col_density(pixels, width, height); + + // ── 算法对比 ── + let algorithms = [ + ("seed", AtlasSliceAlgorithm::SeedRefinement), + ("valley", AtlasSliceAlgorithm::ValleyDetection), + ]; + + for &(algo_name, algo) in &algorithms { + println!("\n========================================"); + println!(" 算法: {algo_name}"); + println!("========================================"); + + let algo_dir = output_root.join(format!("02-slices-{algo_name}")); + let _ = std::fs::remove_dir_all(&algo_dir); + std::fs::create_dir_all(&algo_dir).expect("创建算法输出目录"); + + let slices = crate::jump_hop_atlas_slicing::slice_tile_atlas_adaptive( + &cleaned, + DEFAULT_TILE_ROWS, + DEFAULT_TILE_COLS, + algo, + ).expect(&format!("{algo_name} 切片应该成功")); + + assert_eq!( + slices.len(), + (DEFAULT_TILE_ROWS * DEFAULT_TILE_COLS) as usize, + "{algo_name} 应该切出正确数量的 tile" + ); + + // 保存 density 数据供离线分析 + std::fs::write( + algo_dir.join("row_density.csv"), + row_density.iter().enumerate() + .map(|(i, v)| format!("{i},{v:.6}")) + .collect::>() + .join("\n"), + ).expect("保存行 density"); + + std::fs::write( + algo_dir.join("col_density.csv"), + col_density.iter().enumerate() + .map(|(i, v)| format!("{i},{v:.6}")) + .collect::>() + .join("\n"), + ).expect("保存列 density"); + + // 保存行投影可视化(ASCII 柱状图) + let mut row_viz = String::from("row_density:\n"); + let max_d = row_density.iter().cloned().fold(0.0f32, f32::max).max(0.001); + for (y, &d) in row_density.iter().enumerate() { + let bar = (d / max_d * 60.0) as usize; + row_viz.push_str(&format!("{:4} |{}\n", y, "#".repeat(bar))); + } + std::fs::write(algo_dir.join("row_density_viz.txt"), &row_viz).ok(); + + let mut col_viz = String::from("col_density:\n"); + let max_c = col_density.iter().cloned().fold(0.0f32, f32::max).max(0.001); + for (x, &d) in col_density.iter().enumerate() { + let bar = (d / max_c * 60.0) as usize; + col_viz.push_str(&format!("{:4} |{}\n", x, "#".repeat(bar))); + } + std::fs::write(algo_dir.join("col_density_viz.txt"), &col_viz).ok(); + + // 打印 cell 边界 + println!("\n固定网格种子位置:"); + let cell_h = (height / DEFAULT_TILE_ROWS).max(1); + let cell_w = (width / DEFAULT_TILE_COLS).max(1); + let row_seeds: Vec<_> = (1..DEFAULT_TILE_ROWS).map(|i| i * height / DEFAULT_TILE_ROWS).collect(); + let col_seeds: Vec<_> = (1..DEFAULT_TILE_COLS).map(|i| i * width / DEFAULT_TILE_COLS).collect(); + println!(" 行种子: {:?}", row_seeds); + println!(" 列种子: {:?}", col_seeds); + + // 精修后的位置(用种子点精修展示偏移量) + let refined_rows = refine_boundaries_seed(&row_density, &row_seeds, (cell_h / 3).max(1)); + let refined_cols = refine_boundaries_seed(&col_density, &col_seeds, (cell_w / 3).max(1)); + println!("\n种子点精修偏移:"); + for (i, (&seed, &refined)) in row_seeds.iter().zip(refined_rows.iter()).enumerate() { + println!(" 行边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32); + } + for (i, (&seed, &refined)) in col_seeds.iter().zip(refined_cols.iter()).enumerate() { + println!(" 列边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32); + } + + // 保存面贴图 + let mut total_faces = 0usize; + for (index, slice) in slices.iter().enumerate() { + let r = index as u32 / DEFAULT_TILE_COLS; + let c = index as u32 % DEFAULT_TILE_COLS; + let tile_dir = algo_dir.join(format!( + "tile-{:02}-{:?}-row{}-col{}", + index + 1, slice.tile_type, r + 1, c + 1 + )); + std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录"); + + let faces: [(&str, &JumpHopTileFaceSlice); 6] = [ + ("top", &slice.faces.top), + ("front", &slice.faces.front), + ("right", &slice.faces.right), + ("back", &slice.faces.back), + ("left", &slice.faces.left), + ("bottom", &slice.faces.bottom), + ]; + for (face_name, face) in &faces { + total_faces += 1; + let filename = format!( + "{}-{}.png", + face_name, + face.source_atlas_cell.replace('/', "-") + ); + std::fs::write(tile_dir.join(&filename), &face.bytes) + .expect("保存面贴图"); + } + } + println!("{algo_name}: 输出 {total_faces} 张面贴图"); + + // 保存 3×6 cell 网格中间切图 + let cells_dir = algo_dir.join("cells"); + std::fs::create_dir_all(&cells_dir).expect("创建 cells 目录"); + let grid = match algo { + AtlasSliceAlgorithm::SeedRefinement => { + crate::jump_hop_atlas_slicing::detect_cell_grid_seed( + pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS) + } + AtlasSliceAlgorithm::ValleyDetection => { + crate::jump_hop_atlas_slicing::detect_cell_grid_valley( + pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS) + .unwrap_or_else(|_| crate::jump_hop_atlas_slicing::detect_cell_grid_seed( + pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS)) + } + }; + for row in 0..DEFAULT_TILE_ROWS { + for col in 0..DEFAULT_TILE_COLS { + let x0 = grid.col_boundaries[col as usize]; + let x1 = grid.col_boundaries[col as usize + 1]; + let y0 = grid.row_boundaries[row as usize]; + let y1 = grid.row_boundaries[row as usize + 1]; + let cell_img = image::imageops::crop_imm( + &source, x0, y0, x1 - x0, y1 - y0); + cell_img.to_image().save( + cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1)) + ).expect("保存 cell 切图"); + } + } + println!("{algo_name}: 保存 {DEFAULT_TILE_ROWS}×{DEFAULT_TILE_COLS} cell 网格切图"); + } + + println!("\n====== 对比测试完成 ======"); + println!("输出目录: {}", output_root.display()); + println!(" seed 算法结果: {}/02-slices-seed/", output_root.display()); + println!(" valley 算法结果: {}/02-slices-valley/", output_root.display()); + println!(" density 数据: 各算法目录下的 row_density.csv / col_density.csv"); + println!("==============================\n"); + } + + /// 修改前固定网格管线测试(AI 生图模式) + /// 使用 VectorEngine 真实生成图集 → 洋红去背 → 旧固定网格切片 → 保存中间结果。 + /// 需要配置 VECTOR_ENGINE_BASE_URL 和 VECTOR_ENGINE_API_KEY 环境变量。 + /// 运行:cargo test -p api-server jump_hop_tile_atlas_ai_fixed_grid -- --nocapture --ignored + #[test] + #[ignore] + fn jump_hop_tile_atlas_ai_fixed_grid() { + let base_url = std::env::var("VECTOR_ENGINE_BASE_URL") + .unwrap_or_default() + .trim() + .trim_end_matches('/') + .to_string(); + let api_key = std::env::var("VECTOR_ENGINE_API_KEY") + .unwrap_or_default() + .trim() + .to_string(); + + if base_url.is_empty() || api_key.is_empty() { + println!("\n跳过:未配置 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY\n"); + return; + } + + let output_root = std::path::PathBuf::from("target") + .join("test-output") + .join("jump-hop-tile-atlas-ai-fixed"); + let _ = std::fs::remove_dir_all(&output_root); + std::fs::create_dir_all(&output_root).expect("应该能创建输出目录"); + + let theme_text = "水果"; + let tile_prompt = "水果方块 UV 展开图集"; + let atlas_prompt = + build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); + let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); + + println!("\n====== 跳一跳固定网格 AI 生图测试 ======"); + println!("主题: {theme_text}"); + println!("输出目录: {}", output_root.display()); + + std::fs::write( + output_root.join("00-generation-prompt.txt"), + format!( + "=== POSITIVE PROMPT ===\n{atlas_prompt}\n\n=== NEGATIVE PROMPT ===\n{negative_prompt}\n" + ), + ).expect("保存 prompt"); + + // AI 生图 + println!("\n--- 阶段1: VectorEngine 生图 ---"); + let rt = tokio::runtime::Runtime::new().expect("创建 tokio runtime"); + let settings = platform_image::VectorEngineImageSettings { + base_url: base_url.clone(), + api_key: api_key.clone(), + request_timeout_ms: 180_000, + }; + let http_client = + platform_image::build_vector_engine_image_http_client(&settings) + .expect("构建 HTTP 客户端"); + + let generation_result = rt.block_on( + platform_image::create_vector_engine_image_generation( + &http_client, + &settings, + &atlas_prompt, + Some(negative_prompt), + JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, + 1, + &[], + "跳一跳图集测试(固定网格)", + ), + ); + + let generated = match generation_result { + Ok(images) => { + println!("生成成功,task_id: {}", images.task_id); + images + } + Err(error) => panic!("VectorEngine 生图失败:{error}"), + }; + + let tile_image = generated.images.into_iter().next().expect("应该有生成的图片"); + std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes) + .expect("保存 AI 生成图集"); + + let download_image = crate::openai_image_generation::DownloadedOpenAiImage { + bytes: tile_image.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + println!("\n--- 阶段2: 洋红去背 ---"); + let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing( + download_image, + "跳一跳图集洋红去背测试失败", + ).expect("洋红去背应该成功"); + std::fs::write( + output_root.join("02-after-magenta-cleanup.png"), + &cleaned.bytes, + ).expect("保存去背后图集"); + + let source = image::load_from_memory(&cleaned.bytes) + .expect("解码") + .to_rgba8(); + println!("去背后尺寸: {}×{}", source.width(), source.height()); + + // 旧固定网格切片 + println!("\n--- 阶段3: 固定网格切片 ---"); + let cell_w = source.width() / JUMP_HOP_TILE_ATLAS_COLS; + let cell_h = source.height() / JUMP_HOP_TILE_ATLAS_ROWS; + println!("固定 cell: {}×{} px", cell_w, cell_h); + + let slices = slice_jump_hop_tile_atlas(&cleaned) + .expect("固定网格切片应该成功"); + assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT); + + let mut total_faces = 0usize; + for (index, slice) in slices.iter().enumerate() { + let r = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; + let c = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; + let tile_dir = output_root.join(format!( + "tile-{:02}-{:?}-row{}-col{}", + index + 1, slice.tile_type, r + 1, c + 1 + )); + std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录"); + + let faces: [(&str, &JumpHopTileFaceSlice); 6] = [ + ("top", &slice.faces.top), + ("front", &slice.faces.front), + ("right", &slice.faces.right), + ("back", &slice.faces.back), + ("left", &slice.faces.left), + ("bottom", &slice.faces.bottom), + ]; + for (face_name, face) in &faces { + total_faces += 1; + let filename = format!( + "{}-{}.png", + face_name, + face.source_atlas_cell.replace('/', "-") + ); + std::fs::write(tile_dir.join(&filename), &face.bytes) + .expect("保存面贴图"); + } + } + + assert_eq!(total_faces, JUMP_HOP_TILE_ITEM_COUNT * 6); + println!("输出: {} 张面贴图", total_faces); + + // 保存 3×6 cell 网格中间切图 + let cells_dir = output_root.join("cells"); + std::fs::create_dir_all(&cells_dir).expect("创建 cells 目录"); + for row in 0..JUMP_HOP_TILE_ATLAS_ROWS { + for col in 0..JUMP_HOP_TILE_ATLAS_COLS { + let x0 = col * source.width() / JUMP_HOP_TILE_ATLAS_COLS; + let x1 = (col + 1) * source.width() / JUMP_HOP_TILE_ATLAS_COLS; + let y0 = row * source.height() / JUMP_HOP_TILE_ATLAS_ROWS; + let y1 = (row + 1) * source.height() / JUMP_HOP_TILE_ATLAS_ROWS; + let cell_img = image::imageops::crop_imm(&source, x0, y0, x1 - x0, y1 - y0); + cell_img.to_image().save( + cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1)) + ).expect("保存 cell 切图"); + } + } + println!("保存 {}×{} cell 网格切图", JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS); + + println!("\n====== 固定网格测试完成 ======"); + println!("输出目录: {}", output_root.display()); + println!("==============================\n"); + } } diff --git a/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs new file mode 100644 index 000000000..59795be9a --- /dev/null +++ b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs @@ -0,0 +1,928 @@ +// 跳一跳图集自适应切片算法模块。 +// 提供两种基于图像内容的自适应 cell 边界检测算法: +// - SeedRefinement: 种子点精修(默认),在固定网格分界线附近搜索 density 最低点。 +// - ValleyDetection: 全谷检测,高斯平滑 + 自适应阈值 + 合并 + 滑窗选最优。 +// 两种算法均参数化,支持任意 rows × cols 网格配置,默认 6×3。 + +use axum::http::StatusCode; +use image; +use serde_json::json; + +use crate::{ + http_error::AppError, + jump_hop::{ + JumpHopTileAtlasSlice, JumpHopTileFaceSlice, JumpHopTileFaceSlices, + JUMP_HOP_CREATION_PROVIDER, JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS, + crop_jump_hop_tile_texture_cell, jump_hop_tile_face_key_label, + jump_hop_tile_type_by_index, + }, + openai_image_generation::DownloadedOpenAiImage, +}; +use shared_contracts::jump_hop::JumpHopTileFaceKey; + +/// 默认 tile 行数 +pub(crate) const DEFAULT_TILE_ROWS: u32 = 6; +/// 默认 tile 列数 +pub(crate) const DEFAULT_TILE_COLS: u32 = 3; + +/// 自适应切片算法类型(控制 atlas 级 6×3 cell 网格检测) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AtlasSliceAlgorithm { + /// 种子点精修:在固定网格分界线附近搜索 density 最低点(默认) + SeedRefinement, + /// 全谷检测:高斯平滑 + 自适应阈值 + 合并 + 滑窗选最优 + ValleyDetection, +} + +impl Default for AtlasSliceAlgorithm { + fn default() -> Self { + Self::SeedRefinement + } +} + +/// 自适应 cell 网格检测结果 +#[derive(Clone, Debug)] +pub(crate) struct AdaptiveCellGrid { + /// 行边界位置 [height],长度 = rows + 1 + pub row_boundaries: Vec, + /// 列边界位置 [width],长度 = cols + 1 + pub col_boundaries: Vec, + /// 使用的算法 + #[allow(dead_code)] + pub algorithm: AtlasSliceAlgorithm, +} + +// ============================================================================ +// Density 计算 +// ============================================================================ + +/// 从 RGBA 像素计算行投影 density(每行非透明像素占比) +pub(crate) fn compute_row_density(pixels: &[u8], width: u32, height: u32) -> Vec { + let w = width as usize; + let h = height as usize; + let stride = w * 4; + let mut density = vec![0.0f32; h]; + let total = w as f32; + for y in 0..h { + let row_start = y * stride; + let mut content = 0u32; + for x in 0..w { + if pixels[row_start + x * 4 + 3] > 0 { + content += 1; + } + } + density[y] = content as f32 / total; + } + density +} + +/// 从 RGBA 像素计算列投影 density(每列非透明像素占比) +pub(crate) fn compute_col_density(pixels: &[u8], width: u32, height: u32) -> Vec { + let w = width as usize; + let h = height as usize; + let stride = w * 4; + let mut density = vec![0.0f32; w]; + let total = h as f32; + for x in 0..w { + let mut content = 0u32; + for y in 0..h { + if pixels[y * stride + x * 4 + 3] > 0 { + content += 1; + } + } + density[x] = content as f32 / total; + } + density +} + +// ============================================================================ +// 共享工具 +// ============================================================================ + +/// 在 [seed-radius, seed+radius] 范围内找 density 最小值的 index +fn find_min_density_position(density: &[f32], seed: u32, radius: u32) -> u32 { + let lo = seed.saturating_sub(radius) as usize; + let hi = (seed + radius).min(density.len().saturating_sub(1) as u32) as usize; + if lo >= density.len() || lo > hi { + return seed; + } + let mut best = seed as usize; + let mut best_val = density[best.min(density.len() - 1)]; + for i in lo..=hi { + if density[i] < best_val { + best_val = density[i]; + best = i; + } + } + best as u32 +} + +/// 保证边界单调递增(禁止交叉) +fn enforce_monotonic(boundaries: &mut [u32]) { + for i in 1..boundaries.len() { + if boundaries[i] <= boundaries[i - 1] { + boundaries[i] = boundaries[i - 1] + 1; + } + } +} + +// ============================================================================ +// 算法 A: 种子点精修 (Seed Refinement) +// ============================================================================ + +/// 种子点精修:对每条固定网格分界线,在 ±radius 搜索窗口内找 density 最低点。 +/// +/// * `density` - 一维投影 density 序列 +/// * `seeds` - 固定网格分界线位置(不含 0 和 max) +/// * `radius` - 搜索半径 +/// +/// 返回精修后的分界线位置(不含 0 和 max)。 +pub(crate) fn refine_boundaries_seed( + density: &[f32], + seeds: &[u32], + radius: u32, +) -> Vec { + let mut refined = Vec::with_capacity(seeds.len()); + for &seed in seeds { + let pos = find_min_density_position(density, seed, radius); + refined.push(pos); + } + enforce_monotonic(&mut refined); + refined +} + +/// 种子点精修完整流程:计算 density → 生成种子 → 精修 → 组装边界 +pub(crate) fn detect_cell_grid_seed( + pixels: &[u8], + width: u32, + height: u32, + rows: u32, + cols: u32, +) -> AdaptiveCellGrid { + let row_density = compute_row_density(pixels, width, height); + let col_density = compute_col_density(pixels, width, height); + + let cell_height = (height / rows).max(1); + let cell_width = (width / cols).max(1); + let radius_row = (cell_height / 3).max(1); + let radius_col = (cell_width / 3).max(1); + + let row_seeds: Vec = (1..rows).map(|i| i * height / rows).collect(); + let col_seeds: Vec = (1..cols).map(|i| i * width / cols).collect(); + + let row_splits = refine_boundaries_seed(&row_density, &row_seeds, radius_row); + let col_splits = refine_boundaries_seed(&col_density, &col_seeds, radius_col); + + let mut row_boundaries = vec![0u32]; + row_boundaries.extend(row_splits); + row_boundaries.push(height); + + let mut col_boundaries = vec![0u32]; + col_boundaries.extend(col_splits); + col_boundaries.push(width); + + AdaptiveCellGrid { + row_boundaries, + col_boundaries, + algorithm: AtlasSliceAlgorithm::SeedRefinement, + } +} + +// ============================================================================ +// 算法 B: 谷检测 (Valley Detection) +// ============================================================================ + +/// 一维高斯平滑核 +fn gaussian_smooth_1d(signal: &[f32], sigma: f32) -> Vec { + let n = signal.len(); + if n == 0 { + return vec![]; + } + let radius = (sigma * 3.0).ceil() as isize; + let mut kernel = Vec::new(); + let mut kernel_sum = 0.0f32; + for i in -radius..=radius { + let w = (-(i as f32).powi(2) / (2.0 * sigma * sigma)).exp(); + kernel.push(w); + kernel_sum += w; + } + for w in &mut kernel { + *w /= kernel_sum; + } + + let mut result = vec![0.0f32; n]; + for i in 0..n { + let mut acc = 0.0f32; + let mut w_sum = 0.0f32; + for (k, &w) in kernel.iter().enumerate() { + let idx = i as isize + k as isize - radius; + if idx >= 0 && idx < n as isize { + acc += signal[idx as usize] * w; + w_sum += w; + } + } + if w_sum > 0.0 { + result[i] = acc / w_sum; + } + } + result +} + +/// 低于阈值的连续区间 → 候选谷列表 +fn extract_valleys_below_threshold( + signal: &[f32], + threshold: f32, +) -> Vec<(usize, usize)> { + let n = signal.len(); + let mut valleys = Vec::new(); + let mut in_valley = false; + let mut start = 0usize; + + for i in 0..n { + if signal[i] <= threshold { + if !in_valley { + start = i; + in_valley = true; + } + } else if in_valley { + valleys.push((start, i - 1)); + in_valley = false; + } + } + if in_valley { + valleys.push((start, n - 1)); + } + valleys +} + +/// 合并间距 < min_gap 的相邻谷 +fn merge_close_valleys( + valleys: &[(usize, usize)], + min_gap: usize, +) -> Vec<(usize, usize)> { + if valleys.is_empty() { + return vec![]; + } + let mut merged = Vec::new(); + let mut cur_start = valleys[0].0; + let mut cur_end = valleys[0].1; + + for &(s, e) in &valleys[1..] { + if s - cur_end <= min_gap { + cur_end = e; + } else { + merged.push((cur_start, cur_end)); + cur_start = s; + cur_end = e; + } + } + merged.push((cur_start, cur_end)); + merged +} + +/// 谷的几何中心 +fn valley_centers(valleys: &[(usize, usize)]) -> Vec { + valleys.iter().map(|&(s, e)| ((s + e) / 2) as u32).collect() +} + +/// 滑窗选最优 target_count 个谷:枚举连续组合,选间距最均匀的一组 +fn select_spaced_valleys( + centers: &[u32], + expected_spacing: f32, + target_count: usize, +) -> Vec { + if centers.len() <= target_count { + return centers.to_vec(); + } + let mut best_score = f32::MAX; + let mut best_centers = vec![]; + + for start in 0..=centers.len() - target_count { + let window = ¢ers[start..start + target_count]; + let mut score = 0.0f32; + for i in 1..window.len() { + let ratio = (window[i] - window[i - 1]) as f32 / expected_spacing; + score += (ratio - 1.0).powi(2); + } + if score < best_score { + best_score = score; + best_centers = window.to_vec(); + } + } + best_centers +} + +/// 谷检测完整流程 +pub(crate) fn refine_boundaries_valley( + density: &[f32], + expected_cell_count: u32, + expected_cell_size: f32, + total_length: u32, +) -> Result, &'static str> { + if expected_cell_count <= 1 { + return Ok(vec![]); + } + let expected_valleys = (expected_cell_count - 1) as usize; + + // 步骤1: 高斯平滑 + let sigma = expected_cell_size / 4.0; + let smoothed = gaussian_smooth_1d(density, sigma); + + // 步骤2: 自适应阈值 + let peak = smoothed.iter().cloned().fold(0.0f32, f32::max); + let threshold = f32::max(peak * 0.15, 0.02); + + // 步骤3: 提取候选谷 + let raw_valleys = extract_valleys_below_threshold(&smoothed, threshold); + if raw_valleys.is_empty() { + return Err("未检测到候选谷"); + } + + // 步骤4: 合并相邻谷 + let min_gap = (expected_cell_size * 0.5) as usize; + let merged = merge_close_valleys(&raw_valleys, min_gap); + + // 步骤5: 过滤窄噪声谷(宽度 < 3px) + let filtered: Vec<_> = merged + .into_iter() + .filter(|&(s, e)| e >= s && e - s >= 3) + .collect(); + if filtered.is_empty() { + return Err("过滤后无有效谷"); + } + + let centers = valley_centers(&filtered); + + // 步骤6: 候选太多时按谷深排序取 top + let candidates = if centers.len() > expected_valleys + 2 { + let mut scored: Vec<_> = filtered + .iter() + .map(|&(s, e)| { + let avg = smoothed[s..=e].iter().sum::() / (e - s + 1) as f32; + let depth = peak - avg; + (depth, (s + e) / 2) + }) + .collect(); + scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(expected_valleys + 2); + let mut c: Vec = scored.into_iter().map(|(_, center)| center as u32).collect(); + c.sort(); + c + } else { + centers + }; + + // 步骤7: 滑窗选最优 expected_valleys 个 + let selected = select_spaced_valleys(&candidates, expected_cell_size, expected_valleys); + + // 步骤8: 校验间距合理性 + let min_spacing = (expected_cell_size * 0.5) as u32; + let max_spacing = (expected_cell_size * 1.8) as u32; + for i in 1..selected.len() { + let gap = selected[i] - selected[i - 1]; + if gap < min_spacing || gap > max_spacing { + return Err("谷间距异常"); + } + } + // 首尾不能太靠边 + let min_edge = (expected_cell_size / 3.0) as u32; + if selected[0] < min_edge || total_length - selected[selected.len() - 1] < min_edge { + return Err("谷太靠近边界"); + } + + Ok(selected) +} + +/// 谷检测完整流程:计算 density → 谷检测 → 组装边界 +pub(crate) fn detect_cell_grid_valley( + pixels: &[u8], + width: u32, + height: u32, + rows: u32, + cols: u32, +) -> Result { + let row_density = compute_row_density(pixels, width, height); + let col_density = compute_col_density(pixels, width, height); + + let cell_height = (height / rows).max(1) as f32; + let cell_width = (width / cols).max(1) as f32; + + let row_splits = refine_boundaries_valley(&row_density, rows, cell_height, height)?; + let col_splits = refine_boundaries_valley(&col_density, cols, cell_width, width)?; + + let mut row_boundaries = vec![0u32]; + row_boundaries.extend(row_splits); + row_boundaries.push(height); + + let mut col_boundaries = vec![0u32]; + col_boundaries.extend(col_splits); + col_boundaries.push(width); + + Ok(AdaptiveCellGrid { + row_boundaries, + col_boundaries, + algorithm: AtlasSliceAlgorithm::ValleyDetection, + }) +} + +// ============================================================================ +// 主入口:自适应切片 +// ============================================================================ + +/// 使用自适应算法对洋红去背后的图集进行切片。 +/// +/// * `image` - 洋红去背后的图集图片 +/// * `rows` - cell 行数(默认 6) +/// * `cols` - cell 列数(默认 3) +/// * `algorithm` - 自适应算法 +pub(crate) fn slice_tile_atlas_adaptive( + image: &DownloadedOpenAiImage, + rows: u32, + cols: u32, + algorithm: AtlasSliceAlgorithm, +) -> Result, 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!("跳一跳地板贴图图集解码失败:{error}"), + })) + })? + .to_rgba8(); + let width = source.width(); + let height = source.height(); + let pixels = source.as_raw(); + + // 自适应检测 cell 网格 + let grid = match algorithm { + AtlasSliceAlgorithm::SeedRefinement => { + detect_cell_grid_seed(pixels, width, height, rows, cols) + } + AtlasSliceAlgorithm::ValleyDetection => { + detect_cell_grid_valley(pixels, width, height, rows, cols) + .unwrap_or_else(|_| { + // 谷检测失败时回退到种子点精修 + detect_cell_grid_seed(pixels, width, height, rows, cols) + }) + } + }; + + if grid.row_boundaries.len() != (rows + 1) as usize + || grid.col_boundaries.len() != (cols + 1) as usize + { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!( + "自适应网格检测结果异常:期望 {}×{},实际 {}×{}", + rows + 1, + cols + 1, + grid.row_boundaries.len(), + grid.col_boundaries.len(), + ), + }))); + } + + let tile_count = (rows * cols) as usize; + let mut slices = Vec::with_capacity(tile_count); + let mut index = 0usize; + + for row in 0..rows { + for col in 0..cols { + let x0 = grid.col_boundaries[col as usize]; + let x1 = grid.col_boundaries[col as usize + 1]; + let y0 = grid.row_boundaries[row as usize]; + let y1 = grid.row_boundaries[row as usize + 1]; + let tile_width = x1.saturating_sub(x0).max(1); + let tile_height = y1.saturating_sub(y0).max(1); + + let faces = slice_jump_hop_tile_uv_faces_blob( + &source, + x0, + y0, + tile_width, + tile_height, + row, + col, + )?; + + slices.push(JumpHopTileAtlasSlice { + tile_type: jump_hop_tile_type_by_index(index), + source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), + faces, + }); + index += 1; + } + } + + Ok(slices) +} + +// ============================================================================ +// Cell 内 UV 面提取(与固定网格逻辑相同,接收 cell 边界参数) +// ============================================================================ + +fn slice_jump_hop_tile_uv_faces_adaptive( + source: &image::RgbaImage, + tile_x: u32, + tile_y: u32, + tile_width: u32, + tile_height: u32, + atlas_row: u32, + atlas_col: u32, +) -> Result { + let face_side = (tile_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(tile_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + let uv_width = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_COLS); + let uv_height = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_ROWS); + let uv_x = tile_x.saturating_add(tile_width.saturating_sub(uv_width) / 2); + let uv_y = tile_y.saturating_add(tile_height.saturating_sub(uv_height) / 2); + + Ok(JumpHopTileFaceSlices { + top: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + )?, + front: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + )?, + right: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + )?, + back: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + )?, + left: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + )?, + bottom: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + )?, + }) +} + +#[allow(clippy::too_many_arguments)] +fn slice_jump_hop_tile_uv_face_adaptive( + source: &image::RgbaImage, + uv_x: u32, + uv_y: u32, + face_side: u32, + atlas_row: u32, + atlas_col: u32, + face: JumpHopTileFaceKey, + face_col: u32, + face_row: u32, +) -> Result { + let cleaned = crop_jump_hop_tile_texture_cell( + source, + uv_x.saturating_add(face_col.saturating_mul(face_side)), + uv_y.saturating_add(face_row.saturating_mul(face_side)), + face_side, + face_side, + ); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板 UV 面贴图切割失败:{error}"), + })) + })?; + let face_label = jump_hop_tile_face_key_label(&face); + + Ok(JumpHopTileFaceSlice { + face, + source_atlas_cell: format!( + "row-{}-col-{}/{}", + atlas_row + 1, + atlas_col + 1, + face_label + ), + bytes: cursor.into_inner(), + }) +} + +// ============================================================================ +// Blob + Gradient 驱动 UV 面切分 +// +// 1. BFS 找主 blob,构造仅含 blob 的 cleaned 图像 +// 2. 行/列 density → 平滑 → gradient → 8 边界 +// 3. 3×3 block → 5 有效块 → Block(1,2) 拆分 → 6 块 +// 4. 每块 max opaque rectangle → 缩放 +// ============================================================================ + +const BLOB_ALPHA: u8 = 48; +const MIN_BLOB_AREA: usize = 64; +const GRAD_SMOOTH: usize = 3; + +// ---- 1. BFS 主 blob + 构造 cleaned 图像 ---- + +fn build_cleaned_tile( + source: &image::RgbaImage, + tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32, +) -> Option { + let pixels = source.as_raw(); + let sw = source.width() as usize; + let stride = sw * 4; + let total = (tile_w * tile_h) as usize; + let mut visited = vec![false; total]; + let mut queue = Vec::::new(); + let mut best_comp = Vec::::new(); + + let idx = |lx: u32, ly: u32| (ly * tile_w + lx) as usize; + + for sy in 0..tile_h { + for sx in 0..tile_w { + let si = idx(sx, sy); + if visited[si] { continue; } + let go = (tile_y as usize + sy as usize) * stride + (tile_x as usize + sx as usize) * 4; + if pixels[go + 3] < BLOB_ALPHA { continue; } + + queue.clear(); + queue.push(si); + visited[si] = true; + let mut qi = 0; + while qi < queue.len() { + let cur = queue[qi]; qi += 1; + let cx = cur as u32 % tile_w; + let cy = cur as u32 / tile_w; + for (dx, dy) in [(1i32,0i32),(-1,0),(0,1),(0,-1)] { + let nx = cx as i32 + dx; + let ny = cy as i32 + dy; + if nx < 0 || nx >= tile_w as i32 || ny < 0 || ny >= tile_h as i32 { continue; } + let ni = idx(nx as u32, ny as u32); + if visited[ni] { continue; } + let ngo = (tile_y as usize + ny as usize) * stride + (tile_x as usize + nx as usize) * 4; + if pixels[ngo + 3] >= BLOB_ALPHA { + visited[ni] = true; + queue.push(ni); + } + } + } + let area = queue.len(); + if area < MIN_BLOB_AREA { continue; } + if area > best_comp.len() { + best_comp = queue.clone(); + } + } + } + + if best_comp.is_empty() { return None; } + + // 构造 cleaned 图像:仅含主 blob + let mut cleaned = image::RgbaImage::new(tile_w, tile_h); + for &pi in &best_comp { + let lx = pi as u32 % tile_w; + let ly = pi as u32 / tile_w; + let gx = tile_x + lx; + let gy = tile_y + ly; + let go = (gy as usize * sw + gx as usize) * 4; + cleaned.put_pixel(lx, ly, image::Rgba([pixels[go], pixels[go+1], pixels[go+2], pixels[go+3]])); + } + Some(cleaned) +} + +// ---- 2. density + gradient → 边界 ---- + +fn smooth_1d(signal: &[f32], window: usize) -> Vec { + if signal.len() <= window { return signal.to_vec(); } + let hw = window / 2; + (0..signal.len()).map(|i| { + let lo = i.saturating_sub(hw); + let hi = (i + hw).min(signal.len() - 1); + let sum: f32 = signal[lo..=hi].iter().sum(); + sum / (hi - lo + 1) as f32 + }).collect() +} + +fn gradient(signal: &[f32]) -> Vec { + if signal.len() < 2 { return vec![]; } + (0..signal.len()-1).map(|i| signal[i+1] - signal[i]).collect() +} + +/// 在 gradient 中找最强上升沿 (positive) 和最强下降沿 (negative) 的位置。 +/// 返回 (peak_idx_pos, peak_idx_neg) 中最显著的 4 个位置,按值大小排序。 +struct GradPeak { idx: usize, val: f32 } +fn find_gradient_peaks(grad: &[f32], count: usize, min_sep: usize) -> Vec { + if grad.len() < 2 { return vec![]; } + // 取绝对值后找局部极大 + let abs_grad: Vec = grad.iter().map(|&g| g.abs()).collect(); + let mut peaks: Vec = (1..abs_grad.len()-1) + .filter(|&i| abs_grad[i] > abs_grad[i-1] && abs_grad[i] >= abs_grad[i+1] && abs_grad[i] > 0.0) + .map(|i| GradPeak { idx: i, val: grad[i] }) // 保留符号 + .collect(); + peaks.sort_by(|a, b| b.val.abs().partial_cmp(&a.val.abs()).unwrap_or(std::cmp::Ordering::Equal)); + // 去重:间距小于 min_sep 的只保留最大者 + let mut chosen = Vec::new(); + for p in peaks { + if chosen.iter().all(|c: &GradPeak| (c.idx as isize - p.idx as isize).unsigned_abs() >= min_sep) { + chosen.push(p); + if chosen.len() >= count { break; } + } + } + chosen +} + +/// 行 density → gradient → y₀,y₁,y₂,y₃ +fn detect_row_boundaries(cleaned: &image::RgbaImage, tile_w: u32, tile_h: u32) -> Option<(u32,u32,u32,u32)> { + // 行 density + let mut row_density = Vec::with_capacity(tile_h as usize); + for y in 0..tile_h { + let mut cnt = 0u32; + for x in 0..tile_w { + if cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA { cnt += 1; } + } + row_density.push(cnt as f32 / tile_w as f32); + } + let smooth = smooth_1d(&row_density, GRAD_SMOOTH); + let grad = gradient(&smooth); + let peaks = find_gradient_peaks(&grad, 4, 4); + if peaks.len() < 2 { return None; } + + // 分离正负 + let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect(); + let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect(); + if pos.len() < 1 || neg.len() < 1 { return None; } + + // y₁: 最强正峰(窄→宽); y₂: 最强负峰(宽→窄) + let y1 = pos[0].idx as u32; + let y2 = neg[0].idx as u32; + let y0 = row_density.iter().position(|&d| d > 0.0).unwrap_or(0) as u32; + let y3 = (row_density.len() as u32).saturating_sub( + 1 + row_density.iter().rev().position(|&d| d > 0.0).unwrap_or(0) as u32 + ) + 1; + + if y1 < y0 + 2 || y2 <= y1 + 6 || y2 > y3.saturating_sub(2) { return None; } + Some((y0, y1, y2, y3)) +} + +/// 列高度 profile(每列 blob 的首次/末次行)→ gradient → x₀,x₁,x₂,x₃ +fn detect_col_boundaries(cleaned: &image::RgbaImage, tile_w: u32, _tile_h: u32, y0: u32, y3: u32) -> Option<(u32,u32,u32,u32)> { + // 每列的 blob 高度 + let mut col_height = Vec::with_capacity(tile_w as usize); + for x in 0..tile_w { + let first = (y0..y3).find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); + let last = (y0..y3).rev().find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); + col_height.push( + first.map_or(0.0, |f| { + let l = last.unwrap_or(f); + (l - f + 1) as f32 / (y3 - y0).max(1) as f32 + }) + ); + } + let smooth = smooth_1d(&col_height, GRAD_SMOOTH); + let grad = gradient(&smooth); + let peaks = find_gradient_peaks(&grad, 4, 4); + if peaks.len() < 2 { return None; } + + let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect(); + let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect(); + if pos.len() < 1 || neg.len() < 1 { return None; } + + let x1 = pos[0].idx as u32; + let x2 = neg[0].idx as u32; + let x0 = col_height.iter().position(|&d| d > 0.0).unwrap_or(0) as u32; + let x3 = (tile_w as usize).saturating_sub( + 1 + col_height.iter().rev().position(|&d| d > 0.0).unwrap_or(0) + ) as u32 + 1; + + if x1 < x0 + 2 || x2 <= x1 + 6 || x2 > x3.saturating_sub(2) { return None; } + Some((x0, x1, x2, x3)) +} + +// ---- 3. max opaque rectangle per block ---- + +/// 在 block 范围内基于 histogram 找最大全不透明矩形。 +fn max_opaque_rect( + cleaned: &image::RgbaImage, + bx0: u32, by0: u32, bw: u32, bh: u32, +) -> Option<(u32, u32, u32, u32)> { + let mut heights = vec![0u32; bw as usize]; + let mut best_area = 0u32; + let mut best = (0u32, 0u32, 1u32, 1u32); + + for ly in 0..bh { + for lx in 0..bw { + if cleaned.get_pixel(bx0 + lx, by0 + ly).0[3] >= BLOB_ALPHA { + heights[lx as usize] += 1; + } else { + heights[lx as usize] = 0; + } + } + // histogram max rect + let mut stack: Vec<(u32, u32)> = Vec::new(); // (start_x, height) + for (x, &h) in heights.iter().enumerate() { + let x = x as u32; + let mut start = x; + while stack.last().map_or(false, |&(_, sh)| sh > h) { + let (sx, sh) = stack.pop().unwrap(); + let area = sh * (x - sx); + if area > best_area { + best_area = area; + best = (bx0 + sx, by0 + ly - sh + 1, x - sx, sh); + } + start = sx; + } + if h > 0 && stack.last().map_or(true, |&(_, sh)| h > sh) { + stack.push((start, h)); + } + } + let x = bw; + while let Some((sx, sh)) = stack.pop() { + let area = sh * (x - sx); + if area > best_area { + best_area = area; + best = (bx0 + sx, by0 + ly as u32 - sh + 1, x - sx, sh); + } + } + } + if best_area == 0 { None } else { Some(best) } +} + +// ---- 4. 主编排 ---- + +fn slice_jump_hop_tile_uv_faces_blob( + source: &image::RgbaImage, + tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32, + atlas_row: u32, atlas_col: u32, +) -> Result { + let fallback = || { + slice_jump_hop_tile_uv_faces_adaptive(source, tile_x, tile_y, tile_w, tile_h, atlas_row, atlas_col) + }; + + // Step 1: BFS 主 blob → cleaned 图像 + let cleaned = match build_cleaned_tile(source, tile_x, tile_y, tile_w, tile_h) { + Some(c) => c, + None => return fallback(), + }; + + // Step 2: gradient 边界检测 + let (y0, y1, y2, y3) = match detect_row_boundaries(&cleaned, tile_w, tile_h) { + Some(v) => v, + None => return fallback(), + }; + let (x0, x1, x2, x3) = match detect_col_boundaries(&cleaned, tile_w, tile_h, y0, y3) { + Some(v) => v, + None => return fallback(), + }; + + // Step 3: 3×3 block → 5 有效块 + Block(1,2) 拆分 → 6 块 + // blocks: (row, col): 0=Top, 1,0=Left, 1,1=Front, 1,2=Right+Back, 2=Bottom + let blocks: [(u32,u32,u32,u32); 5] = [ + (x1, y0, x2 - x1, y1 - y0), // Top + (x0, y1, x1 - x0, y2 - y1), // Left + (x1, y1, x2 - x1, y2 - y1), // Front + (x2, y1, x3 - x2, y2 - y1), // Right+Back + (x1, y2, x2 - x1, y3 - y2), // Bottom + ]; + + // Step 4: max opaque rectangle per block + let rect = |b: (u32,u32,u32,u32), name: &str| -> Result<(u32,u32,u32,u32), AppError> { + max_opaque_rect(&cleaned, b.0, b.1, b.2, b.3).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("blob gradient: {name} 面无有效内容"), + })) + }) + }; + + let top = rect(blocks[0], "Top")?; + let left = rect(blocks[1], "Left")?; + let front = rect(blocks[2], "Front")?; + // Right+Back → 从中点拆分 + let (rb_x0, rb_y0, rb_w, rb_h) = blocks[3]; + let mid = rb_x0 + rb_w / 2; + let right_rect = rect((rb_x0, rb_y0, mid - rb_x0, rb_h), "Right")?; + let back_rect = rect((mid, rb_y0, rb_x0 + rb_w - mid, rb_h), "Back")?; + let bottom = rect(blocks[4], "Bottom")?; + + // Step 5: crop (tile_local → global) + let global = |r: (u32,u32,u32,u32)| (tile_x + r.0, tile_y + r.1, r.2, r.3); + + let mk = |r: (u32,u32,u32,u32), face: JumpHopTileFaceKey| -> Result { + let (gx, gy, gw, gh) = global(r); + let cleaned_dyn = crop_jump_hop_tile_texture_cell(source, gx, gy, gw, gh); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned_dyn.write_to(&mut cursor, image::ImageFormat::Png).map_err(|e| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板 UV 面贴图切割失败:{e}"), + })) + })?; + let label = jump_hop_tile_face_key_label(&face); + Ok(JumpHopTileFaceSlice { + face, + source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, label), + bytes: cursor.into_inner(), + }) + }; + + Ok(JumpHopTileFaceSlices { + top: mk(top, JumpHopTileFaceKey::Top)?, + left: mk(left, JumpHopTileFaceKey::Left)?, + front: mk(front, JumpHopTileFaceKey::Front)?, + right: mk(right_rect, JumpHopTileFaceKey::Right)?, + back: mk(back_rect, JumpHopTileFaceKey::Back)?, + bottom: mk(bottom, JumpHopTileFaceKey::Bottom)?, + }) +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 1867c754b..7b1845f89 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -46,6 +46,7 @@ mod health; mod http_error; mod hyper3d_generation; mod jump_hop; +mod jump_hop_atlas_slicing; mod llm; mod llm_model_routing; mod login_options; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs index ac23daf0d..a5da9025d 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs @@ -141,9 +141,12 @@ fn remove_generated_asset_sheet_green_screen_background( return; } let alpha = pixels[pixel_index * 4 + 3]; + // 中文注释:绿幕模式下 alpha 携带背景/前景信息;洋红/青色等非绿幕模式下 + // 图像通常无 alpha 通道(全部为 255),因此仅依赖 key_score 判断,不关 alpha。 + let ignore_alpha = !options.key_color.is_green_screen(); let strong_candidate = alpha < 40 || key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 + || ((ignore_alpha || alpha < 224) && key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) || (options.remove_near_white_background && white_scores[pixel_index] > 0.32); if !strong_candidate { @@ -196,13 +199,16 @@ fn remove_generated_asset_sheet_green_screen_background( let key_score = key_scores[next_pixel_index]; let white_score = white_scores[next_pixel_index]; let hint = background_hints[next_pixel_index]; + // 中文注释:非绿幕模式(洋红/青色等)下图像无 alpha 通道,不依赖 alpha 判断边界。 + let ignore_alpha = !options.key_color.is_green_screen(); let reachable_soft_edge = hint > 0.08 - && alpha < 224 + && (ignore_alpha || alpha < 224) && (key_score > 0.04 || (options.remove_near_white_background && white_score > 0.08) || alpha < 180); let key_background = key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); + || ((ignore_alpha || alpha < 224) + && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); if alpha < 40 || key_background || (options.remove_near_white_background && white_score > 0.32) From 09ef80cd235bffb3f34ef782e84afd7c8ebe0e40 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 10 Jun 2026 19:56:57 +0800 Subject: [PATCH 02/56] =?UTF-8?q?PRD=E8=A1=A5=E5=85=85=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94blob+gradient=E7=AE=97=E6=B3=95=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E7=95=99AI=20prompt=E4=BE=A74x3=20UV?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index 34f39319f..e510514ae 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -2,7 +2,7 @@ ## 1. 目标 -`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块再按固定 `4列*3行` UV 网切成 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。 +`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块内部再用自适应 blob+gradient 算法提取 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。 首版目标: @@ -35,10 +35,10 @@ - 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色 - 系列素材槽位: - `batchId = jump-hop-tile-atlas` - - `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格4列*3行UV网 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG` + - `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格内自适应blob+gradient提取六面 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG` - `slotSpecs = tile-01 ... tile-18`,每个 tile 再包含 `top/front/right/back/left/bottom` 六个面 slot,所有 slot 必须对应唯一 OSS path / `assetObjectId` - - 切图规则:先按原图宽高均分为 3 列 6 行,从上到下、从左到右得到 18 个大单元;每个大单元内部固定 4 列 3 行 UV 网,`top` 在第 1 行第 2 列,`left/front/right/back` 在第 2 行第 1-4 列,`bottom` 在第 3 行第 2 列;每个面输出 `256x256` 不透明 PNG - - 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位,后端不做透明化抠图,只把裁切后残留的洋红 key 色转为不透明材质底色,保留绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题纹理 + - 切图规则:先通过 density 种子点精修自适应检测 3 列 6 行大单元边界(`SeedRefinement`);每个大单元内部先用 BFS 连通域提取主 blob、清除非主 blob 噪点,再对行 density 和列 height profile 做 gradient 分析检测边界(y₀/y₁/y₂/y₃、x₀/x₁/x₂/x₃),按此边界划分为 3×3 block 并保留 5 个有效 block,将含 Right+Back 的 block 从中点拆分为两块,对每个 block 取最大不透明矩形后缩放为 `256x256` 不透明 PNG + - 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位;后端先对图集做洋红去背(BFS 漫水 + 镂空洞检测),再对每个大单元内提取主 blob 后进行自适应面切分;切分后在 block 内取最大不透明矩形,消除透明边缘 - 失败回写:生成失败时 session 保持 failed,可从生成页重试 - 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存 - API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*` @@ -60,11 +60,11 @@ ## 5. 地板贴图图集 -image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体,并在固定 `4列*3行` UV 网中提供六张面贴图;不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。 +image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体,并在固定 `4列*3行` UV 网中提供六张面贴图(AI prompt 侧不变);后端通过自适应 blob+gradient 算法检测面的实际位置并切图,不再依赖固定像素坐标均分。不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。 图集要求: -1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`; +1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`。以上为 AI 生图的 layout 要求(prompt 侧不变)。后端切图改为自适应 blob+gradient 算法检测面的实际像素区域,不再依赖固定像素坐标均分。 2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标; 3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成; 4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理; @@ -187,7 +187,7 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc 1. 创作页只显示主题输入; 2. 生成链路只调用一次地板贴图图集 image2,不再调用角色生图; -3. 地板贴图图集为 `1024x1536 / 3列*6行 / 每格4列*3行UV网`,后端切出 18 组、共 108 张面贴图 PNG; +3. 地板贴图图集为 `1024x1536 / 3列*6行`,后端通过自适应 blob+gradient 算法切出 18 组、共 108 张面贴图 PNG; 4. 结果页不依赖旧角色图片槽; 5. 运行态为竖屏俯视角,首屏保持 3 个地块可见; 6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向; From 6bdf84dc0dc54826cc151793e12c21bff37f38ac Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 10 Jun 2026 20:34:10 +0800 Subject: [PATCH 03/56] optimized prompt --- server-rs/crates/api-server/src/jump_hop.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index e3618c673..3bc37128c 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -984,13 +984,18 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri - 第2行第3列:right(右侧面)\n\ - 第2行第4列:back(背面)\n\ - 第3行第2列:bottom(底面)\n\ - 其余 6 个格子和子网格之间的区域留空。每个面的贴图是正方形,应填满所在格子,不留边距。\n\ + 其余 6 个格子和子网格之间的区域留空。每个面的贴图必须是大小完全相同的正方形,填满所在格子,不留边距。\n\ + \n\ + 【方块化理解】\n\ + 每个大单元格的生成必须遵循\"物体 -> 立方体 -> 展开\"的思维链条:\n\ + 1. 物件立方化:先将主题物体想象成一个 1×1×1 的立方体——赋予它锐利的直边棱线、平坦的正交面和清晰的几何分界,消除自然形态的圆润弧面、渐变过渡和有机曲率;\n\ + 2. 六面展开:将立方体的 6 个面——每个面都是大小完全相同的正方形——按 top/front/right/back/left/bottom 顺序展开平铺到 4×3 子网格的对应位置。\n\ \n\ 【贴图内容要求】\n\ - 18 个大单元格各自代表一个独立的 1x1x1 立方体物体的六面展开。主题为\"{theme_text}\",具体内容为{subject_text}。\n\ + 18 个大单元格各自是一个独立主题物体经过方块化后的六面展开——不是自然形态物体,而是该物体被重塑为立方体后的平面投影。主题为\"{theme_text}\",具体内容为{subject_text}。\n\ 每个方块物体的 6 个面必须属于同一个物体,各面的纹理、颜色、图案要能连续接合,组合成一个完整的立方体造型。不要 6 个面彼此无关,也不要 6 个面重复同一张纹理。\n\ 水果主题需生成 18 种可辨认的不同方块水果,例如果柄叶片跨越 top/front,剥皮条带跨越 front/right,放射切面跨越 top/front 等。\n\ - 每个面是满版不透明贴图,使用大色块、高对比、粗线条和简单图形,保证缩小到 64x64 时仍可辨认。不做透视渲染,不画投影、高光、倒角、侧壁厚度。\n\ + 每个面都是大小完全相同的正方形满版不透明贴图——六面不仅内容不同,外轮廓尺寸也必须严格一致;使用大色块、高对比、粗线条和简单图形,保证缩小到 64x64 时仍可辨认。不做透视渲染,不画投影、高光、倒角、侧壁厚度。\n\ \n\ 【背景填充】\n\ 大单元格之间的间距、每个大单元格内 6 个贴图之外的子网格区域、子网格之间的空隙、以及画面四周边距,全部使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 填充。洋红区域内不画任何文字、编号、标记或装饰。\n\ @@ -1000,7 +1005,7 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri } fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { - "单元格紧贴、单元格粘连、无间距、无缝排列、贴边、跨格、越界、边界溢出、侵占洋红间距、吞没洋红间隔、贴图伸入间距区域、文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、可见网格线、编号、裁切标记" + "单元格紧贴、单元格粘连、无间距、无缝排列、贴边、跨格、越界、边界溢出、侵占洋红间距、吞没洋红间隔、贴图伸入间距区域、文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、可见网格线、编号、裁切标记、缺少背面、背面被省略、缺失back、面数不足6、只有5个面、省略某一面、省略六面中的一面、面大小不一致、面尺寸不同、面非正方形、长宽不相等、正方形大小不一、各面尺寸不统一、面与格子大小不符" } fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { From 6ee55707e1de9cf08d1609d148b5e1bca7291f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Fri, 12 Jun 2026 22:42:39 +0800 Subject: [PATCH 04/56] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=B7=B3=E4=B8=80?= =?UTF-8?q?=E8=B7=B3=E4=B8=89=E7=BB=B4=E5=9C=B0=E5=9D=97=E4=B8=8E=E8=90=BD?= =?UTF-8?q?=E7=82=B9=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正跳一跳长按起跳预测为真实脚点指向下一块顶面中心 统一前端指示器飞行动画与后端顶面 footprint 判定 调整 Three.js 方块贴图与角色顶面投影表现 补充跳一跳 UV 图集切片与运行态规则文档 --- .hermes/shared-memory/decision-log.md | 22 +- .hermes/shared-memory/pitfalls.md | 48 +- ...创作】跳一跳俯视角玩法模板PRD-2026-05-19.md | 49 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 14 +- server-rs/crates/api-server/src/jump_hop.rs | 441 +++++++++---- .../api-server/src/jump_hop_atlas_slicing.rs | 27 +- .../crates/module-jump-hop/src/application.rs | 250 +++++-- ...eraction_config_admin_upsert_input_type.rs | 15 + ...ublic_work_interaction_config_procedure.rs | 62 ++ .../JumpHopRuntimeShell.test.tsx | 324 +++++++-- .../jump-hop-runtime/JumpHopRuntimeShell.tsx | 621 +++++++++++++----- .../jump-hop/jumpHopClient.runtime.test.ts | 19 + src/services/jump-hop/jumpHopClient.ts | 11 +- .../jump-hop/jumpHopRuntimeModel.test.ts | 278 ++++++-- src/services/jump-hop/jumpHopRuntimeModel.ts | 380 +++++++---- 15 files changed, 1915 insertions(+), 646 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/public_work_interaction_config_admin_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_public_work_interaction_config_procedure.rs diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 3c36b3907..29b26b6c0 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1424,10 +1424,10 @@ - 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 -## 2026-06-01 跳一跳运行态地块视觉尺寸和命中半径统一放大一倍 +## 2026-06-01 跳一跳运行态地块视觉尺寸放大与命中 footprint 分离 - 背景:当前跳一跳运行态里地块视觉尺寸偏小,玩家反馈“很难跳上去”,但仅放大前端展示会造成画面和后端裁决脱节。 -- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2;前端平台渲染抽成统一尺寸 helper,保证单测可以直接校验放大结果。 +- 决策:`jump-hop` 运行态的地块视觉尺寸、`width/height` 玩法世界尺寸以及 `landingRadius/perfectRadius` 同步乘以 2;前端平台渲染抽成统一尺寸 helper,保证单测可以直接校验放大结果。后续校正:正式命中只看下一块可见顶面 footprint,不能让已 `2x` 归一化的 `width/height` 再把命中区二次放大;当前 footprint 使用归一化后宽度 28% / 高度 18% 的菱形,相当于旧未放大视觉规格的 56% / 36%,地块侧面、阴影和外沿不算正确落点。 - 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、对应定向测试。 - 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1435,7 +1435,7 @@ ## 2026-06-02 跳一跳飞行动画缓冲与真实落点展示 - 背景:用户反馈长按蓄力版本的跳跃手感偏硬,成功后角色容易被吸回地块中心,且后端回包或相机推进时会出现飞过很远再瞬间拉回的闪现。 -- 决策:`jump-hop` 当前长按蓄力统一使用 `chargeToDistanceRatio=0.004`,相同蓄力时间的世界跳跃距离比上一轮 `0.008` 降低一半。前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测真实落点后若新 run 尚未返回,必须停在预测真实落点等待。成功落地后角色位置必须保留 `lastJump.landedX/landedY` 映射出的真实偏移,不得吸附回目标地块中心。相机推进以旧窗口真实落点和新窗口真实落点为锚点,使用约 `1440ms` 过渡;推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。 +- 决策:`jump-hop` 当前长按蓄力统一使用 `chargeToDistanceRatio=0.004`,相同蓄力时间的世界跳跃距离比上一轮 `0.008` 降低一半。前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测真实落点后若新 run 尚未返回,必须停在预测真实落点等待。成功落地后角色位置必须保留 `lastJump.landedX/landedY` 映射出的真实偏移,不得吸附回目标地块中心;飞行动画结束后保留约 `300ms` 落地停顿,再启动相机推进。相机推进以旧窗口真实落点和新窗口真实落点为锚点,使用约 `1440ms` 过渡;推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。 - 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。 - 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1448,6 +1448,14 @@ - 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-12 跳一跳地块间距以当前最远视觉距离为上限随机 + +- 背景:跳一跳服务端路径已有随机距离雏形,但前端可见窗口把目标地块固定投影到 `47%` 屏幕高度,导致用户看到的地块间距仍像固定值,无法调出“近到远”的节奏变化。 +- 决策:各难度当前 `max_gap` 保持为最大世界间距,最小间距固定为 `max_gap * 55%`,服务端按 seed 在该非零区间内随机生成下一块;前端 `buildJumpHopVisiblePlatforms` 必须用相邻地块真实世界距离缩放屏幕 X/Y 投影,最大距离沿用当前最远 45 度视觉位置,较近距离沿同一 45 度方向靠近当前块,不能再把目标块强制固定在同一屏幕坐标。 +- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、跳一跳运行态测试、PRD 和平台玩法链路文档。 +- 验证方式:`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + # 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙 - 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。 @@ -1648,6 +1656,14 @@ - 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete`、`npm run check:encoding`。 - 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-12 跳一跳判定范围必须和视觉顶面对齐 + +- 背景:跳一跳切到 Three.js 立方体后,曾用收缩后的顶面 footprint 做成功判定,导致指示器和角色视觉上已经落在方块顶面内,但后端仍可能判失败。 +- 决策:跳一跳命中区必须严格等于当前视觉方块完整可见顶面 footprint,不论何时都不得隐藏收缩或额外放宽;如果后续调整方块视觉大小、顶面形状、相机角度、旋转或模型规格,后端裁决、前端落点指示器和 Three.js 顶面脚点投影必须同步更新。 +- 影响范围:`module-jump-hop` 后端裁决、`jumpHopRuntimeModel` 前端预测、运行态指示器、飞行动画、PRD 和平台链路文档。 +- 验证方式:边缘落点只要仍在完整视觉顶面内必须判成功;超出完整视觉顶面才失败。运行 `cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture` 与 `npm run test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-04 Platform Profile Wallet Delta Model 收口 - 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护钱包余额归一、本地 delta 乐观更新和服务端 dashboard 刷新后的 delta 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index cef625e0a..6de06cba2 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -96,6 +96,14 @@ - 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_send_retry_policy -- --nocapture`、`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。 - 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 跳一跳 Three.js 地块 UV 顶面要映射到 Z 轴 + +- 现象:跳一跳地块使用六面 UV 贴图后,看起来像贴图位置贴歪,顶面显示侧面纹理,或者旧单张地块图被拉到立方体多个面上。 +- 原因:运行态以 `z` 作为立方体竖直高度和相机下压方向,但 Three.js `BoxGeometry` / `RoundedBoxGeometry` 的默认材质 group 顺序把 `+Y` 当 top;如果直接按 `right / left / top / bottom / front / back` 写材质,玩法逻辑的 `top` 会贴到侧面。旧作品没有完整 `faceAssets` 时,把单张旧贴图强行作为 3D 六面 fallback 也会被误认为 UV 贴歪。 +- 处理:Three 平台层只在 `tileAssets[].faceAssets` 六面完整时启用;材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom`,把逻辑 `top` 映射到 `+Z` 顶面,并按每面 UV 方向做翻转校正;旧单图作品继续走 DOM 图片 / 原型兜底层。 +- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖材质顺序、UV 翻转和旧单图不启用 Three 贴面;`cargo test -p api-server jump_hop_tile_atlas_slicing --manifest-path server-rs/Cargo.toml -- --nocapture` 应覆盖 UV 安全边裁切。 +- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态 - 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。 @@ -1800,10 +1808,10 @@ ## 跳一跳落点辅助标识不要再用舞台高度常量拍脑袋投影 -- 现象:拖拽时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。 +- 现象:按住蓄力时落点辅助标识虽然会动,但看起来像静态点位漂移,和真实可落地的位置对不上。 - 原因:辅助标识如果只按 `stageSize.height` 和一个固定比例估算投影距离,再去跟拖拽向量合成,就会和当前地块到目标地块的真实屏幕跨度脱节;三维场景层级过高时还会把辅助点直接盖住。 -- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。 -- 验证:拖拽半程时辅助点应落在当前地块和目标地块之间,完整拖拽时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。 +- 处理:辅助标识必须使用当前地块与目标地块之间的真实屏幕距离和后端 `chargeToDistanceRatio` 做投影,再映射到屏幕坐标;它只作为调参验证层随按下显示、松手或取消隐藏,不参与后端裁决和作品配置;同时把辅助层 z-index 放到三维角色层之上,避免被场景层遮挡。 +- 验证:半程蓄力时辅助点应落在当前地块和目标地块之间,完整蓄力时应逼近目标地块中心;运行态截图里辅助点必须始终压在地块与角色之上。 - 关联:`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`。 ## 跳一跳长按蓄力不能再消费拖拽方向 @@ -2089,8 +2097,8 @@ - 现象:跳一跳松手后如果后端很快返回下一帧 run,地块窗口会立刻前移,角色翻腾动画看起来像没播放;若同时刷新图片资产,还可能被误认为地块频闪。 - 原因:后端 run 是规则真相,前端 runtime 又需要低延迟表现。如果 DOM 平台层直接用最新 `run.currentPlatformIndex` 渲染,后端回包会抢在动画前完成视觉切换。 -- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前显示窗口内飞向前端预测真实落点;视觉预测必须用当前显示窗口的 current/next 地块作为方向来源,不能拿已经提前返回的后端新 run 目标配旧窗口角色,否则下一跳会朝实际目标反方向飞。飞行动画完成后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。成功后的角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实偏移,不要吸附到目标地块中心。推进期间地块 DOM 层和 DOM 角色层必须统一包在同一个 camera layer 下移动,旧当前地块先跟随相机偏移离开主视野,之后只保留在屏幕后方;不要给旧地块加独立向上 / 向下飞走 keyframes,也不要因为旧地块还在保留列表里阻塞下一跳。玩家继续向前跳时,已完成旧地块继续被新的相机推进自然带离屏幕,超过离屏阈值后销毁。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,并以旧窗口真实落点和新窗口真实落点为锚点,避免先横向瞬切居中再纵向推进;运行态相机层当前为约 `1.3x` 近距缩放。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。相机推进期间角色自身也不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回;角色推进期只允许 transform / opacity transition。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。 -- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,成功落地保留真实落点偏移,动画结束后进入 `data-platform-advancing=true`,DOM 角色层与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态角色 transition 不包含 `left/top`、旧地块没有独立 `jump-hop-platform-exit-drift` keyframes 且下一跳不会被旧地块保留态阻塞。 +- 处理:前端保留独立 `displayRun`,松手后先进入 `isJumpAnimating=true`,角色在当前显示窗口内飞向前端预测真实落点;视觉预测必须用当前显示窗口的 current/next 地块作为方向来源,不能拿已经提前返回的后端新 run 目标配旧窗口角色,否则下一跳会朝实际目标反方向飞。飞行动画完成后再把 `displayRun` 切到最新后端 run,并进入约 `1440ms` 的 `platformAdvancing` 表现态。成功后的角色显示必须使用 `lastJump.landedX/landedY` 映射出的真实偏移,不要吸附到目标地块中心。推进期间地块层和角色层必须统一包在同一个 camera layer 下移动,旧当前地块先跟随相机偏移离开主视野,之后只保留在屏幕后方;不要给旧地块加独立向上 / 向下飞走 keyframes,也不要因为旧地块还在保留列表里阻塞下一跳。玩家继续向前跳时,已完成旧地块继续被新的相机推进自然带离屏幕,超过离屏阈值后销毁。相机层必须同时设置 `--jump-hop-camera-shift-x` 与 `--jump-hop-camera-shift-y`,并以旧窗口真实落点和新窗口真实落点为锚点,避免先横向瞬切居中再纵向推进;运行态相机层当前为约 `1.3x` 近距缩放。地块保留当前 / 目标 / 预览的深度尺寸差异,但深度差异必须用固定宽高 + CSS transform scale 缓动实现,不能直接改宽高瞬切;当前态不要额外叠 CSS scale。Three.js Sprite 角色与平台共用同一套屏幕坐标投影,DOM 角色只作为 WebGL 或贴图加载失败 fallback;DOM fallback 在相机推进期间自身不能保留 `left/top` transition,否则 `displayRun` 切换造成的角色局部坐标变更会和父级 camera layer 位移叠加,视觉上像落地后又从屏幕外飞回。正式胜负、成功跳跃次数、时长和排行榜仍以后端 run 为准,前端只延迟显示态。 +- 验证:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖动画期间平台仍停在旧窗口,成功落地保留真实落点偏移,动画结束后进入 `data-platform-advancing=true`,角色 Three 帧沿真实预测落点插值并保留飞行弧线,DOM fallback 角色与地块层同在 `jump-hop-camera-layer` 内,通过 `--jump-hop-camera-shift-x` 和 `--jump-hop-camera-shift-y` 完成相机斜向推进,并校验可见地块按深度保留不同视觉尺寸、运行态平台宽高使用固定基准值、推进态 transform transition 为 `1440ms`、推进态 DOM fallback 角色 transition 不包含 `left/top`、旧地块没有独立 `jump-hop-platform-exit-drift` keyframes 且下一跳不会被旧地块保留态阻塞。 - 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`server-rs/crates/module-jump-hop/src/application.rs`。 ## 跳一跳相机推进不要让地块图片回退到原型方块 @@ -2104,19 +2112,43 @@ ## 跳一跳 Three.js 平台层不能左右镜像 DOM 坐标 - 现象:视觉上下一块地块在角色右侧,但蓄力引导和角色飞行动画朝左侧;后端回包后地块窗口又闪现摆回正确位置,像是先按反方向飞、再由快照刷新纠正。 -- 原因:Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和 DOM 角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。 -- 处理:Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。DOM 角色、蓄力引导、落点预测和 Three 平台层必须共用同向屏幕坐标。 +- 原因:Three.js 平台层如果把相机 `up` 设置成反向,或在 Three 容器上做左右镜像,会让 WebGL 地块的屏幕 X 轴和角色 / 落点预测的屏幕 X 轴相反。规则层仍沿当前地块中心到下一块中心裁决,所以后端快照会把状态纠正回来,表现为跳后刷新。 +- 处理:Three 相机保持 `up=(0, 1, 0)`,再用内部投影公式抵消 45° 下压导致的 Y 轴压缩;不要通过反向 `camera.up` 解决上下方向。Three.js Sprite 角色、DOM fallback 角色、蓄力引导、落点预测和 Three 平台层必须共用同向屏幕坐标。 - 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/services/jump-hop/jumpHopRuntimeModel.test.ts` 应覆盖 `JUMP_HOP_THREE_CAMERA_UP_Y=1`,并断言 Three 投影与 DOM 屏幕坐标同向。 - 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`。 +## 跳一跳 Three.js 角色不要被地块透明排序压住 + +- 现象:角色已经进 Three.js 场景后,看起来像落在地块内部或只露出头,角色没有站在方块顶面上。 +- 原因:地块材质如果设置 `transparent=true` 会进入 Three.js 透明物体排序队列,可能在 Sprite 角色之后绘制;同时角色脚点如果仍用固定 Z 高度,遇到标准 `1x1x1` 方块放大后的当前块时会落到顶面后方或方块体内。 +- 处理:地块贴图材质只使用 `alphaTest` 裁掉透明边,不放入透明材质队列;角色 Sprite 的 `renderOrder` 必须高于平台 mesh,脚点 Z 高度按最近方块半高加顶面偏移计算,确保角色站在当前方块顶面上方。 +- 验证:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx` 应覆盖平台材质不透明队列、角色 renderOrder 高于地块、角色脚点高度高于方块顶面。 +- 关联:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 + ## 跳一跳立方体贴图不要走透明主体切片 - 现象:水果等主题生成成功后,运行态地块看起来像薄的纯水果 PNG、果切贴纸、透明 cutout;或者反过来六个面都是同一张平铺果皮 / 果肉材质,无法组合成方块苹果 / 方块香蕉这类完整主题对象表达。 - 原因:跳一跳地板已经改为 Three.js 标准 `1x1x1` 等比极小倒角立方体复用几何体,运行态视角固定为近距相机和 45° 下压视角;image2 应生成 `1024x1536` 的 18 个 cube object UV unwrap,每个大单元内的 top/front/right/back/left/bottom 六面要共同包装同一个主题物体。只强调 full-bleed 容易让水果主题退化成果皮、果肉、叶脉等表面纹理;如果仍把一张图贴给六个面,模型也不需要理解正反和跨面连续特征。旧切图链路若把洋红 key 转 alpha、裁边、只保留最大 alpha 连通主体并补透明安全边,会把整格贴图重新抠成苹果 / 香蕉 / 果切等居中主体,贴到立方体上后四角和侧面都变透明。 -- 处理:跳一跳地板图集 prompt 固定要求 `cube object UV unwrap atlas / 立方体主题物体六面展开图集`,一张图只生成 18 个大单元,每个大单元固定 `4列*3行` UV 网:第 1 行第 2 列 top,第 2 行 left/front/right/back,第 3 行第 2 列 bottom;水果主题要明确生成能一眼说出名称的方块苹果、方块香蕉、方块橙子、方块西瓜等可识别对象,并要求果柄叶片、剥皮条带、放射切面、红瓤黑籽等身份特征跨面连续。禁止自然圆形水果、自然长条香蕉、非方块化完整水果、果切小贴纸、居中小物体、透明背景和留白,同时也禁止“单纯平铺材质 / 抽象纹理 / 只铺主题颜色 / 纯果皮材质 / 纯果肉纹理 / 纯叶脉纹理”。后端按 3x6 大单元和 4x3 UV 网切出 108 张 `256x256` 不透明面贴图,不再调用透明化、最大 alpha 连通主体保留或透明补边。洋红 `#FF00FF` 只作为图集安全缝 / UV 空位 / 外圈 key 色,裁切后若仍有极少残留则转成不透明材质底色;绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色必须完整保留。 +- 处理:跳一跳地板图集 prompt 固定要求 `cube object UV unwrap atlas / 立方体主题物体六面展开图集`,一张图只生成 18 个大单元,每个大单元固定 `4列*3行` UV 网:第 1 行第 2 列 top,第 2 行 left/front/right/back,第 3 行第 2 列 bottom;水果主题要明确生成能一眼说出名称的方块苹果、方块香蕉、方块橙子、方块西瓜等可识别对象,并要求果柄叶片、剥皮条带、放射切面、红瓤黑籽等身份特征跨面连续。禁止自然圆形水果、自然长条香蕉、非方块化完整水果、果切小贴纸、居中小物体、透明背景和留白,同时也禁止“单纯平铺材质 / 抽象纹理 / 只铺主题颜色 / 纯果皮材质 / 纯果肉纹理 / 纯叶脉纹理”。后端先对图集做洋红去背,再以 `jump_hop_atlas_slicing.rs` 的自适应 blob+gradient 算法检测 3x6 大单元和单元内六面区域,输出 108 张 `256x256` 不透明面贴图;固定 3x6 / 4x3 切片只作为测试对照和必要 fallback 参考,不作为优先生图切图路径。洋红 `#FF00FF` 只作为图集安全缝 / UV 空位 / 外圈 key 色;绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色必须完整保留。 - 验证:`cargo test -p api-server jump_hop --manifest-path server-rs/Cargo.toml -- --nocapture` 覆盖跳一跳 UV unwrap prompt、18 个大单元、108 张不透明面贴图、绿色 / 白色材质不被透明化、洋红 key 残留不作为透明洞;前端 `JumpHopRuntimeShell` 测试覆盖新 UV 资产会解析六张面贴图,旧单贴图资产仍可 fallback。 - 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs`、`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/jump_hop.rs`。 +## 跳一跳 UV 图集切片要防贴边矩形 u32 中间溢出 + +- 现象:跳一跳草稿在背景、返回按钮和地板图集 image2 都生成成功后,前端报“执行跳一跳共创操作失败”,Vite 代理日志出现 `socket hang up`,后端日志出现 `jump_hop_atlas_slicing.rs` 内 `attempt to subtract with overflow`。 +- 原因:blob gradient 切片的 histogram 最大不透明矩形在计算顶部坐标时写成 `by0 + ly - sh + 1`。当模型输出的 UV 面内容刚好贴到 cell 顶边,数学结果本应是 0,但 `u32` 会先执行中间步骤 `0 - 1` 并在 debug 运行时 panic。 +- 处理:顶部坐标先在局部坐标内用 `ly.saturating_add(1).saturating_sub(sh)` 计算,再加 block 偏移;不要恢复成连写减法。补充贴顶两行不透明矩形回归测试,保证贴边 UV 面不会打崩共创接口。 +- 验证:`RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop_atlas_slicing::tests::max_opaque_rect_handles_content_touching_top_edge`;整组再跑 `RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop`。 +- 关联:`server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs`、`server-rs/crates/api-server/src/jump_hop.rs`。 + +## 跳一跳生图切图主路径不要绕过自适应图集切片 + +- 现象:拉取 `fix/jump-hop-image-gen` 后,如果又把生成链路切回旧固定坐标裁切,容易和该分支解决的 AI 图集偏移、间距不均、UV 面位置漂移问题互相抵消,导致新生图链路的实际收益无法验证。 +- 原因:当前跳一跳 image2 prompt 仍要求 3x6 大单元和 4x3 UV 子网格,这是给模型和算法的结构约束;真实生产切图由自适应 `SeedRefinement + blob + gradient + max opaque rectangle` 链路消化 AI 输出偏差。固定网格切片只能验证理想图集,不适合覆盖新分支的主修复。 +- 处理:生产生成链路优先调用 `slice_tile_atlas_adaptive(...)`;旧固定 `slice_jump_hop_tile_atlas(...)` 只保留为对照测试、实验和必要 fallback 参考。若自适应切图出现具体误切,应优先修正自适应模块的边界检测、主 blob、透明/安全色处理和回归测试,而不是直接全局切回固定坐标。 +- 验证:新生成作品下载 `tile-01-top/front/right` 等面贴图时,单图应基本充满对应主题面内容,不应出现大块空背景、相邻面混入或纯色原型 cube;同时执行 `RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml jump_hop_atlas_slicing -- --nocapture`。 +- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 + ## 含中文 image2 live 验证不要用 PowerShell 管道喂 Node 源码 - 现象:本地用 `@'...'@ | node -` 跑 VectorEngine / gpt-image-2 live 验证时,`request.json` 里的中文 prompt 可能全部变成 `????`,生成图会变成完全不相关的 UI、建筑海报或其它随机内容,容易误判为模型不服从提示词。 diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md index e510514ae..1df121a4c 100644 --- a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -9,8 +9,8 @@ 1. 创作输入只保留主题,标题、简介、标签和提示词由系统派生; 2. image2 只生成一张 `1024x1536` 地板 UV 展开图集,后端切成 18 组、共 108 张面贴图 PNG; 3. 角色不再单独生图,v1 使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG; -4. 运行态每屏只展示 3 个地块:当前地块、目标地块、下一预览地块; -5. 操作方式为长按屏幕蓄力并按拖拽方向起跳,松手后角色按前端提交的后端方向向量弹出; +4. 运行态每屏只展示 2 个地块:当前地块、目标地块,不再展示下一预览地块; +5. 操作方式为长按屏幕蓄力,松手后角色朝下一块地块中心方向弹出; 6. 只要落点未命中下一个地块,本局立即失败并冻结计时; 7. 成绩记录成功跳跃次数和游戏时长; 8. 排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。 @@ -21,12 +21,12 @@ - 展示名:`跳一跳` - 工程域:`jump-hop` - 创作入口卡:`subtitle = 主题驱动平台跳跃`,`imageSrc = /creation-type-references/jump-hop.webp` -- 运行态:`Three.js 标准 1x1x1 等比极小倒角立方体地板 + DOM 角色 + DOM HUD` +- 运行态:`Three.js 标准 1x1x1 等比极小倒角立方体地板 + Three.js Sprite 角色 + DOM HUD` - 画面比例:移动端竖屏优先,桌面端居中承载 `9:16` - 素材策略:18 个立方体主题物体 UV 展开包装 + Three.js 复用标准 1x1x1 等比立方体几何 + 陶泥儿 logo 透明角色 -- 渲染分层:Three.js 平台层复用一份标准 `1x1x1` 等比极小倒角立方体几何体,`tileAssets[]` 切片只作为主题身份方块包装贴图;单块立方体必须正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,也不得用不同 x/y/z scale 压成扁盒子;运行态视角采用约 `1.3x` 近距相机和 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,后续两块向上展开且保持紧凑的纵向 / 横向间距;Three.js 平台层与 DOM 角色层必须保持屏幕 X 轴同向,禁止通过反向相机 `up` 或镜像容器把平台左右翻转;DOM 地块图片层只用于换签、预加载、WebGL 不可用和测试 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,退出地块只随相机推进自然离屏,不播放独立飞走动画,超过屏幕后再销毁,避免旧地块退出期露出被放大的平面 DOM 贴图;角色必须由 DOM 透明 PNG 层渲染并保持在 Three.js 平台层之上 +- 渲染分层:Three.js 场景层复用一份标准 `1x1x1` 等比极小倒角立方体几何体,`tileAssets[]` 切片只作为主题身份方块包装贴图;单块立方体统一绕玩法竖直 Z 轴自转 45°,让运行态稳定露出顶面和两个侧面,不得做 Y 轴偏航或把 x/y/z scale 压成扁盒子;Three.js 方块模型边长在当前基础上视觉放大 1 倍,只改变模型显示尺寸,不改变平台中心点、随机间距和蓄力换算;后端命中 footprint 必须同步等于当前视觉可见顶面,不得隐藏收缩;运行态视角采用约 `1.69x` 近距相机和 45° 下压视角,每屏只保留当前地块和目标地块,当前脚下地块会根据目标方向偏向场地反侧,给下一块留出足够视野;地块从出现开始就使用自身真实规格,不再按当前 / 目标 / 远近或预览状态叠加倍率缩放,视觉远近只由相机和 Three.js 投影决定;Three.js 平台层、Three.js Sprite 角色和 DOM fallback 层必须保持屏幕 X 轴同向,禁止通过反向相机 `up` 或镜像容器把平台左右翻转;DOM 地块图片层只用于换签、预加载、WebGL 不可用和测试 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,退出地块只随相机推进自然离屏,不播放独立飞走动画,超过屏幕后再销毁,避免旧地块退出期露出被放大的平面 DOM 贴图;角色主路径使用 Three.js Sprite 承载陶泥儿透明 PNG,Sprite 脚点必须落在当前方块顶面中心高度并且绘制顺序高于地块,DOM 角色层仅在 WebGL 或角色贴图加载失败时兜底 -本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块在当前地块上方不同 x 轴位置随机出现。 +本玩法不是横版平台跳跃,也不是关卡制闯关。平台从屏幕下方向上无限延展,目标地块永远在当前脚下地块的正 45 度或负 45 度方向随机出现。 ## 3. 创作工具平台接入声明 @@ -37,7 +37,7 @@ - `batchId = jump-hop-tile-atlas` - `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格内自适应blob+gradient提取六面 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG` - `slotSpecs = tile-01 ... tile-18`,每个 tile 再包含 `top/front/right/back/left/bottom` 六个面 slot,所有 slot 必须对应唯一 OSS path / `assetObjectId` - - 切图规则:先通过 density 种子点精修自适应检测 3 列 6 行大单元边界(`SeedRefinement`);每个大单元内部先用 BFS 连通域提取主 blob、清除非主 blob 噪点,再对行 density 和列 height profile 做 gradient 分析检测边界(y₀/y₁/y₂/y₃、x₀/x₁/x₂/x₃),按此边界划分为 3×3 block 并保留 5 个有效 block,将含 Right+Back 的 block 从中点拆分为两块,对每个 block 取最大不透明矩形后缩放为 `256x256` 不透明 PNG + - 切图规则:先通过 density 种子点精修自适应检测 3 列 6 行大单元边界(`SeedRefinement`);每个大单元内部先用 BFS 连通域提取主 blob、清除非主 blob 噪点,再对行 density 和列 height profile 做 gradient 分析检测边界(y0/y1/y2/y3、x0/x1/x2/x3),按此边界划分为 3x3 block 并保留 5 个有效 block,将含 Right+Back 的 block 从中点拆分为两块,对每个 block 取最大不透明矩形后缩放为 `256x256` 不透明 PNG - 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位;后端先对图集做洋红去背(BFS 漫水 + 镂空洞检测),再对每个大单元内提取主 blob 后进行自适应面切分;切分后在 block 内取最大不透明矩形,消除透明边缘 - 失败回写:生成失败时 session 保持 failed,可从生成页重试 - 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存 @@ -64,7 +64,7 @@ image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀 图集要求: -1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`。以上为 AI 生图的 layout 要求(prompt 侧不变)。后端切图改为自适应 blob+gradient 算法检测面的实际像素区域,不再依赖固定像素坐标均分。 +1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`。以上为 AI 生图的 layout 要求(prompt 侧不变)。后端切图优先使用自适应 blob+gradient 算法检测面的实际像素区域,不依赖固定像素坐标均分;固定网格切片只作为测试对照和必要 fallback 参考。 2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标; 3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成; 4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理; @@ -83,38 +83,37 @@ tile-13 tile-14 tile-15 tile-16 tile-17 tile-18 ``` -每个 `tile-XX` 再切出 `top/front/right/back/left/bottom` 六个面贴图并写入 `tileAssets[].faceAssets`。历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 保存 top 面,旧作品没有 `faceAssets` 时运行态仍可把单张旧贴图应用到立方体所有面。运行态随机使用这 18 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。 +每个 `tile-XX` 再切出 `top/front/right/back/left/bottom` 六个面贴图并写入 `tileAssets[].faceAssets`。历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 保存 top 面;旧作品没有完整 `faceAssets` 时只走 DOM 图片 / 原型兜底层,不再把单张旧贴图强行贴到 Three.js 立方体所有面,避免旧平面素材被误表现成 UV 贴歪。运行态随机使用这 18 个地块作为后续平台外观。起点地块可复用第一个切片,其余平台从完整池中随机选择。 ## 6. 运行态规则 ### 6.1 平台流 -运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 3 个地块可见: +运行态从底部初始地块开始,后续地块持续向屏幕上方生成。每次相机窗口只保留 2 个地块可见: 1. 当前地块; -2. 目标地块; -3. 下一预览地块。 +2. 目标地块。 -服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。前端只展示服务端快照,不自行生成正式路径。 +服务端保存当前 run 的路径缓冲,并在每次成功落地后按同一 seed 补齐后续地块。每个后续地块只能生成在当前地块的正 45 度或负 45 度方向上,世界坐标必须满足 `abs(next.x - current.x) == next.y - current.y`,左右方向由 seed 随机决定。当前版本已有的最远相邻地块间距作为各难度 `max_gap` 上限;每次新地块距离由 seed 在 `max_gap * 55%` 到 `max_gap` 之间随机,保证相对距离永远大于 0 且不会超过当前最大手感距离。前端只展示服务端快照,不自行生成正式路径;当前两块可见窗口必须按服务端真实相邻距离缩放屏幕投影,最大距离仍落在当前版本固定的左上 / 右上 45 度位置,较近距离则沿同一 45 度方向靠近当前脚下地块。当前脚下地块仍根据目标方向偏向目标反侧,避免前方同时暴露两块地块。角色开局时脚点必须锚定在初始地块顶面中心;Three.js 角色层通过立方体顶面高度定位脚点,DOM fallback 也使用同一顶面中心屏幕锚点,不得额外把角色吸附到地块侧面或阴影中心。 ### 6.2 操作 1. 用户按住当前地块或画面开始蓄力; 2. 长按时长形成蓄力值,达到 `maxChargeMs` 后封顶; -3. 松手后角色按本次输入方向弹出; -4. 蓄力值决定跳跃距离,拖拽方向决定跳跃方向; -5. 前端必须同时提交 `dragDistance` 与换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端以这两个方向字段裁决真实落点;旧客户端缺失方向或方向非法时,后端才 fallback 到当前地块中心指向下一块地块中心。 +3. 松手后角色从当前真实脚点出发,朝下一块地块顶面中心方向弹出; +4. 蓄力值决定跳跃距离,用户拖拽方向不决定跳跃方向; +5. 前端提交 `dragDistance`,并为兼容后端契约提交由角色当前真实脚点指向下一块地块顶面中心推导出的 `dragVectorX/dragVectorY`;这些方向字段不得来自用户手指拖拽方向。 -手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃距离的换算系数;旧作品运行时若仍携带其它系数,开局归一化为 `0.004`。契约中的 `dragDistance` 语义是前端提交的蓄力值;`dragVectorX/dragVectorY` 是正式方向输入契约,不能在前端提交或后端裁决中丢弃。 +手感参数固定由后端 `module-jump-hop` 提供:`chargeToDistanceRatio = 0.004`。该值表示蓄力时长到世界跳跃距离的换算系数;旧作品运行时若仍携带其它系数,开局归一化为 `0.004`。契约中的 `dragDistance` 语义是前端提交的蓄力值;`dragVectorX/dragVectorY` 仅用于兼容当前后端请求结构,玩法语义上不表示用户拖拽方向。 -松手后前端必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画;视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色从当前地块沿下一块地块中心方向弹向预测真实落点,蓄力阶段角色只做垂直压缩,不沿目标方向拉长。成功落地后必须保留 `lastJump.landedX/landedY` 对应的真实落点偏移,不得强制吸附回目标地块中心;落地后可以轻量回弹,但不能把角色位置拉离真实落点。动画期间 DOM 地块窗口保持在本次起跳前的 3 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应先使用后端真实落点对齐显示态,再进入约 `1440ms` 的相机推进过渡,避免角色先飞过很远再瞬间拉回地块。推进过渡中,地块 DOM 层和 DOM 角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块只随相机推进保留在屏幕后方,不单独执行飞走动画,玩家继续向前跳时再被新的相机推进自然带出屏幕并销毁,新预览地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块可以保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 CSS `transform: scale(...)` 表达,并在相机推进期间用同一 `1440ms` 缓动过渡;不得通过直接改宽高造成瞬切变大。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。 +松手后前端必须立即生成 `visualJump`,用当前角色真实脚点作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画;视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色从当前真实脚点沿下一块地块顶面中心方向弹向预测真实落点,蓄力阶段角色只做垂直压缩,不沿目标方向拉长。当前调参验证阶段,按住蓄力时允许显示一枚实时预测落点指示器,位置必须复用同一套前端预测结果:先按真实脚点到下一块顶面中心计算 `landedX/landedY`,再把该世界坐标投影到当前窗口和 Three.js 顶面脚点屏幕位置;不得用当前地块中心或屏幕线性插值替代。松手或取消时隐藏指示器,不参与后端裁决、不写入作品配置。成功落地后必须保留 `lastJump.landedX/landedY` 对应的真实落点偏移,不得强制吸附回目标地块中心;落地后可以轻量回弹,但不能把角色位置拉离真实落点。动画期间显示窗口保持在本次起跳前的 2 块布局,动画路径不得等待后端新 run。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应先使用后端真实落点对齐显示态,成功跳跃在飞行动画结束后保留约 `300ms` 落地停顿,再进入约 `1440ms` 的相机推进过渡,避免角色刚落地就立刻拉镜头。推进过渡中,地块层和角色层必须放在同一个相机层里统一位移,不允许 p1/p2 单独改 `top/left` 做过渡;旧当前地块只随相机推进保留在屏幕后方,不单独执行飞走动画,玩家继续向前跳时再被新的相机推进自然带出屏幕并销毁,新目标地块从上方自然露出,避免角色和地块不同步或闪现。相机推进必须同时携带 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不允许先横向瞬切居中后再只做纵向滑动。地块从出现开始保持自身真实尺寸,不得通过当前 / 目标 / 远近 / 预览状态附加 CSS `scale(...)` 或深度倍率;推进期只做统一相机层位移,远近变化交给相机和 Three.js 真实投影。当前地块高亮不得额外通过 CSS `scale` 放大。该动画只属于表现层,命中、失败、成功跳跃次数和冻结时长仍以后端裁决为准。 ### 6.3 判定 1. 目标永远是当前地块后的下一个地块; -2. 真实落点沿前端提交的 `dragVectorX/dragVectorY` 归一化方向计算;仅当方向缺失、非有限数或长度过小时,才沿当前地块中心到下一块地块中心方向兼容计算; -3. 落点进入下一个地块可见顶面 footprint,则成功;footprint 使用当前路径里该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%; -4. 落点未进入下一个地块可见顶面 footprint,则失败;旧 `landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定; +2. 真实落点沿角色当前真实脚点到下一块地块顶面中心方向计算;开局脚点等于初始地块顶面中心,成功跳跃后脚点等于后端 `lastJump.landedX/landedY`,不得回退到当前地块中心; +3. 落点进入下一个地块完整可见顶面 footprint,则成功;footprint 使用当前路径里该地块 `width/height` 按 45° 顶面投影得到的完整菱形区域,必须严格和当前视觉方块顶面一致,不得再额外收缩或放宽; +4. 落点未进入下一个地块完整可见顶面 footprint,则失败;地块侧面、底面、投影阴影和旧半径范围都不算正确落点;旧 `landingRadius/perfectRadius` 字段仅保留兼容读写,不再作为当前 v1 成功判定; 5. 失败后状态改为 `failed`,计时冻结; 6. v1 没有通关状态、combo、perfect 或生命数。 @@ -153,7 +152,7 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc 1. 陶泥儿 logo 透明角色预览; 2. 18 个地块资源池预览; -3. 首屏 3 块平台预览; +3. 首屏 2 块平台预览; 4. 试玩; 5. 发布; 6. 返回编辑; @@ -189,11 +188,11 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc 2. 生成链路只调用一次地板贴图图集 image2,不再调用角色生图; 3. 地板贴图图集为 `1024x1536 / 3列*6行`,后端通过自适应 blob+gradient 算法切出 18 组、共 108 张面贴图 PNG; 4. 结果页不依赖旧角色图片槽; -5. 运行态为竖屏俯视角,首屏保持 3 个地块可见; -6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向; +5. 运行态为竖屏俯视角,首屏保持 2 个地块可见; +6. 长按蓄力值影响落点距离,角色初始脚点在初始地块顶面中心,跳跃方向固定朝下一块地块中心,目标地块始终位于当前脚下地块的正 45 度或负 45 度方向; 7. 未落到下一个地块立即失败; 8. 成功跳跃次数累加,失败后计时冻结; 9. 排行榜按成功跳跃次数优先排序; 10. 作品可保存、发布、分享并从公开入口启动。 -11. 运行态 Three.js 地板必须优先把 `tileAssets[].faceAssets` 六面贴图按 right/left/top/bottom/front/back 材质顺序贴到标准 `1x1x1` 等比立方体上;旧作品没有 `faceAssets` 时才使用 `tileAssets[].imageSrc` 单贴图 fallback。六面贴图通过换签或 blob 异步解析时,Three.js 平台 mesh 的刷新签名必须包含 top/front/right/back/left/bottom 六个 texture URL,任一面 URL 变化都要触发材质重建,不能只监听旧单图 `imageSrc`。立方体正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转,不得把 x/y/z 缩放成扁盒子;相机保持近距 45° 下压视角,当前脚下地块基准位于屏幕中线略下方,可见三块地板之间的屏幕间距必须偏紧凑;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或 DOM 角色层。 -12. 同等世界距离的蓄力换算必须使用 `0.004` 系数,松手后必须先看到角色飞行动画,再看到地块窗口前移;成功落地显示必须保留真实落点偏移。 +11. 运行态 Three.js 地板必须只在 `tileAssets[].faceAssets` 六面贴图完整时启用 Three 平台层;玩法坐标把 Z 轴作为立方体竖直高度,因此材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom`,把逻辑 `top` 精确映射到 `+Z` 顶面,并按每面 UV 朝向做必要的翻转校正;六面贴图通过换签或 blob 异步解析时,Three.js 平台 mesh 的刷新签名必须包含 top/front/right/back/left/bottom 六个 texture URL,任一面 URL 变化都要触发材质重建,不能只监听旧单图 `imageSrc`。旧作品没有完整 `faceAssets` 时使用 DOM 图片 / 原型兜底层,不使用单图 3D 贴面 fallback。立方体统一绕玩法竖直 Z 轴自转 45°,让玩家稳定看到顶面和两个侧面,不做 Y 轴偏航,不得把 x/y/z 缩放成扁盒子;Three.js 方块模型边长视觉放大 1 倍,但平台中心点、随机间距和蓄力换算均保持原规则;后端命中 footprint 必须与当前视觉顶面完整对齐;地块材质使用 `alphaTest` 裁边但不得放进透明材质队列,避免透明排序把地块画到角色之上;角色主路径使用 Three.js Sprite 并与平台共用同一屏幕坐标投影,Sprite 脚点必须按当前方块半高抬到顶面中心高度,绘制顺序必须高于地块,DOM 角色仅作为 WebGL 或角色贴图加载失败兜底;相机保持约 `1.69x` 近距 45° 下压视角,当前脚下地块根据目标方向偏向场地反侧,可见当前 / 目标两块地板之间的屏幕间距必须形成正负 45 度关系;所有地块从出现开始保持真实规格,不按距离、深度或预览状态做倍率缩放;长按蓄力、计时刷新和角色位置更新不得销毁重建透明画布、平台贴图预加载层或角色层。 +12. 同等世界距离的蓄力换算必须使用 `0.004` 系数,松手后必须先看到角色飞行动画,再保留约 `300ms` 落地停顿,随后看到地块窗口前移;成功落地显示必须保留真实落点偏移,且正确落点范围必须严格等于下一块地块完整可见顶面 footprint。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 00e3bf72c..9e07f2d07 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -176,23 +176,25 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色; 3. 地板贴图只调用一次 image2,输出一张 `1024x1536` 竖版、`3列*6行`、单一纯洋红 `#FF00FF` key 安全缝 / 外圈背景的立方体主题物体 UV 展开图集;image2 要生成 18 个完整 `1x1x1` 立方体主题物体包装,每个大单元格内部固定为 `4列*3行` UV 网:第 1 行第 2 列为 `top`,第 2 行依次为 `left / front / right / back`,第 3 行第 2 列为 `bottom`,其它 UV 空位保持纯洋红。每个大单元格的六个面必须属于同一个方块化主题物体,top/front/right/back/left/bottom 之间的果皮、切面、籽点、条纹、果柄、叶片等身份特征要连续一致,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标。水果主题应生成 18 种可一眼辨认的方块水果 UV,例如方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。禁止文字、UI、底座、托盘、圆台、地板垫层、落地投影、接触阴影、方形阴影、洋红描边、紫色底边、粉色脏边、彩色光晕、发光边、透明背景、留白、自然圆形水果、自然长条香蕉、孤立水果照片、小型贴纸、纯果皮材质、纯果肉纹理、纯叶脉纹理和无法分辨具体物体的抽象纹理;真实透视、极小倒角、侧壁厚度和阴影统一由运行态 Three.js 标准 `1x1x1` 等比立方体生成。后端只把洋红 key 作为图集安全边界处理,先按 3x6 大单元格切出 18 个方块,再按每格 4x3 UV 网切出 108 张 `256x256` 不透明面贴图,不再运行透明化抠图、最大 alpha 连通主体保留或透明安全边补白;若裁切后仍残留极少洋红 key 色,会转成不透明材质底色。前端和后端默认 `tilePrompt` 都必须使用“立方体主题物体 UV 展开包装图集 / cube object UV unwrap atlas”的口径,不再提交“正面30度主题物体 / 平台素材 / 跳台 / 地块成品 / 地砖 / 材质贴片 / 平铺纹理”等会把模型拉回 2D 地块、平台或单纯材质的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / 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-18`,每个方块再持久化 `tile-XX-top/front/right/back/left/bottom` 六个独立 slot/path,不能按重复的 `tileType` 复用槽位;`tileAssets[].faceAssets` 保存六面贴图,历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 写 top 面作为旧单贴图 fallback,运行态对旧作品没有 `faceAssets` 时仍可把单张贴图应用到立方体所有面; -6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;移动端结果页必须由结果页根容器承接纵向滚动并保留底部安全区,确保素材预览较长时仍能下滑到返回编辑、试玩和发布按钮; +5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-18`,每个方块再持久化 `tile-XX-top/front/right/back/left/bottom` 六个独立 slot/path,不能按重复的 `tileType` 复用槽位;`tileAssets[].faceAssets` 保存六面贴图,历史兼容字段 `imageSrc/imageObjectKey/assetObjectId` 写 top 面作为旧单贴图 fallback;运行态只有在六面 `faceAssets` 完整时才启用 Three.js 立方体贴面,旧作品没有完整 `faceAssets` 时只走 DOM 图片 / 原型兜底层,不再把单张旧贴图强行贴到立方体所有面; +6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 2 地块预览;不再提供旧角色图生成槽;移动端结果页必须由结果页根容器承接纵向滚动并保留底部安全区,确保素材预览较长时仍能下滑到返回编辑、试玩和发布按钮; 7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、返回按钮去绿、地板贴图图集切片和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。 待解决问题(风险程度:高):跳一跳创作链路目前仍是一次 HTTP 请求内串行生成背景底图、返回按钮、地板贴图图集、切片和 OSS 写入;VectorEngine image2 单步 timeout/connect 失败会在后端最多重试 5 次,而前端只有 20 分钟总等待窗口。若某次背景底图生成接近或超过 18 分钟,前端会先报“请求超时,请稍后重试”,但后端可能继续跑完并在数分钟后写入草稿;同时因为背景、返回按钮和图集等中间资产未按阶段落库,同一 session 超时后重试会重新从背景图开始生成,存在重复生图、重复计费、用户误以为失败、作品架状态短时间不一致的风险。后续应将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,并在每个素材阶段成功后写入可恢复状态;同时收口后端全局生成 deadline、前端等待策略和失败态回写,确保超时、重试和最终成功不会互相打架。 生成页“当前跳一跳信息”只展示实际参与创作提示词的主题、地块提示词等用户可理解信息;`stylePreset` 等未参与当前 image2 提示词组装的内部风格枚举不得作为兜底内容展示,避免把 `minimal-blocks`、`paper-toy` 等工程值暴露给创作者。 +运行态地块刷新规则固定由后端路径快照决定:后续地块只会出现在当前地块正负 45 度方向,当前版本已有的最远相邻地块间距作为各难度 `max_gap`,每次新地块距离按 seed 在 `max_gap * 55%` 到 `max_gap` 之间随机,距离不允许为 0。前端可见窗口不得再把目标块强制放到固定屏幕坐标,而必须按服务端真实相邻距离缩放投影;最大距离沿用当前固定视觉间距,较近距离沿同一 45 度方向靠近当前块。 + 运行态规则真相必须沉到 `module-jump-hop`,前端只做长按蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 -每屏只展示 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`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。 +每屏只展示 2 个地块:当前地块和目标地块,不再展示下一预览地块。平台流按同一 seed 无限生成,服务端每次补齐路径时只能把下一块生成在当前脚下块的正 45 度或负 45 度方向上,世界坐标满足 `abs(next.x - current.x) == next.y - current.y`;前端不得自行生成正式路径,只能按服务端路径方向把当前两块可见窗口投影成左上 / 右上 45 度布局,并把当前脚下地块偏向目标反侧,给目标地块留出更大视野空间。运行态 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 背景底图,图片读取继续走平台资产换签,没有背景时才回退到内置渐变;Three.js 平台层复用同一份标准 `1x1x1` 等比极小倒角立方体几何体,只按单一 side 等比缩放当前 / 目标 / 预览地块,并把 `tileAssets[]` 的生成切片作为主题身份方块包装贴图加载到立方体表面;单块地板保持正轴向摆放,不做 Y 轴偏航或 Z 轴歪斜旋转;`tileAssets[].faceAssets` 存在时,Three.js 材质刷新签名必须纳入 top/front/right/back/left/bottom 六面 texture URL,任一面异步换签或 blob URL 变化都要重建平台材质,不能只监听旧单图 `imageSrc` 或基础 render key;运行态采用约 `1.3x` 近距相机、45° 下压视角和更紧凑的可见地板间距,当前脚下地块基准位于屏幕中线略下方,目标和预览地块向上展开,侧壁、倒角、透视和软椭圆阴影均由 Three.js 统一表现;Three.js 相机和 DOM 角色层必须保持屏幕 X 轴同向,不得通过反向 `camera.up` 或镜像 wrapper 把平台层左右翻转,否则会出现地块显示在右侧但蓄力与飞行动画朝左侧的反向错觉;DOM 地块图片层只作为资产换签、预加载、WebGL 不可用和测试环境 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,避免露出旧原型方块或双层闪现;推进期存在旧地块退出保留时,Three 平台层必须继续承接 3D 地块渲染,旧地块只跟随后续相机推进逐步离屏,不播放独立飞走动画,超过屏幕后自然销毁;图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存。DOM 角色层固定使用 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG 并保持在 Three.js 平台层之上。长按蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景、平台贴图预加载层或 DOM 角色层,否则会造成背景、地块和角色层频闪。 +运行态渲染分层固定为:舞台底层 `.jump-hop-runtime__scene-backdrop` 优先使用 `coverComposite` / `coverImageSrc` 中的 image2 背景底图,图片读取继续走平台资产换签,没有背景时才回退到内置渐变;Three.js 场景层复用同一份标准 `1x1x1` 等比极小倒角立方体几何体,按地块自身真实规格计算当前 / 目标地块尺寸,并且只有在 `tileAssets[].faceAssets` 六面贴图完整时才把生成切片作为主题身份方块包装贴图加载到立方体表面;六面贴图通过换签或 blob 异步解析时,Three.js 平台 mesh 的刷新签名必须纳入 top/front/right/back/left/bottom 六面 texture URL,任一面 URL 变化都要重建平台材质,不能只监听旧单图 `imageSrc` 或基础 render key;玩法坐标把 Z 轴作为立方体竖直高度,运行态必须把逻辑 `top` 映射到 Three.js `+Z` 顶面,材质数组按 Three group 顺序写入 `right / left / back / front / top / bottom` 并按每面 UV 朝向做翻转校正;单块地板统一绕玩法竖直 Z 轴自转 45°,让玩家稳定看到顶面和两个侧面,不做 Y 轴偏航,也不得把 x/y/z 缩放成扁盒子;Three.js 方块模型边长在当前基础上视觉放大 1 倍,但只改变模型显示尺寸,不改变平台中心点、上一轮 `max_gap * 55%` 到 `max_gap` 的随机相对间距和蓄力换算;后端命中 footprint 必须严格等于当前视觉完整顶面,不论何时都不得额外收缩或放宽;地块材质使用 `alphaTest` 裁边但不得放进透明材质队列,避免透明排序把地块画到角色之上;运行态采用约 `1.69x` 近距相机、45° 下压视角和更紧凑的可见地板间距,当前脚下地块根据目标方向偏向场地反侧,目标地块向上展开,侧壁、倒角、透视和软椭圆阴影均由 Three.js 统一表现;地块从出现开始保持真实规格,不再按当前 / 目标 / 远近或预览状态叠加倍率缩放,视觉远近只由相机和 Three.js 投影决定;Three.js 平台、Three.js Sprite 角色和 DOM fallback 层必须保持屏幕 X 轴同向,不得通过反向 `camera.up` 或镜像 wrapper 把平台层左右翻转,否则会出现地块显示在右侧但蓄力与飞行动画朝左侧的反向错觉;DOM 地块图片层只作为资产换签、预加载、WebGL 不可用和测试环境 fallback,Three.js 平台层 ready 后必须隐藏 DOM 地块图片和 DOM 阴影,避免露出旧原型方块或双层闪现;旧作品没有完整 `faceAssets` 时不启用 Three 平台层,继续显示 DOM 图片 / 原型兜底层;推进期存在旧地块退出保留时,Three 平台层必须继续承接 3D 地块渲染,旧地块只跟随后续相机推进逐步离屏,不播放独立飞走动画,超过屏幕后自然销毁;图片读取继续走平台资产换签,并以 `assetObjectId` 作为刷新键避免重生成后沿用旧签名或旧图片缓存。角色主路径使用 Three.js Sprite 承载 `public/branding/jump-hop-taonier-character.png` 陶泥儿 logo 透明 PNG,开局脚点必须位于初始地块顶面中心,Sprite 脚点必须按当前方块半高抬到顶面中心高度且绘制顺序高于地块;DOM 角色层仅在 WebGL 或角色贴图加载失败时兜底,并使用同一个顶面中心屏幕锚点,不得额外锚到地块侧面或阴影中心。长按蓄力、计时刷新和角色位置变化只能更新 refs 或 DOM 状态,不得销毁重建透明画布、背景、平台贴图预加载层或角色层,否则会造成背景、地块和角色层频闪。 -跳一跳当前长按蓄力手感统一采用 `chargeToDistanceRatio=0.004`,用于把长按时长换算成世界跳跃距离;如果历史路径仍保存其它系数,`start_run` 会在开局归一化到新系数。用户按住画面开始蓄力,松手立即起跳;前端必须提交 `dragDistance` 以及换算到后端世界坐标的 `dragVectorX/dragVectorY`,后端正式裁决用该方向向量计算真实落点,只有旧客户端缺失方向、方向非有限数或向量长度过小时,才 fallback 到当前地块中心指向下一块地块中心。成功判定使用下一块地块可见顶面 footprint:后端以该地块 `width/height` 的收缩矩形模拟 45° 视角下的可见顶面,当前命中区约为宽度 72% 和高度 52%,落点进入该视觉顶面则成功,未进入则失败;旧 `landingRadius/perfectRadius` 只保留兼容读写,不再作为当前命中真相。蓄力中角色只做垂直压缩,不沿目标方向拉伸;蓄力反馈可显示朝向当前目标方向的轻量引导,但不显示落点辅助点、投影圈或其它命中提示。松手后运行态必须立即生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画:视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色沿本次提交方向弹向预测真实落点,成功也不得强制吸附回目标地块中心。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应优先用 `lastJump.landedX/landedY` 映射出的真实落点显示角色,再把显示态切到后端最新 run,并用约 `1440ms` 的相机层推进过渡承接新窗口,避免先飞过很远再瞬间拉回地块造成闪现。推进时地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块只随相机推进保留在屏幕后方,不单独执行向上 / 向下飞走动画;玩家继续向前跳时,旧地块继续被新的相机推进带离视口,超过离屏阈值后自然销毁,新预览地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块允许保留当前 / 目标 / 预览的深度尺寸差异,但该差异必须通过固定基准宽高上的 `transform: scale(...)` 缓动呈现,并与相机推进使用同一 `1440ms` 节奏;不要直接修改宽高造成瞬切,也不要再给当前态额外叠 CSS scale。相机推进期间角色自身必须禁用 `left/top` transition,只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。 +跳一跳当前长按蓄力手感统一采用 `chargeToDistanceRatio=0.004`,用于把长按时长换算成世界跳跃距离;如果历史路径仍保存其它系数,`start_run` 会在开局归一化到新系数。用户按住画面开始蓄力,松手立即起跳;跳跃朝向永远由角色当前脚点指向下一块地块顶面中心,前端不再提交拖拽方向,后端即使收到旧客户端的 `dragVectorX/dragVectorY` 也必须忽略。实际落点只由蓄力时长换算出的跳跃距离决定,成功判定只使用下一块地块完整可见顶面 footprint:后端以该地块 `width/height` 按 45° 顶面投影得到的完整菱形区域作为命中区,必须严格和视觉方块顶面效果对齐,禁止隐藏收缩命中区。落点进入该视觉顶面则成功,未进入则失败;地块侧面、底面、投影阴影和旧 `landingRadius/perfectRadius` 半径范围都不算正确落点,旧半径字段只保留兼容读写,不再作为当前命中真相。蓄力中角色只做垂直压缩,不沿目标方向拉伸;蓄力反馈可显示朝向下一块顶面中心的轻量引导。当前调参验证阶段,按住蓄力时允许显示一枚实时预测落点指示器,位置必须复用同一套前端预测结果:先按真实脚点到下一块顶面中心计算 `landedX/landedY`,再把该世界坐标投影到当前窗口和 Three.js 顶面脚点屏幕位置,不得用当前地块中心或屏幕线性插值替代;松手或取消时隐藏,不参与后端裁决、不写入作品配置。松手后运行态必须立即生成 `visualJump`,用当前角色真实脚点作为起点、前端预测真实落点作为终点,播放约 `560ms` 的角色飞行动画:视觉预测必须使用当前显示窗口的 current/next 地块作为方向来源,即使后端最新 run 已提前返回,也不能拿新 run 目标配旧窗口角色导致下一跳反向;角色沿当前脚点到下一块顶面中心方向弹向预测真实落点,成功也不得强制吸附回目标地块中心。若后端新 run 晚于飞行动画返回,角色必须停在预测真实落点等待;新 run 到达后应优先用 `lastJump.landedX/landedY` 映射出的真实落点显示角色,成功跳跃在飞行动画结束后保留约 `300ms` 落地停顿,再把显示态切到后端最新 run,并用约 `1440ms` 的相机层推进过渡承接新窗口,避免先飞过很远再瞬间拉回地块或刚落地就立刻拉镜头。推进时地块层和角色层统一包在同一个 camera layer 下移动,旧当前地块只随相机推进保留在屏幕后方,不单独执行向上 / 向下飞走动画;玩家继续向前跳时,旧地块继续被新的相机推进带离视口,超过离屏阈值后自然销毁,新目标地块从上方露出,禁止用 p1/p2 各自 `top/left` 过渡造成角色和地块不同步。相机层推进必须同时使用 X/Y 偏移,从旧真实落点位置斜向滑到新当前地块聚焦位置,不得先横向瞬切到居中再纵向滑动。地块从出现开始保持自身真实尺寸,不得通过当前 / 目标 / 远近 / 预览状态附加 CSS `scale(...)` 或深度倍率;推进期只做统一相机层位移,远近变化交给相机和 Three.js 真实投影。相机推进期间 DOM 兜底角色自身必须禁用 `left/top` transition,只允许父级 camera layer 负责位移,否则角色局部坐标切换和相机推进会叠加,表现为落地后又从屏幕外闪回。 -平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地板贴图图集和路径配置,必须先补读完整 work profile 再传入运行态。`/runtime/jump-hop?work=JH-*` 这类正式深链必须先通过公开作品号回读 gallery detail,再以 profileId 启动 published run;直接打开没有 `work` 参数的 `/runtime/jump-hop` 时不能停留在空运行态或“正在加载内容”,应回到平台首页。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 +平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地板贴图图集和路径配置,必须先补读完整 work profile 再传入运行态。结果页草稿试玩调用启动 run 时必须把 `runtimeMode=draft` 写进请求 body,不能只放在请求选项里;正式作品深链和推荐流使用 published 默认模式。`/runtime/jump-hop?work=JH-*` 这类正式深链必须先通过公开作品号回读 gallery detail,再以 profileId 启动 published run;直接打开没有 `work` 参数的 `/runtime/jump-hop` 时不能停留在空运行态或“正在加载内容”,应回到平台首页。平台壳层必须同步注册 `jump-hop-workspace`、`jump-hop-generating`、`jump-hop-result`、`jump-hop-runtime`、`jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop`、`/creation/jump-hop/generating`、`/creation/jump-hop/result`、`/gallery/jump-hop/detail`、`/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。 diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 3bc37128c..c1607175c 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -1090,15 +1090,8 @@ pub(crate) fn slice_jump_hop_tile_atlas( let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; let tile_width = x1.saturating_sub(x0).max(1); let tile_height = y1.saturating_sub(y0).max(1); - let faces = slice_jump_hop_tile_uv_faces( - &source, - x0, - y0, - tile_width, - tile_height, - row, - col, - )?; + let faces = + slice_jump_hop_tile_uv_faces(&source, x0, y0, tile_width, tile_height, row, col)?; slices.push(JumpHopTileAtlasSlice { tile_type: jump_hop_tile_type_by_index(index), source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), @@ -1129,22 +1122,70 @@ pub(crate) fn slice_jump_hop_tile_uv_faces( Ok(JumpHopTileFaceSlices { top: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Top, + 1, + 0, )?, front: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Front, + 1, + 1, )?, right: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Right, + 2, + 1, )?, back: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Back, + 3, + 1, )?, left: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Left, + 0, + 1, )?, bottom: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Bottom, + 1, + 2, )?, }) } @@ -1182,12 +1223,7 @@ pub(crate) fn slice_jump_hop_tile_uv_face( Ok(JumpHopTileFaceSlice { face, - source_atlas_cell: format!( - "row-{}-col-{}/{}", - atlas_row + 1, - atlas_col + 1, - face_label - ), + source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label), bytes: cursor.into_inner(), }) } @@ -1200,8 +1236,8 @@ pub(crate) fn crop_jump_hop_tile_texture_cell( height: u32, ) -> image::DynamicImage { let min_side = width.min(height).max(1); - // 洋红去背已在切片前完成,内缩只需微调去掉可能的极边缘残留,不再承担主要去洋红职责。 - let safe_inset = (min_side / 64).clamp(1, 4); + // 洋红去背已在切片前完成;这里仅避开 UV 安全缝和极边缘残留,不按颜色透明化主体。 + let safe_inset = (min_side / 18).clamp(4, 18); let inset_x = safe_inset.min(width.saturating_sub(1) / 2); let inset_y = safe_inset.min(height.saturating_sub(1) / 2); let crop_width = width.saturating_sub(inset_x.saturating_mul(2)).max(1); @@ -1918,7 +1954,9 @@ mod tests { assert!(prompt.contains("第3行第2列:bottom")); assert!(prompt.contains("其余 6 个格子")); // 贴图内容 - assert!(prompt.contains("1x1x1 立方体物体的六面展开")); + assert!(prompt.contains("物体 -> 立方体 -> 展开")); + assert!(prompt.contains("1×1×1 的立方体")); + assert!(prompt.contains("经过方块化后的六面展开")); assert!(prompt.contains("主题为\"森林冒险\"")); assert!(prompt.contains("6 个面必须属于同一个物体")); assert!(prompt.contains("组合成一个完整的立方体造型")); @@ -1935,6 +1973,7 @@ mod tests { assert!(prompt.contains("不做透视渲染")); assert!(prompt.contains("不画投影、高光、倒角、侧壁厚度")); assert!(prompt.contains("大色块、高对比、粗线条和简单图形")); + assert!(prompt.contains("大小完全相同的正方形")); // 背景填充(最后一段) assert!(prompt.contains("【背景填充】")); assert!(prompt.contains("大单元格之间的间距")); @@ -2080,7 +2119,9 @@ mod tests { "科幻芯片主题的俯视角清爽游戏化立体感平台素材", ); - assert!(prompt.contains("具体内容为科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")); + assert!( + prompt.contains("具体内容为科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图") + ); assert!(!prompt.contains("俯视角清爽游戏化立体感平台素材")); assert!(!prompt.contains("俯视角")); @@ -2176,12 +2217,10 @@ mod tests { .max(1); let tile_x = atlas_col.saturating_mul(cell_width); let tile_y = atlas_row.saturating_mul(cell_height); - let uv_x = tile_x.saturating_add( - cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2, - ); - let uv_y = tile_y.saturating_add( - cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2, - ); + let uv_x = tile_x + .saturating_add(cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2); + let uv_y = tile_y + .saturating_add(cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2); for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side { for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side { atlas.put_pixel(x, y, color); @@ -2217,14 +2256,8 @@ mod tests { ), "{message}" ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == color), - "{message}" - ); - assert!( - decoded.pixels().all(|pixel| pixel.0[3] == 255), - "{message}" - ); + assert!(decoded.pixels().any(|pixel| pixel.0 == color), "{message}"); + assert!(decoded.pixels().all(|pixel| pixel.0[3] == 255), "{message}"); } #[test] @@ -2404,6 +2437,61 @@ mod tests { ); } + #[test] + fn jump_hop_tile_atlas_slicing_avoids_uv_gutter_edges() { + let width = 384; + let height = 576; + let mut atlas = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255])); + paint_test_uv_face(&mut atlas, 0, 0, 1, 0, image::Rgba([255, 0, 255, 255])); + + let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; + let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS; + let face_side = (cell_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(cell_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + let tile_x = 0; + let tile_y = 0; + let uv_x = tile_x + (cell_width - face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2; + let uv_y = tile_y + (cell_height - face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2; + let face_x = uv_x + face_side; + let face_y = uv_y; + let inset = 4; + for y in face_y + inset..face_y + face_side - inset { + for x in face_x + inset..face_x + face_side - inset { + atlas.put_pixel(x, y, image::Rgba([68, 186, 96, 255])); + } + } + + let image = load_test_png(encode_test_atlas(atlas)); + let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice"); + let top_tile = image::load_from_memory(slices[0].faces.top.bytes.as_slice()) + .expect("top tile should decode") + .to_rgba8(); + + for (x, y) in [ + (0, 0), + (JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE - 1, 0), + (0, JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE - 1), + ( + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE - 1, + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE - 1, + ), + ] { + assert_eq!( + top_tile.get_pixel(x, y).0, + [68, 186, 96, 255], + "UV 安全边不能被采样到输出面贴图角落" + ); + } + assert!( + top_tile + .pixels() + .all(|pixel| !is_jump_hop_tile_texture_key_pixel(*pixel)), + "输出面贴图不应残留接近洋红安全色的像素" + ); + } + #[test] fn jump_hop_tile_asset_slots_are_unique_for_eighteen_slices() { let slots = (0..JUMP_HOP_TILE_ITEM_COUNT) @@ -2522,14 +2610,12 @@ mod tests { // 在边缘区域故意填充一些洋红色像素,验证后续内缩裁切和关键色清除 let atlas_bytes = encode_test_atlas(atlas); - std::fs::write(output_root.join("00-raw-atlas.png"), &atlas_bytes) - .expect("保存原始图集"); + std::fs::write(output_root.join("00-raw-atlas.png"), &atlas_bytes).expect("保存原始图集"); let atlas_image = load_test_png(atlas_bytes); // === 阶段1: 图集切片(18 个 tile cell) === - let slices = - slice_jump_hop_tile_atlas(&atlas_image).expect("图集切片应该成功"); + let slices = slice_jump_hop_tile_atlas(&atlas_image).expect("图集切片应该成功"); assert_eq!( slices.len(), JUMP_HOP_TILE_ITEM_COUNT, @@ -2538,8 +2624,7 @@ mod tests { let mut total_faces = 0usize; let mut no_key_color_residue = true; - let expected_output_size = - JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE as u32; + let expected_output_size = JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE as u32; for (index, slice) in slices.iter().enumerate() { let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; @@ -2585,8 +2670,7 @@ mod tests { face.source_atlas_cell.replace('/', "-") ); let face_path = tile_dir.join(&face_filename); - std::fs::write(&face_path, &face.bytes) - .expect("保存面贴图"); + std::fs::write(&face_path, &face.bytes).expect("保存面贴图"); // 解码并验证 let decoded = image::load_from_memory(&face.bytes) @@ -2598,7 +2682,8 @@ mod tests { decoded.dimensions(), (expected_output_size, expected_output_size), "tile {index} face {face_name} 应该输出 {}×{}", - expected_output_size, expected_output_size + expected_output_size, + expected_output_size ); // 验证没有残余洋红关键色 @@ -2617,10 +2702,7 @@ mod tests { } assert_eq!(total_faces, JUMP_HOP_TILE_ITEM_COUNT * 6); - assert!( - no_key_color_residue, - "所有输出面贴图不应残留洋红关键色像素" - ); + assert!(no_key_color_residue, "所有输出面贴图不应残留洋红关键色像素"); // === 阶段2: 关键色检测算法验证 === // 纯洋红应被检测为关键色 @@ -2683,31 +2765,34 @@ mod tests { assert!(!negative_prompt.contains("规则圆盘")); // === 阶段5: prompt 清洗验证 === - let sanitized = sanitize_jump_hop_tile_prompt( - "宝可梦主题方块,皮卡丘风格可落脚平台素材", - ); + let sanitized = sanitize_jump_hop_tile_prompt("宝可梦主题方块,皮卡丘风格可落脚平台素材"); assert!(!sanitized.contains("宝可梦")); assert!(!sanitized.contains("皮卡丘")); assert!(sanitized.contains("原创幻想萌宠冒险道具")); assert!(sanitized.contains("黄色闪电萌宠符号")); assert!(sanitized.contains("立方体主题身份方块包装")); - let sanitized2 = sanitize_jump_hop_tile_prompt( - "水果主题,跳台和地板", - ); + let sanitized2 = sanitize_jump_hop_tile_prompt("水果主题,跳台和地板"); assert!(sanitized2.contains("立方体地板")); assert!(!sanitized2.contains("跳台")); // 打印摘要 - println!( - "\n====== 跳一跳图集切片测试完成 ======" - ); + println!("\n====== 跳一跳图集切片测试完成 ======"); println!("原始图集尺寸: {}×{}", width, height); - println!("大单元格数: {} ({}行×{}列)", JUMP_HOP_TILE_ITEM_COUNT, JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS); - println!("每格 UV 面网格: {}×{}", JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS); + println!( + "大单元格数: {} ({}行×{}列)", + JUMP_HOP_TILE_ITEM_COUNT, JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS + ); + println!( + "每格 UV 面网格: {}×{}", + JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS + ); println!("每格 face_side: {}px", face_side); println!("输出面贴图数量: {}", total_faces); - println!("输出面贴图尺寸: {}×{}", expected_output_size, expected_output_size); + println!( + "输出面贴图尺寸: {}×{}", + expected_output_size, expected_output_size + ); println!("无关键色残留: {}", no_key_color_residue); for (index, slice) in slices.iter().enumerate() { println!( @@ -2730,8 +2815,8 @@ mod tests { #[ignore] fn jump_hop_tile_atlas_ai_generation_pipeline() { use crate::jump_hop_atlas_slicing::{ - AtlasSliceAlgorithm, DEFAULT_TILE_COLS, DEFAULT_TILE_ROWS, - compute_col_density, compute_row_density, refine_boundaries_seed, + AtlasSliceAlgorithm, DEFAULT_TILE_COLS, DEFAULT_TILE_ROWS, compute_col_density, + compute_row_density, refine_boundaries_seed, }; let base_url = std::env::var("VECTOR_ENGINE_BASE_URL") @@ -2757,8 +2842,7 @@ mod tests { let theme_text = "水果"; let tile_prompt = "水果方块 UV 展开图集"; - let atlas_prompt = - build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); + let atlas_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); println!("\n====== 跳一跳 AI 图集生成与自适应切片对比测试 ======"); @@ -2781,22 +2865,19 @@ mod tests { api_key: api_key.clone(), request_timeout_ms: 180_000, }; - let http_client = - platform_image::build_vector_engine_image_http_client(&settings) - .expect("构建 HTTP 客户端"); + let http_client = platform_image::build_vector_engine_image_http_client(&settings) + .expect("构建 HTTP 客户端"); - let generation_result = rt.block_on( - platform_image::create_vector_engine_image_generation( - &http_client, - &settings, - &atlas_prompt, - Some(negative_prompt), - JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, - 1, - &[], - "跳一跳图集测试", - ), - ); + let generation_result = rt.block_on(platform_image::create_vector_engine_image_generation( + &http_client, + &settings, + &atlas_prompt, + Some(negative_prompt), + JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, + 1, + &[], + "跳一跳图集测试", + )); let generated = match generation_result { Ok(images) => { @@ -2806,9 +2887,16 @@ mod tests { Err(error) => panic!("VectorEngine 生图失败:{error}"), }; - let tile_image = generated.images.into_iter().next().expect("应该有生成的图片"); - std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes) - .expect("保存 AI 生成图集"); + let tile_image = generated + .images + .into_iter() + .next() + .expect("应该有生成的图片"); + std::fs::write( + output_root.join("01-ai-generated-atlas.png"), + &tile_image.bytes, + ) + .expect("保存 AI 生成图集"); let download_image = crate::openai_image_generation::DownloadedOpenAiImage { bytes: tile_image.bytes, @@ -2820,11 +2908,13 @@ mod tests { let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing( download_image, "跳一跳图集洋红去背测试失败", - ).expect("洋红去背应该成功"); + ) + .expect("洋红去背应该成功"); std::fs::write( output_root.join("01b-after-magenta-cleanup.png"), &cleaned.bytes, - ).expect("保存去背后图集"); + ) + .expect("保存去背后图集"); // 解码并计算 density,打印供对比 let source = image::load_from_memory(&cleaned.bytes) @@ -2858,7 +2948,8 @@ mod tests { DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS, algo, - ).expect(&format!("{algo_name} 切片应该成功")); + ) + .expect(&format!("{algo_name} 切片应该成功")); assert_eq!( slices.len(), @@ -2869,23 +2960,33 @@ mod tests { // 保存 density 数据供离线分析 std::fs::write( algo_dir.join("row_density.csv"), - row_density.iter().enumerate() + row_density + .iter() + .enumerate() .map(|(i, v)| format!("{i},{v:.6}")) .collect::>() .join("\n"), - ).expect("保存行 density"); + ) + .expect("保存行 density"); std::fs::write( algo_dir.join("col_density.csv"), - col_density.iter().enumerate() + col_density + .iter() + .enumerate() .map(|(i, v)| format!("{i},{v:.6}")) .collect::>() .join("\n"), - ).expect("保存列 density"); + ) + .expect("保存列 density"); // 保存行投影可视化(ASCII 柱状图) let mut row_viz = String::from("row_density:\n"); - let max_d = row_density.iter().cloned().fold(0.0f32, f32::max).max(0.001); + let max_d = row_density + .iter() + .cloned() + .fold(0.0f32, f32::max) + .max(0.001); for (y, &d) in row_density.iter().enumerate() { let bar = (d / max_d * 60.0) as usize; row_viz.push_str(&format!("{:4} |{}\n", y, "#".repeat(bar))); @@ -2893,7 +2994,11 @@ mod tests { std::fs::write(algo_dir.join("row_density_viz.txt"), &row_viz).ok(); let mut col_viz = String::from("col_density:\n"); - let max_c = col_density.iter().cloned().fold(0.0f32, f32::max).max(0.001); + let max_c = col_density + .iter() + .cloned() + .fold(0.0f32, f32::max) + .max(0.001); for (x, &d) in col_density.iter().enumerate() { let bar = (d / max_c * 60.0) as usize; col_viz.push_str(&format!("{:4} |{}\n", x, "#".repeat(bar))); @@ -2904,20 +3009,34 @@ mod tests { println!("\n固定网格种子位置:"); let cell_h = (height / DEFAULT_TILE_ROWS).max(1); let cell_w = (width / DEFAULT_TILE_COLS).max(1); - let row_seeds: Vec<_> = (1..DEFAULT_TILE_ROWS).map(|i| i * height / DEFAULT_TILE_ROWS).collect(); - let col_seeds: Vec<_> = (1..DEFAULT_TILE_COLS).map(|i| i * width / DEFAULT_TILE_COLS).collect(); + let row_seeds: Vec<_> = (1..DEFAULT_TILE_ROWS) + .map(|i| i * height / DEFAULT_TILE_ROWS) + .collect(); + let col_seeds: Vec<_> = (1..DEFAULT_TILE_COLS) + .map(|i| i * width / DEFAULT_TILE_COLS) + .collect(); println!(" 行种子: {:?}", row_seeds); println!(" 列种子: {:?}", col_seeds); // 精修后的位置(用种子点精修展示偏移量) - let refined_rows = refine_boundaries_seed(&row_density, &row_seeds, (cell_h / 3).max(1)); - let refined_cols = refine_boundaries_seed(&col_density, &col_seeds, (cell_w / 3).max(1)); + let refined_rows = + refine_boundaries_seed(&row_density, &row_seeds, (cell_h / 3).max(1)); + let refined_cols = + refine_boundaries_seed(&col_density, &col_seeds, (cell_w / 3).max(1)); println!("\n种子点精修偏移:"); for (i, (&seed, &refined)) in row_seeds.iter().zip(refined_rows.iter()).enumerate() { - println!(" 行边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32); + println!( + " 行边界 {}: seed={seed} → refined={refined} (偏移 {})", + i + 1, + refined as i32 - seed as i32 + ); } for (i, (&seed, &refined)) in col_seeds.iter().zip(refined_cols.iter()).enumerate() { - println!(" 列边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32); + println!( + " 列边界 {}: seed={seed} → refined={refined} (偏移 {})", + i + 1, + refined as i32 - seed as i32 + ); } // 保存面贴图 @@ -2927,7 +3046,10 @@ mod tests { let c = index as u32 % DEFAULT_TILE_COLS; let tile_dir = algo_dir.join(format!( "tile-{:02}-{:?}-row{}-col{}", - index + 1, slice.tile_type, r + 1, c + 1 + index + 1, + slice.tile_type, + r + 1, + c + 1 )); std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录"); @@ -2946,8 +3068,7 @@ mod tests { face_name, face.source_atlas_cell.replace('/', "-") ); - std::fs::write(tile_dir.join(&filename), &face.bytes) - .expect("保存面贴图"); + std::fs::write(tile_dir.join(&filename), &face.bytes).expect("保存面贴图"); } } println!("{algo_name}: 输出 {total_faces} 张面贴图"); @@ -2958,13 +3079,30 @@ mod tests { let grid = match algo { AtlasSliceAlgorithm::SeedRefinement => { crate::jump_hop_atlas_slicing::detect_cell_grid_seed( - pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS) + pixels, + width, + height, + DEFAULT_TILE_ROWS, + DEFAULT_TILE_COLS, + ) } AtlasSliceAlgorithm::ValleyDetection => { crate::jump_hop_atlas_slicing::detect_cell_grid_valley( - pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS) - .unwrap_or_else(|_| crate::jump_hop_atlas_slicing::detect_cell_grid_seed( - pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS)) + pixels, + width, + height, + DEFAULT_TILE_ROWS, + DEFAULT_TILE_COLS, + ) + .unwrap_or_else(|_| { + crate::jump_hop_atlas_slicing::detect_cell_grid_seed( + pixels, + width, + height, + DEFAULT_TILE_ROWS, + DEFAULT_TILE_COLS, + ) + }) } }; for row in 0..DEFAULT_TILE_ROWS { @@ -2973,11 +3111,11 @@ mod tests { let x1 = grid.col_boundaries[col as usize + 1]; let y0 = grid.row_boundaries[row as usize]; let y1 = grid.row_boundaries[row as usize + 1]; - let cell_img = image::imageops::crop_imm( - &source, x0, y0, x1 - x0, y1 - y0); - cell_img.to_image().save( - cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1)) - ).expect("保存 cell 切图"); + let cell_img = image::imageops::crop_imm(&source, x0, y0, x1 - x0, y1 - y0); + cell_img + .to_image() + .save(cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1))) + .expect("保存 cell 切图"); } } println!("{algo_name}: 保存 {DEFAULT_TILE_ROWS}×{DEFAULT_TILE_COLS} cell 网格切图"); @@ -2986,7 +3124,10 @@ mod tests { println!("\n====== 对比测试完成 ======"); println!("输出目录: {}", output_root.display()); println!(" seed 算法结果: {}/02-slices-seed/", output_root.display()); - println!(" valley 算法结果: {}/02-slices-valley/", output_root.display()); + println!( + " valley 算法结果: {}/02-slices-valley/", + output_root.display() + ); println!(" density 数据: 各算法目录下的 row_density.csv / col_density.csv"); println!("==============================\n"); } @@ -3021,8 +3162,7 @@ mod tests { let theme_text = "水果"; let tile_prompt = "水果方块 UV 展开图集"; - let atlas_prompt = - build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); + let atlas_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); println!("\n====== 跳一跳固定网格 AI 生图测试 ======"); @@ -3044,22 +3184,19 @@ mod tests { api_key: api_key.clone(), request_timeout_ms: 180_000, }; - let http_client = - platform_image::build_vector_engine_image_http_client(&settings) - .expect("构建 HTTP 客户端"); + let http_client = platform_image::build_vector_engine_image_http_client(&settings) + .expect("构建 HTTP 客户端"); - let generation_result = rt.block_on( - platform_image::create_vector_engine_image_generation( - &http_client, - &settings, - &atlas_prompt, - Some(negative_prompt), - JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, - 1, - &[], - "跳一跳图集测试(固定网格)", - ), - ); + let generation_result = rt.block_on(platform_image::create_vector_engine_image_generation( + &http_client, + &settings, + &atlas_prompt, + Some(negative_prompt), + JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, + 1, + &[], + "跳一跳图集测试(固定网格)", + )); let generated = match generation_result { Ok(images) => { @@ -3069,9 +3206,16 @@ mod tests { Err(error) => panic!("VectorEngine 生图失败:{error}"), }; - let tile_image = generated.images.into_iter().next().expect("应该有生成的图片"); - std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes) - .expect("保存 AI 生成图集"); + let tile_image = generated + .images + .into_iter() + .next() + .expect("应该有生成的图片"); + std::fs::write( + output_root.join("01-ai-generated-atlas.png"), + &tile_image.bytes, + ) + .expect("保存 AI 生成图集"); let download_image = crate::openai_image_generation::DownloadedOpenAiImage { bytes: tile_image.bytes, @@ -3083,11 +3227,13 @@ mod tests { let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing( download_image, "跳一跳图集洋红去背测试失败", - ).expect("洋红去背应该成功"); + ) + .expect("洋红去背应该成功"); std::fs::write( output_root.join("02-after-magenta-cleanup.png"), &cleaned.bytes, - ).expect("保存去背后图集"); + ) + .expect("保存去背后图集"); let source = image::load_from_memory(&cleaned.bytes) .expect("解码") @@ -3100,8 +3246,7 @@ mod tests { let cell_h = source.height() / JUMP_HOP_TILE_ATLAS_ROWS; println!("固定 cell: {}×{} px", cell_w, cell_h); - let slices = slice_jump_hop_tile_atlas(&cleaned) - .expect("固定网格切片应该成功"); + let slices = slice_jump_hop_tile_atlas(&cleaned).expect("固定网格切片应该成功"); assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT); let mut total_faces = 0usize; @@ -3110,7 +3255,10 @@ mod tests { let c = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; let tile_dir = output_root.join(format!( "tile-{:02}-{:?}-row{}-col{}", - index + 1, slice.tile_type, r + 1, c + 1 + index + 1, + slice.tile_type, + r + 1, + c + 1 )); std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录"); @@ -3129,8 +3277,7 @@ mod tests { face_name, face.source_atlas_cell.replace('/', "-") ); - std::fs::write(tile_dir.join(&filename), &face.bytes) - .expect("保存面贴图"); + std::fs::write(tile_dir.join(&filename), &face.bytes).expect("保存面贴图"); } } @@ -3147,12 +3294,16 @@ mod tests { let y0 = row * source.height() / JUMP_HOP_TILE_ATLAS_ROWS; let y1 = (row + 1) * source.height() / JUMP_HOP_TILE_ATLAS_ROWS; let cell_img = image::imageops::crop_imm(&source, x0, y0, x1 - x0, y1 - y0); - cell_img.to_image().save( - cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1)) - ).expect("保存 cell 切图"); + cell_img + .to_image() + .save(cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1))) + .expect("保存 cell 切图"); } } - println!("保存 {}×{} cell 网格切图", JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS); + println!( + "保存 {}×{} cell 网格切图", + JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS + ); println!("\n====== 固定网格测试完成 ======"); println!("输出目录: {}", output_root.display()); diff --git a/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs index 59795be9a..94cf2b68f 100644 --- a/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs +++ b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs @@ -819,7 +819,8 @@ fn max_opaque_rect( let area = sh * (x - sx); if area > best_area { best_area = area; - best = (bx0 + sx, by0 + ly - sh + 1, x - sx, sh); + let top = by0 + ly.saturating_add(1).saturating_sub(sh); + best = (bx0 + sx, top, x - sx, sh); } start = sx; } @@ -832,13 +833,35 @@ fn max_opaque_rect( let area = sh * (x - sx); if area > best_area { best_area = area; - best = (bx0 + sx, by0 + ly as u32 - sh + 1, x - sx, sh); + let top = by0 + ly.saturating_add(1).saturating_sub(sh); + best = (bx0 + sx, top, x - sx, sh); } } } if best_area == 0 { None } else { Some(best) } } +#[cfg(test)] +mod tests { + use image::{Rgba, RgbaImage}; + + use super::max_opaque_rect; + + #[test] + fn max_opaque_rect_handles_content_touching_top_edge() { + let mut image = RgbaImage::from_pixel(4, 3, Rgba([0, 0, 0, 0])); + for y in 0..2 { + for x in 0..3 { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + + let rect = max_opaque_rect(&image, 0, 0, 4, 3).expect("应该能识别贴顶矩形"); + + assert_eq!(rect, (0, 0, 3, 2)); + } +} + // ---- 4. 主编排 ---- fn slice_jump_hop_tile_uv_faces_blob( diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs index bce39499a..6f9b7243a 100644 --- a/server-rs/crates/module-jump-hop/src/application.rs +++ b/server-rs/crates/module-jump-hop/src/application.rs @@ -7,8 +7,9 @@ use crate::{ const JUMP_HOP_PLATFORM_SIZE_MULTIPLIER: f32 = 2.0; const JUMP_HOP_CHARGE_TO_DISTANCE_RATIO: f32 = 0.004; -const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO: f32 = 0.72; -const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO: f32 = 0.52; +// 中文注释:命中区必须与视觉顶面一致,禁止再做隐藏收缩。 +const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO: f32 = 1.0; +const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO: f32 = 1.0; pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath { let config = difficulty_config(difficulty); @@ -64,8 +65,8 @@ pub fn start_run( pub fn apply_jump( run: &JumpHopRunSnapshot, drag_distance: f32, - drag_vector_x: Option, - drag_vector_y: Option, + _drag_vector_x: Option, + _drag_vector_y: Option, jumped_at_ms: u64, ) -> Result { if run.status != JumpHopRunStatus::Playing { @@ -85,17 +86,15 @@ pub fn apply_jump( .ok_or(JumpHopError::NoNextPlatform)?; let capped_drag_distance = drag_distance.clamp(0.0, run.path.scoring.max_charge_ms as f32); let jump_distance = capped_drag_distance * run.path.scoring.charge_to_distance_ratio; - let vector_x = target.x - current.x; - let vector_y = target.y - current.y; + let (origin_x, origin_y) = current_jump_origin(run, current); + let vector_x = target.x - origin_x; + let vector_y = target.y - origin_y; let target_distance = vector_x.hypot(vector_y).max(0.0001); - let (unit_x, unit_y) = normalize_jump_direction( - drag_vector_x, - drag_vector_y, - vector_x / target_distance, - vector_y / target_distance, - ); - let landed_x = current.x + unit_x * jump_distance; - let landed_y = current.y + unit_y * jump_distance; + // 中文注释:规则真相只认角色当前脚点到下一块顶面中心,拖拽方向不参与裁决。 + let unit_x = vector_x / target_distance; + let unit_y = vector_y / target_distance; + let landed_x = origin_x + unit_x * jump_distance; + let landed_y = origin_y + unit_y * jump_distance; let landed_on_target = is_landing_inside_platform_footprint(target, landed_x, landed_y); let mut next = run.clone(); @@ -129,6 +128,22 @@ pub fn apply_jump( Ok(next) } +fn current_jump_origin(run: &JumpHopRunSnapshot, current: &JumpHopPlatform) -> (f32, f32) { + if let Some(last_jump) = &run.last_jump { + let landed_on_current = last_jump.target_platform_index == run.current_platform_index; + let is_successful = last_jump.result != JumpHopJumpResultKind::Miss; + if landed_on_current + && is_successful + && last_jump.landed_x.is_finite() + && last_jump.landed_y.is_finite() + { + return (last_jump.landed_x, last_jump.landed_y); + } + } + + (current.x, current.y) +} + fn is_landing_inside_platform_footprint( platform: &JumpHopPlatform, landed_x: f32, @@ -136,33 +151,13 @@ fn is_landing_inside_platform_footprint( ) -> bool { let half_width = (platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO).max(0.0); let half_height = (platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO).max(0.0); + if half_width <= f32::EPSILON || half_height <= f32::EPSILON { + return false; + } let error_x = landed_x - platform.x; let error_y = landed_y - platform.y; - error_x.abs() <= half_width && error_y.abs() <= half_height -} - -fn normalize_jump_direction( - drag_vector_x: Option, - drag_vector_y: Option, - fallback_x: f32, - fallback_y: f32, -) -> (f32, f32) { - let Some(drag_x) = drag_vector_x.filter(|value| value.is_finite()) else { - return (fallback_x, fallback_y); - }; - let Some(drag_y) = drag_vector_y.filter(|value| value.is_finite()) else { - return (fallback_x, fallback_y); - }; - // 前端提交屏幕拖拽向量:x 轴同向,y 轴向下为正;真实起跳反向弹出,世界 y 向上为正。 - let jump_x = -drag_x; - let jump_y = drag_y; - let length = jump_x.hypot(jump_y); - if length < 0.0001 { - (fallback_x, fallback_y) - } else { - (jump_x / length, jump_y / length) - } + error_x.abs() / half_width + error_y.abs() / half_height <= 1.0 + f32::EPSILON } pub fn restart_run( @@ -214,6 +209,8 @@ struct DifficultyConfig { max_charge_ms: u32, } +const JUMP_HOP_MIN_GAP_RATIO_OF_MAX: f32 = 0.55; + fn build_platforms_until( seed: &str, difficulty: JumpHopDifficulty, @@ -229,9 +226,8 @@ fn build_platforms_until( if index + 1 < required_count { let mut rng = DeterministicRng::new(seed, &format!("{}:{index}", difficulty.as_str())); let distance = rng.range_f32(config.min_gap, config.max_gap); - let lane = rng.range_f32(0.42, 0.86); let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 }; - x += distance * lane * direction; + x += distance * direction; y += distance; } } @@ -290,7 +286,7 @@ fn extend_jump_hop_path(mut path: JumpHopPath, required_count: usize) -> JumpHop fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { match difficulty { JumpHopDifficulty::Easy => DifficultyConfig { - min_gap: 1.0, + min_gap: 1.45 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX, max_gap: 1.45, min_width: 0.9, max_width: 1.08, @@ -300,7 +296,7 @@ fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { max_charge_ms: 700, }, JumpHopDifficulty::Standard => DifficultyConfig { - min_gap: 1.22, + min_gap: 1.78 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX, max_gap: 1.78, min_width: 0.82, max_width: 1.0, @@ -310,7 +306,7 @@ fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { max_charge_ms: 780, }, JumpHopDifficulty::Advanced => DifficultyConfig { - min_gap: 1.45, + min_gap: 2.05 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX, max_gap: 2.05, min_width: 0.72, max_width: 0.94, @@ -320,7 +316,7 @@ fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { max_charge_ms: 860, }, JumpHopDifficulty::Challenge => DifficultyConfig { - min_gap: 1.7, + min_gap: 2.35 * JUMP_HOP_MIN_GAP_RATIO_OF_MAX, max_gap: 2.35, min_width: 0.66, max_width: 0.88, @@ -383,6 +379,89 @@ mod tests { assert_eq!(first.finish_index, u32::MAX); } + #[test] + fn generated_platforms_are_locked_to_positive_or_negative_45_degree_lanes() { + for difficulty in [ + JumpHopDifficulty::Easy, + JumpHopDifficulty::Standard, + JumpHopDifficulty::Advanced, + JumpHopDifficulty::Challenge, + ] { + let path = generate_jump_hop_path("seed-45-degree", difficulty); + + for pair in path.platforms.windows(2) { + let current = &pair[0]; + let next = &pair[1]; + let dx = next.x - current.x; + let dy = next.y - current.y; + + assert!( + dy > 0.0, + "next platform should always move forward for {difficulty:?}", + ); + assert!( + (dx.abs() - dy).abs() < 0.0001, + "next platform should stay on a 45 degree lane for {difficulty:?}: dx={dx}, dy={dy}", + ); + } + } + } + + #[test] + fn generated_platform_gaps_use_current_spacing_as_max_and_nonzero_min() { + for difficulty in [ + JumpHopDifficulty::Easy, + JumpHopDifficulty::Standard, + JumpHopDifficulty::Advanced, + JumpHopDifficulty::Challenge, + ] { + let config = super::difficulty_config(difficulty); + let path = generate_jump_hop_path("seed-random-gap-range", difficulty); + + for pair in path.platforms.windows(2) { + let current = &pair[0]; + let next = &pair[1]; + let gap = next.y - current.y; + + assert!( + gap >= config.max_gap * super::JUMP_HOP_MIN_GAP_RATIO_OF_MAX - 0.0001, + "gap should keep a non-zero minimum for {difficulty:?}: gap={gap}, max={}", + config.max_gap, + ); + assert!( + gap <= config.max_gap + 0.0001, + "gap should not exceed the current max spacing for {difficulty:?}: gap={gap}, max={}", + config.max_gap, + ); + } + } + } + + #[test] + fn generated_platform_centers_are_reachable_within_charge_window() { + for difficulty in [ + JumpHopDifficulty::Easy, + JumpHopDifficulty::Standard, + JumpHopDifficulty::Advanced, + JumpHopDifficulty::Challenge, + ] { + let path = generate_jump_hop_path("seed-reachable-centers", difficulty); + + for pair in path.platforms.windows(2) { + let current = &pair[0]; + let next = &pair[1]; + let distance = (next.x - current.x).hypot(next.y - current.y); + let required_charge = distance / path.scoring.charge_to_distance_ratio; + + assert!( + required_charge <= path.scoring.max_charge_ms as f32, + "next platform center must be reachable for {difficulty:?}: required={required_charge}, max={}", + path.scoring.max_charge_ms, + ); + } + } + } + #[test] fn difficulty_charge_to_distance_ratio_is_reduced_for_long_press() { let easy = generate_jump_hop_path("seed-ratio-easy", JumpHopDifficulty::Easy); @@ -477,7 +556,7 @@ mod tests { } #[test] - fn jump_resolution_uses_client_drag_direction_for_landing() { + fn jump_resolution_ignores_client_drag_direction_and_targets_next_center() { let path = generate_jump_hop_path("seed-screen-axis", JumpHopDifficulty::Easy); let run = start_run( "run-screen-axis".to_string(), @@ -495,11 +574,12 @@ mod tests { let result = apply_jump(&run, charge as f32, Some(999.0), Some(-999.0), 200) .expect("jump should resolve"); - assert_eq!(result.status, JumpHopRunStatus::Failed); + assert_eq!(result.status, JumpHopRunStatus::Playing); assert_eq!( result.last_jump.as_ref().unwrap().result, - JumpHopJumpResultKind::Miss + JumpHopJumpResultKind::Hit ); + assert_eq!(result.current_platform_index, 1); } #[test] @@ -528,11 +608,52 @@ mod tests { assert!((last_jump.landed_y - target.y).abs() < target.landing_radius); } + #[test] + fn jump_resolution_uses_previous_landing_point_as_next_origin() { + let mut path = generate_jump_hop_path("seed-foot-origin", JumpHopDifficulty::Easy); + path.platforms[0] = test_platform("p0", 0.0, 0.0, 2.0, 2.0); + path.platforms[1] = test_platform("p1", 1.0, 0.0, 2.0, 2.0); + path.platforms[2] = test_platform("p2", 2.0, 1.0, 2.0, 2.0); + let run = start_run( + "run-foot-origin".to_string(), + "user-foot-origin".to_string(), + "profile-foot-origin".to_string(), + path, + 100, + ) + .expect("run should start"); + + let first_charge = 0.9 / run.path.scoring.charge_to_distance_ratio; + let first = + apply_jump(&run, first_charge, None, None, 200).expect("first jump should resolve"); + let first_jump = first.last_jump.as_ref().expect("first jump should exist"); + assert_eq!(first.status, JumpHopRunStatus::Playing); + assert_eq!(first.current_platform_index, 1); + assert_eq!(first_jump.result, JumpHopJumpResultKind::Hit); + assert!((first_jump.landed_x - 0.9).abs() < 0.0001); + + let target = &first.path.platforms[2]; + let second_origin = first.last_jump.as_ref().expect("origin should exist"); + let second_distance = + (target.x - second_origin.landed_x).hypot(target.y - second_origin.landed_y); + let second_charge = second_distance / first.path.scoring.charge_to_distance_ratio; + let second = + apply_jump(&first, second_charge, None, None, 300).expect("second jump should resolve"); + let second_jump = second.last_jump.as_ref().expect("second jump should exist"); + + assert_eq!(second.status, JumpHopRunStatus::Playing); + assert_eq!(second.current_platform_index, 2); + assert_eq!(second_jump.result, JumpHopJumpResultKind::Hit); + assert!((second_jump.landed_x - target.x).abs() < 0.0001); + assert!((second_jump.landed_y - target.y).abs() < 0.0001); + } + #[test] fn jump_resolution_uses_visual_top_face_footprint_instead_of_landing_radius() { let mut path = generate_jump_hop_path("seed-footprint", JumpHopDifficulty::Easy); path.platforms[0] = test_platform("p0", 0.0, 0.0, 1.2, 1.0); path.platforms[1] = test_platform("p1", 1.0, 0.0, 2.0, 0.6); + path.platforms[1].landing_radius = 10.0; path.scoring.max_charge_ms = 600; let run = start_run( "run-footprint".to_string(), @@ -543,16 +664,16 @@ mod tests { ) .expect("run should start"); - let edge_hit_charge = 1.6 / run.path.scoring.charge_to_distance_ratio; + let edge_hit_charge = 1.99 / run.path.scoring.charge_to_distance_ratio; let edge_hit = apply_jump(&run, edge_hit_charge, None, None, 200).expect("jump should resolve"); let last_hit = edge_hit.last_jump.as_ref().expect("last jump should exist"); assert_eq!(edge_hit.status, JumpHopRunStatus::Playing); assert_eq!(last_hit.result, JumpHopJumpResultKind::Hit); - assert!(last_hit.landed_x > 1.5); - assert!(last_hit.landed_x <= 1.72); + assert!(last_hit.landed_x > 1.98); + assert!(last_hit.landed_x <= 2.0); - let outside_charge = 1.8 / run.path.scoring.charge_to_distance_ratio; + let outside_charge = 2.01 / run.path.scoring.charge_to_distance_ratio; let outside = apply_jump(&run, outside_charge, None, None, 200).expect("jump should resolve"); assert_eq!(outside.status, JumpHopRunStatus::Failed); @@ -562,6 +683,33 @@ mod tests { ); } + #[test] + fn top_face_footprint_rejects_rectangle_corners_outside_visible_top() { + let platform = test_platform("p-corner", 0.0, 0.0, 2.0, 2.0); + + assert!(super::is_landing_inside_platform_footprint( + &platform, 0.5, 0.5, + )); + assert!(!super::is_landing_inside_platform_footprint( + &platform, 0.7, 0.5, + )); + assert!(super::is_landing_inside_platform_footprint( + &test_platform("p-diagonal", 0.8, 1.2, 2.0, 2.0), + 1.3, + 1.6, + )); + assert!(!super::is_landing_inside_platform_footprint( + &test_platform("p-diagonal-outside", 0.8, 1.2, 2.0, 2.0), + 1.4, + 1.8, + )); + assert!(super::is_landing_inside_platform_footprint( + &test_platform("p-side", 0.8, 1.2, 2.0, 2.0), + -0.19, + 1.2, + )); + } + #[test] fn restart_returns_to_first_platform_and_playing_state() { let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy); diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_interaction_config_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_interaction_config_admin_upsert_input_type.rs new file mode 100644 index 000000000..3f76cc6b8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_interaction_config_admin_upsert_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PublicWorkInteractionConfigAdminUpsertInput { + pub public_work_interactions_json: String, +} + +impl __sdk::InModule for PublicWorkInteractionConfigAdminUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_public_work_interaction_config_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_public_work_interaction_config_procedure.rs new file mode 100644 index 000000000..88e88d690 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_public_work_interaction_config_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::creation_entry_config_procedure_result_type::CreationEntryConfigProcedureResult; +use super::public_work_interaction_config_admin_upsert_input_type::PublicWorkInteractionConfigAdminUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertPublicWorkInteractionConfigArgs { + pub input: PublicWorkInteractionConfigAdminUpsertInput, +} + +impl __sdk::InModule for UpsertPublicWorkInteractionConfigArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_public_work_interaction_config`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_public_work_interaction_config { + fn upsert_public_work_interaction_config( + &self, + input: PublicWorkInteractionConfigAdminUpsertInput, + ) { + self.upsert_public_work_interaction_config_then(input, |_, _| {}); + } + + fn upsert_public_work_interaction_config_then( + &self, + input: PublicWorkInteractionConfigAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_public_work_interaction_config for super::RemoteProcedures { + fn upsert_public_work_interaction_config_then( + &self, + input: PublicWorkInteractionConfigAdminUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreationEntryConfigProcedureResult>( + "upsert_public_work_interaction_config", + UpsertPublicWorkInteractionConfigArgs { input }, + __callback, + ); + } +} diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx index 3d5e3c681..c0e0d8e52 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx @@ -11,9 +11,22 @@ import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard'; import { JUMP_HOP_THREE_CAMERA_UP_Y, + JUMP_HOP_THREE_CHARACTER_RENDER_ORDER, + JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT, + JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE, + JUMP_HOP_THREE_PLATFORM_YAW_RAD, + JUMP_HOP_THREE_MATERIAL_FACE_ORDER, + JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET, + JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER, + JUMP_HOP_THREE_TEXTURE_TRANSFORMS, JumpHopRuntimeShell, + getJumpHopThreeCharacterFootZ, + getJumpHopThreeCubeSide, getJumpHopThreeProjectedY, + getJumpHopThreeWorldYForScreenY, getJumpHopTileTextureSignature, + hasCompleteJumpHopTileFaceAssets, + resolveJumpHopThreeCharacterFrame, } from './JumpHopRuntimeShell'; vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ @@ -91,16 +104,14 @@ test('跳一跳运行态松手时提交长按蓄力值和后端方向向量', as expect(onJump).toHaveBeenCalledTimes(1); const jumpPayload = onJump.mock.calls[0]?.[0]; - expect(typeof jumpPayload?.dragVectorX).toBe('number'); - expect(typeof jumpPayload?.dragVectorY).toBe('number'); - expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true); - expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true); + expect(jumpPayload).not.toHaveProperty('dragVectorX'); + expect(jumpPayload).not.toHaveProperty('dragVectorY'); expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(360); expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(380); vi.useRealTimers(); }); -test('跳一跳运行态手指移动不改变蓄力时长但仍提交方向向量', async () => { +test('跳一跳运行态手指移动不改变蓄力时长且不提交方向向量', async () => { vi.useFakeTimers(); const onJump = vi.fn().mockResolvedValue(undefined); const run = buildRun(); @@ -141,10 +152,8 @@ test('跳一跳运行态手指移动不改变蓄力时长但仍提交方向向 }); const jumpPayload = onJump.mock.calls[0]?.[0]; - expect(typeof jumpPayload?.dragVectorX).toBe('number'); - expect(typeof jumpPayload?.dragVectorY).toBe('number'); - expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true); - expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true); + expect(jumpPayload).not.toHaveProperty('dragVectorX'); + expect(jumpPayload).not.toHaveProperty('dragVectorY'); expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240); expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260); vi.useRealTimers(); @@ -448,49 +457,133 @@ test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', () expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284); }); -test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => { - const onJump = vi.fn().mockResolvedValue(undefined); +test('跳一跳 Three.js 顶面脚点投影会扣除立方体高度偏移', () => { + const projectedTopY = getJumpHopThreeProjectedY(360, 568); + const cubeTopWorldY = getJumpHopThreeWorldYForScreenY(360, 568, 48); - render( - {}} - />, + expect(cubeTopWorldY).toBeLessThan(projectedTopY); + expect(getJumpHopThreeWorldYForScreenY(360, 568, 0)).toBe(projectedTopY); +}); + +test('跳一跳 Three.js 方块只放大模型尺寸不改变屏幕间距规则', () => { + const run = buildRun(); + const platform = run.path.platforms[0]!; + + expect(JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER).toBe(2); + expect(getJumpHopThreeCubeSide(platform, 1)).toBeCloseTo(165.12, 2); + expect(run.path.platforms[1]!.x - run.path.platforms[0]!.x).toBeCloseTo( + 1.78, ); + expect(run.path.platforms[1]!.y - run.path.platforms[0]!.y).toBeCloseTo( + 1.78, + ); +}); - const stage = screen.getByTestId('jump-hop-stage'); - await act(async () => { - dispatchPointerEvent(stage, 'pointerdown', { - pointerId: 1, - clientX: 180, - clientY: 420, - }); +test('跳一跳 Three.js 角色帧沿真实预测落点插值并保留飞行弧线', () => { + const from = { + screenX: 50, + screenY: 64, + sceneX: 0, + sceneY: 0.84, + sceneZ: 0, + isMiss: false, + }; + const to = { + screenX: 64, + screenY: 47, + sceneX: 1.1, + sceneY: 0.84, + sceneZ: 3.4, + isMiss: true, + }; + + const midFrame = resolveJumpHopThreeCharacterFrame({ + characterPosition: null, + visualJump: { from, to }, + jumpAnimationProgress: 0.5, + isJumpAnimating: true, }); - await act(async () => { - dispatchPointerEvent(stage, 'pointermove', { - pointerId: 1, - clientX: 148, - clientY: 454, - }); + expect(midFrame?.progress).toBe(0.5); + expect(midFrame?.position.screenX).toBeGreaterThan(from.screenX); + expect(midFrame?.position.screenX).toBeLessThan(to.screenX); + expect(midFrame?.position.screenY).toBeLessThan(to.screenY); + expect(midFrame?.position.sceneZ).toBeGreaterThan(from.sceneZ); + expect(midFrame?.position.sceneZ).toBeLessThan(to.sceneZ); + expect(midFrame?.position.isMiss).toBe(true); + + const landedFrame = resolveJumpHopThreeCharacterFrame({ + characterPosition: from, + visualJump: { from, to }, + jumpAnimationProgress: 2, + isJumpAnimating: true, }); + expect(landedFrame?.progress).toBe(1); + expect(landedFrame?.position).toMatchObject(to); - expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); - expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); - - await act(async () => { - dispatchPointerEvent(stage, 'pointermove', { - pointerId: 1, - clientX: 112, - clientY: 492, - }); + expect( + resolveJumpHopThreeCharacterFrame({ + characterPosition: from, + visualJump: { from, to }, + jumpAnimationProgress: 0.5, + isJumpAnimating: false, + }), + ).toEqual({ + position: from, + progress: 1, }); +}); - expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull(); - expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); - expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull(); +test('跳一跳蓄力时实时显示松手后的预测落点', async () => { + vi.useFakeTimers(); + try { + const onJump = vi.fn().mockResolvedValue(undefined); + + render( + {}} + />, + ); + + const stage = screen.getByTestId('jump-hop-stage'); + await act(async () => { + dispatchPointerEvent(stage, 'pointerdown', { + pointerId: 1, + clientX: 180, + clientY: 420, + }); + }); + + await act(async () => { + vi.advanceTimersByTime(240); + }); + + const firstAssist = screen.getByTestId('jump-hop-landing-assist'); + expect(firstAssist).toBeTruthy(); + expect(firstAssist.getAttribute('data-on-target')).toMatch(/^(true|false)$/); + expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); + const firstLeft = firstAssist.style.getPropertyValue('--jump-hop-landing-x'); + const firstTop = firstAssist.style.getPropertyValue('--jump-hop-landing-y'); + + await act(async () => { + vi.advanceTimersByTime(260); + }); + + const secondAssist = screen.getByTestId('jump-hop-landing-assist'); + expect(secondAssist.style.getPropertyValue('--jump-hop-landing-x')).not.toBe( + firstLeft, + ); + expect(secondAssist.style.getPropertyValue('--jump-hop-landing-y')).not.toBe( + firstTop, + ); + expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy(); + expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull(); + } finally { + vi.useRealTimers(); + } }); test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => { @@ -504,14 +597,14 @@ test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => { ); const tileImages = screen.getAllByTestId('jump-hop-tile-image'); - expect(tileImages).toHaveLength(3); + expect(tileImages).toHaveLength(2); expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull(); const generatedReadUrlCalls = vi .mocked(useResolvedAssetReadUrl) .mock.calls.filter(([source]) => source?.includes('/generated-jump-hop-assets/'), ); - expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3); + expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(2); for (const [, options] of generatedReadUrlCalls) { expect(options).toEqual( expect.objectContaining({ @@ -539,7 +632,7 @@ test('跳一跳运行态提前预加载下一屏地块且不在真实图片加 />, ); - expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(3); + expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(2); expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull(); const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image'); expect(preloadImages.length).toBeGreaterThan(0); @@ -613,7 +706,63 @@ test('跳一跳 Three.js 地板贴图签名包含六面贴图 URL', () => { expect(signature).toContain('bottom-url'); }); -test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => { +test('跳一跳 Three 立方体按玩法 Z 轴顶面映射 UV 六面贴图', () => { + expect(JUMP_HOP_THREE_PLATFORM_YAW_RAD).toBeCloseTo(Math.PI / 4); + expect(JUMP_HOP_THREE_MATERIAL_FACE_ORDER).toEqual([ + 'right', + 'left', + 'back', + 'front', + 'top', + 'bottom', + ]); + expect(JUMP_HOP_THREE_TEXTURE_TRANSFORMS).toMatchObject({ + top: { flipX: true, flipY: false }, + front: { flipX: true, flipY: true }, + right: { flipX: false, flipY: true }, + back: { flipX: false, flipY: true }, + left: { flipX: true, flipY: true }, + bottom: { flipX: true, flipY: true }, + }); +}); + +test('跳一跳 Three 角色绘制层级高于地块并且脚点落在顶面中心高度', () => { + expect(JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT).toBe(false); + expect(JUMP_HOP_THREE_CHARACTER_RENDER_ORDER).toBeGreaterThan( + JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE + 18, + ); + expect(JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET).toBe(0); + expect(getJumpHopThreeCharacterFootZ(96, false)).toBe(48); + expect(getJumpHopThreeCharacterFootZ(96, true)).toBeLessThan( + getJumpHopThreeCharacterFootZ(96, false), + ); + expect(getJumpHopThreeCharacterFootZ(40, false)).toBe(20); +}); + +test('跳一跳旧单图地块资源不启用 Three UV 贴面层', () => { + const oldAssets = buildTileAssets(); + const uvAssets = buildTileAssets({ withFaceAssets: true }); + expect(hasCompleteJumpHopTileFaceAssets(oldAssets[0])).toBe(false); + expect(hasCompleteJumpHopTileFaceAssets(uvAssets[0])).toBe(true); + + render( + {}} + />, + ); + + expect( + screen + .getByTestId('jump-hop-camera-layer') + .getAttribute('data-three-platform-ready'), + ).toBe('false'); + expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(2); +}); + +test('跳一跳运行态只展示当前块和下一块并给下一块留出视野空间', () => { render( { +test('跳一跳运行态地块从出现开始保持真实尺寸', () => { render( , ); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( + 'true', + ); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'false', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( 'false', ); @@ -808,8 +968,8 @@ test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向', chargeMs: 300, jumpDistance: 1.0, targetPlatformIndex: 1, - landedX: 0, - landedY: 1.2, + landedX: 1.7, + landedY: 1.74, result: 'hit', }, }; @@ -866,8 +1026,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async chargeMs: 420, jumpDistance: 1.68, targetPlatformIndex: 1, - landedX: 0.93, - landedY: 1.4, + landedX: 1.7, + landedY: 1.74, result: 'hit', }, }; @@ -880,8 +1040,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async chargeMs: 360, jumpDistance: 1.44, targetPlatformIndex: 2, - landedX: -0.2, - landedY: 2.4, + landedX: 0, + landedY: 3.56, result: 'hit', }, }; @@ -958,6 +1118,17 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async await vi.advanceTimersByTimeAsync(580); }); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( + 'true', + ); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'false', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe( 'false', ); @@ -978,16 +1149,19 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async const cameraLayer = screen.getByTestId('jump-hop-camera-layer'); expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true'); expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-zoom')).toBe( - '1.3', + '1.69', ); expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe( '-17%', ); + // 下一块在反向时,当前块会切换到场地反侧锚点,横向镜头推进应被侧边视野校正抵消。 expect( - Number.parseFloat( - cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'), + Math.abs( + Number.parseFloat( + cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'), + ), ), - ).toBeCloseTo(8.96, 2); + ).toBeLessThan(1); const styleText = Array.from(document.querySelectorAll('style')) .map((style) => style.textContent ?? '') .join('\n'); @@ -1028,7 +1202,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async '64%', ); expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe( - '1.08', + '', ); expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe( 'p2', @@ -1127,6 +1301,14 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async await vi.advanceTimersByTimeAsync(580); }); + expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe( + 'false', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + const movedOldPlatform = screen .getByTestId('jump-hop-stage') .querySelector("[data-platform-id='p0']") as HTMLElement | null; @@ -1181,8 +1363,8 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse { { platformId: 'p1', tileType: 'normal', - x: 0.8, - y: 1.2, + x: 1.78, + y: 1.78, width: 1, height: 1, landingRadius: 0.5, @@ -1192,8 +1374,8 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse { { platformId: 'p2', tileType: 'target', - x: -0.2, - y: 2.4, + x: 0, + y: 3.56, width: 1, height: 1, landingRadius: 0.5, @@ -1239,8 +1421,8 @@ function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse { { platformId: 'p3', tileType: 'normal', - x: 0.5, - y: 3.6, + x: 1.78, + y: 5.34, width: 1, height: 1, landingRadius: 0.5, diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 8d691720a..96821de8d 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -27,9 +27,9 @@ import type { JumpHopRuntimeRequestOptions } from '../../services/jump-hop/jumpH import { buildJumpHopVisiblePlatforms, formatJumpHopDurationLabel, + getJumpHopCharacterTopFaceVisualPosition, getJumpHopCharacterVisualPosition, getJumpHopJumpFeedbackLabel, - getJumpHopBackendDragVector, getJumpHopLandingAssistVisualPosition, getJumpHopPlatformVisualSize, getJumpHopRunDurationMs, @@ -45,8 +45,6 @@ import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMa type JumpHopRuntimeJumpPayload = { dragDistance: number; - dragVectorX: number; - dragVectorY: number; }; type JumpHopVisualJump = { @@ -54,6 +52,11 @@ type JumpHopVisualJump = { to: JumpHopCharacterVisualPosition; }; +type JumpHopThreeCharacterFrame = { + position: JumpHopCharacterVisualPosition; + progress: number; +}; + type JumpHopPlatformRenderItem = JumpHopVisiblePlatform & { renderKey: string; advanceState: 'exiting' | 'camera' | 'idle'; @@ -89,12 +92,20 @@ type JumpHopRuntimeShellProps = { const MAX_CHARGE_RATIO = 1; const DEFAULT_MAX_DRAG_DISTANCE_PX = 180; const JUMP_HOP_ANIMATION_DURATION_MS = 560; +const JUMP_HOP_POST_LANDING_CAMERA_DELAY_MS = 300; const JUMP_HOP_LANDING_RECOIL_DURATION_MS = 560; const JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS = 1440; const JUMP_HOP_PLATFORM_RETAIN_OFFSCREEN_SCREEN_Y = 122; -const JUMP_HOP_CAMERA_ZOOM = 1.3; +const JUMP_HOP_CAMERA_ZOOM = 1.69; const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC = '/branding/jump-hop-taonier-character.png'; +const JUMP_HOP_THREE_CHARACTER_WIDTH = 72; +const JUMP_HOP_THREE_CHARACTER_HEIGHT = 84; +export const JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT = false; +export const JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE = 30; +export const JUMP_HOP_THREE_PLATFORM_YAW_RAD = Math.PI / 4; +export const JUMP_HOP_THREE_CHARACTER_RENDER_ORDER = 240; +export const JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET = 0; const JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT = 3; const JUMP_HOP_TILE_FACE_KEYS: JumpHopTileFaceKey[] = [ 'top', @@ -104,12 +115,32 @@ const JUMP_HOP_TILE_FACE_KEYS: JumpHopTileFaceKey[] = [ 'left', 'bottom', ]; +export const JUMP_HOP_THREE_MATERIAL_FACE_ORDER = [ + 'right', + 'left', + 'back', + 'front', + 'top', + 'bottom', +] as const satisfies JumpHopTileFaceKey[]; +export const JUMP_HOP_THREE_TEXTURE_TRANSFORMS = { + top: { flipX: true, flipY: false, rotation: 0 }, + front: { flipX: true, flipY: true, rotation: 0 }, + right: { flipX: false, flipY: true, rotation: 0 }, + back: { flipX: false, flipY: true, rotation: 0 }, + left: { flipX: true, flipY: true, rotation: 0 }, + bottom: { flipX: true, flipY: true, rotation: 0 }, +} as const satisfies Record< + JumpHopTileFaceKey, + { flipX: boolean; flipY: boolean; rotation: number } +>; const JUMP_HOP_THREE_CAMERA_PITCH_RAD = Math.PI / 4; const JUMP_HOP_THREE_CAMERA_PITCH_COS = Math.cos( JUMP_HOP_THREE_CAMERA_PITCH_RAD, ); export const JUMP_HOP_THREE_CAMERA_UP_Y = 1; const JUMP_HOP_THREE_CAMERA_DISTANCE_MULTIPLIER = 1.34; +export const JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER = 2; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -158,31 +189,6 @@ function shouldAnimateJumpHopPlatformAdvance( ); } -function buildJumpHopCharacterVisualPositionFromPlatform( - platform: JumpHopVisiblePlatform, - isMiss = false, -): JumpHopCharacterVisualPosition { - if (isMiss) { - return { - screenX: platform.screenX + 8, - screenY: platform.screenY - 2, - sceneX: platform.sceneX + 0.7, - sceneY: platform.sceneY + 0.48, - sceneZ: platform.sceneZ - 0.4, - isMiss: true, - }; - } - - return { - screenX: platform.screenX, - screenY: platform.screenY - 3, - sceneX: platform.sceneX, - sceneY: platform.sceneY + 0.84, - sceneZ: platform.sceneZ, - isMiss: false, - }; -} - function getJumpHopRunLandingVisualPosition({ run, platforms, @@ -200,12 +206,57 @@ function getJumpHopRunLandingVisualPosition({ return getJumpHopCharacterVisualPosition(run, platforms, stageSize); } -function getJumpHopThreeCubeSide( +export function getJumpHopThreeCubeSide( platform: JumpHopVisiblePlatform['platform'], scale: number, ) { const platformSize = getJumpHopPlatformVisualSize(platform, scale); - return Math.max(56, Math.min(platformSize.width, platformSize.height) * 0.86); + return ( + Math.max(56, Math.min(platformSize.width, platformSize.height) * 0.86) * + JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER + ); +} + +export function getJumpHopThreeCharacterFootZ( + cubeSide: number | null | undefined, + isMiss = false, +) { + const safeCubeSide = Number.isFinite(cubeSide ?? NaN) ? Math.max(0, cubeSide ?? 0) : 0; + const topFaceZ = safeCubeSide / 2; + if (isMiss) { + return Math.max(0, topFaceZ * 0.72); + } + return topFaceZ + JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET; +} + +function resolveJumpHopThreeCharacterFootZ( + characterPosition: JumpHopCharacterVisualPosition, + platforms: JumpHopPlatformRenderItem[], +) { + const closestPlatform = platforms.reduce( + (closest, item) => { + if (item.advanceState === 'exiting') { + return closest; + } + if (!closest) { + return item; + } + const itemDistance = Math.hypot( + item.sceneX - characterPosition.sceneX, + item.sceneZ - characterPosition.sceneZ, + ); + const closestDistance = Math.hypot( + closest.sceneX - characterPosition.sceneX, + closest.sceneZ - characterPosition.sceneZ, + ); + return itemDistance < closestDistance ? item : closest; + }, + null, + ); + const cubeSide = closestPlatform + ? getJumpHopThreeCubeSide(closestPlatform.platform, closestPlatform.scale) + : null; + return getJumpHopThreeCharacterFootZ(cubeSide, characterPosition.isMiss); } export function getJumpHopThreeProjectedY( @@ -218,6 +269,77 @@ export function getJumpHopThreeProjectedY( ); } +// 中文注释:screenY 表示顶面脚点投影;Three 世界坐标需要扣掉高度投影,避免角色和指示器落到方块几何中心。 +export function getJumpHopThreeWorldYForScreenY( + screenY: number, + viewportHeight: number, + worldZ = 0, +) { + return ( + getJumpHopThreeProjectedY(screenY, viewportHeight) - + worldZ * Math.tan(JUMP_HOP_THREE_CAMERA_PITCH_RAD) + ); +} + +function clampJumpHopAnimationProgress(progress: number) { + return clamp(progress, 0, 1); +} + +function easeJumpHopFlightProgress(progress: number) { + const clamped = clampJumpHopAnimationProgress(progress); + return 1 - Math.pow(1 - clamped, 3); +} + +function interpolateJumpHopCharacterPosition( + from: JumpHopCharacterVisualPosition, + to: JumpHopCharacterVisualPosition, + progress: number, +): JumpHopCharacterVisualPosition { + const clamped = clampJumpHopAnimationProgress(progress); + const eased = easeJumpHopFlightProgress(clamped); + const arcLift = Math.sin(Math.PI * clamped) * 9; + + return { + screenX: from.screenX + (to.screenX - from.screenX) * eased, + screenY: from.screenY + (to.screenY - from.screenY) * eased - arcLift, + sceneX: from.sceneX + (to.sceneX - from.sceneX) * eased, + sceneY: from.sceneY + (to.sceneY - from.sceneY) * eased, + sceneZ: from.sceneZ + (to.sceneZ - from.sceneZ) * eased, + isMiss: to.isMiss, + }; +} + +export function resolveJumpHopThreeCharacterFrame({ + characterPosition, + visualJump, + jumpAnimationProgress, + isJumpAnimating, +}: { + characterPosition: JumpHopCharacterVisualPosition | null; + visualJump: JumpHopVisualJump | null; + jumpAnimationProgress: number; + isJumpAnimating: boolean; +}): JumpHopThreeCharacterFrame | null { + if (isJumpAnimating && visualJump) { + const progress = clampJumpHopAnimationProgress(jumpAnimationProgress); + return { + position: interpolateJumpHopCharacterPosition( + visualJump.from, + visualJump.to, + progress, + ), + progress, + }; + } + if (!characterPosition) { + return null; + } + return { + position: characterPosition, + progress: 1, + }; +} + function IsometricFallbackTile({ platform, }: { @@ -320,6 +442,17 @@ function hasJumpHopTileTexturesReady( ); } +export function hasCompleteJumpHopTileFaceAssets( + asset: JumpHopTileAsset | null | undefined, +) { + return Boolean( + asset?.faceAssets && + JUMP_HOP_TILE_FACE_KEYS.every((face) => + Boolean(asset.faceAssets?.[face]?.imageSrc?.trim()), + ), + ); +} + function getJumpHopActiveTextureKeys( renderKey: string, asset: JumpHopTileAsset | null | undefined, @@ -657,41 +790,51 @@ function disposeJumpHopThreeObject(object: import('three').Object3D) { } function JumpHopThreeScene({ - characterPosition, + characterFrame, chargeRatio, + isLandingRecoilAnimating, isJumpAnimating, platforms, platformCount, renderCharacter, + renderPlatforms, textureUrlsByRenderKey, onCharacterLayerReadyChange, onPlatformLayerReadyChange, }: { - characterPosition: JumpHopCharacterVisualPosition | null; + characterFrame: JumpHopThreeCharacterFrame | null; chargeRatio: number; + isLandingRecoilAnimating: boolean; isJumpAnimating: boolean; platforms: JumpHopPlatformRenderItem[]; platformCount: number; renderCharacter: boolean; + renderPlatforms: boolean; textureUrlsByRenderKey: Record; onCharacterLayerReadyChange: Dispatch>; onPlatformLayerReadyChange: Dispatch>; }) { const hostRef = useRef(null); - const characterPositionRef = useRef(characterPosition); + const characterFrameRef = useRef(characterFrame); const chargeRatioRef = useRef(chargeRatio); + const isLandingRecoilAnimatingRef = useRef(isLandingRecoilAnimating); const isJumpAnimatingRef = useRef(isJumpAnimating); const platformsRef = useRef(platforms); + const renderPlatformsRef = useRef(renderPlatforms); const textureUrlsByRenderKeyRef = useRef(textureUrlsByRenderKey); useEffect(() => { - characterPositionRef.current = characterPosition; - }, [characterPosition]); + characterFrameRef.current = characterFrame; + }, [characterFrame]); useEffect(() => { chargeRatioRef.current = chargeRatio; }, [chargeRatio]); + useEffect(() => { + isLandingRecoilAnimatingRef.current = isLandingRecoilAnimating; + }, [isLandingRecoilAnimating]); + useEffect(() => { isJumpAnimatingRef.current = isJumpAnimating; }, [isJumpAnimating]); @@ -700,6 +843,10 @@ function JumpHopThreeScene({ platformsRef.current = platforms; }, [platforms]); + useEffect(() => { + renderPlatformsRef.current = renderPlatforms; + }, [renderPlatforms]); + useEffect(() => { textureUrlsByRenderKeyRef.current = textureUrlsByRenderKey; }, [textureUrlsByRenderKey]); @@ -773,36 +920,6 @@ function JumpHopThreeScene({ rimLight.position.set(120, 44, 120); scene.add(rimLight); - const character = renderCharacter ? new three.Group() : null; - if (character) { - const body = new three.Mesh( - new three.CapsuleGeometry(10, 22, 8, 18), - new three.MeshStandardMaterial({ - color: 0xdf7f40, - roughness: 0.74, - }), - ); - body.position.y = -28; - const head = new three.Mesh( - new three.SphereGeometry(11, 28, 20), - new three.MeshStandardMaterial({ - color: 0xf59e0b, - roughness: 0.7, - }), - ); - head.position.y = -62; - const accent = new three.Mesh( - new three.BoxGeometry(15, 7, 7), - new three.MeshStandardMaterial({ - color: 0x2563eb, - roughness: 0.64, - }), - ); - accent.position.set(0, -36, 10); - character.add(body, head, accent); - scene.add(character); - } - const platformGroup = new three.Group(); platformGroup.renderOrder = 20; scene.add(platformGroup); @@ -831,9 +948,68 @@ function JumpHopThreeScene({ >(); const fallbackMaterialCache = new Map(); let platformSignature = ''; + let isCharacterTextureLoaded = false; + let character: import('three').Sprite | null = null; + const publishCharacterLayerReady = () => { + if (!disposed) { + onCharacterLayerReadyChange(Boolean(character && isCharacterTextureLoaded)); + } + }; - const getTexture = (url: string) => { - const cached = textureCache.get(url); + const characterTexture = textureLoader.load( + JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC, + () => { + if (disposed) { + return; + } + isCharacterTextureLoaded = true; + renderer.render(scene, camera); + publishCharacterLayerReady(); + }, + undefined, + () => { + if (disposed) { + return; + } + isCharacterTextureLoaded = false; + publishCharacterLayerReady(); + }, + ); + characterTexture.colorSpace = three.SRGBColorSpace; + const characterMaterial = new three.SpriteMaterial({ + alphaTest: 0.02, + color: 0xffffff, + depthTest: false, + depthWrite: false, + map: characterTexture, + transparent: true, + }); + character = renderCharacter ? new three.Sprite(characterMaterial) : null; + if (character) { + character.renderOrder = JUMP_HOP_THREE_CHARACTER_RENDER_ORDER; + character.center.set(0.5, 0.08); + character.scale.set( + JUMP_HOP_THREE_CHARACTER_WIDTH, + JUMP_HOP_THREE_CHARACTER_HEIGHT, + 1, + ); + character.visible = false; + scene.add(character); + } + + const getTexture = (url: string, face?: JumpHopTileFaceKey) => { + const textureTransform = face + ? JUMP_HOP_THREE_TEXTURE_TRANSFORMS[face] + : null; + const cacheKey = textureTransform + ? [ + url, + textureTransform.flipX ? 'flip-x' : 'keep-x', + textureTransform.flipY ? 'flip-y' : 'keep-y', + textureTransform.rotation, + ].join('|') + : url; + const cached = textureCache.get(cacheKey); if (cached) { return cached; } @@ -845,7 +1021,19 @@ function JumpHopThreeScene({ texture.wrapS = three.ClampToEdgeWrapping; texture.wrapT = three.ClampToEdgeWrapping; texture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 6); - textureCache.set(url, texture); + if (textureTransform) { + texture.center.set(0.5, 0.5); + texture.rotation = textureTransform.rotation; + texture.repeat.set( + textureTransform.flipX ? -1 : 1, + textureTransform.flipY ? -1 : 1, + ); + texture.offset.set( + textureTransform.flipX ? 1 : 0, + textureTransform.flipY ? 1 : 0, + ); + } + textureCache.set(cacheKey, texture); return texture; }; @@ -858,7 +1046,7 @@ function JumpHopThreeScene({ item.renderKey, 'top', ); - if (item.asset?.faceAssets && textureUrl) { + if (hasCompleteJumpHopTileFaceAssets(item.asset) && textureUrl) { const cacheKey = JUMP_HOP_TILE_FACE_KEYS.map((face) => getJumpHopTileTextureUrl(textureUrls, item.renderKey, face), ).join('|'); @@ -867,27 +1055,20 @@ function JumpHopThreeScene({ return cached; } - // 中文注释:Three.js Box/RoundedBox 材质顺序为 right, left, top, bottom, front, back。 - const materials = [ - 'right', - 'left', - 'top', - 'bottom', - 'front', - 'back', - ].map((face) => { + // 中文注释:玩法把 Z 轴作为立方体竖直高度,所以这里把逻辑 top 映射到 Three 的 +Z 面。 + const materials = JUMP_HOP_THREE_MATERIAL_FACE_ORDER.map((face) => { const faceUrl = getJumpHopTileTextureUrl( textureUrls, item.renderKey, - face as JumpHopTileFaceKey, + face, ) || textureUrl; return new three.MeshStandardMaterial({ alphaTest: 0.04, - map: getTexture(faceUrl), + map: getTexture(faceUrl, face), metalness: 0, roughness: 0.76, - transparent: true, + transparent: JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT, }); }); materialCache.set(cacheKey, materials); @@ -905,7 +1086,7 @@ function JumpHopThreeScene({ map: getTexture(textureUrl), metalness: 0, roughness: 0.76, - transparent: true, + transparent: JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT, }); materialCache.set(textureUrl, material); return material; @@ -953,6 +1134,7 @@ function JumpHopThreeScene({ }; const syncPlatformMeshes = () => { + platformGroup.visible = renderPlatformsRef.current; const nextPlatforms = platformsRef.current; const textureUrls = textureUrlsByRenderKeyRef.current; const nextSignature = nextPlatforms @@ -989,9 +1171,10 @@ function JumpHopThreeScene({ const cubeSide = getJumpHopThreeCubeSide(item.platform, item.scale); const root = new three.Group(); const rootBaseX = (item.screenX / 100) * viewportSize.width; - const rootBaseY = getJumpHopThreeProjectedY( + const rootBaseY = getJumpHopThreeWorldYForScreenY( (item.screenY / 100) * viewportSize.height, viewportSize.height, + cubeSide / 2, ); root.position.set(rootBaseX, rootBaseY, 0); root.renderOrder = 20 + item.index; @@ -1015,9 +1198,10 @@ function JumpHopThreeScene({ getPlatformMaterial(item, textureUrls), ); mesh.position.set(0, 0, 0); - mesh.rotation.set(0, 0, 0); + mesh.rotation.set(0, 0, JUMP_HOP_THREE_PLATFORM_YAW_RAD); mesh.scale.setScalar(cubeSide); - mesh.renderOrder = 30 + item.index; + mesh.renderOrder = + JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE + item.index; root.add(shadow, mesh); platformGroup.add(root); @@ -1042,35 +1226,50 @@ function JumpHopThreeScene({ : null; resizeObserver?.observe(host); resize(); - onCharacterLayerReadyChange(Boolean(character)); + publishCharacterLayerReady(); onPlatformLayerReadyChange(true); const animate = () => { + platformGroup.visible = renderPlatformsRef.current; syncPlatformMeshes(); - const nextCharacterPosition = characterPositionRef.current; - if (character && nextCharacterPosition) { + const nextCharacterFrame = characterFrameRef.current; + if (character && nextCharacterFrame) { + const nextCharacterPosition = nextCharacterFrame.position; const nextChargeRatio = chargeRatioRef.current; const canvasPosition = resolveJumpHopCharacterCanvasPosition( nextCharacterPosition, viewportSize, ); + const characterFootZ = resolveJumpHopThreeCharacterFootZ( + nextCharacterPosition, + platformsRef.current, + ); character.visible = true; - character.position.set(canvasPosition?.x ?? 0, canvasPosition?.y ?? 0, 0); + character.position.set( + canvasPosition?.x ?? 0, + getJumpHopThreeWorldYForScreenY( + canvasPosition?.y ?? 0, + viewportSize.height, + characterFootZ, + ), + characterFootZ, + ); if (isJumpAnimatingRef.current) { const now = window.performance.now(); - character.rotation.z = Math.sin(now / 42) * 1.22; - character.rotation.x = Math.sin(now / 28) * 0.28; - character.rotation.y = Math.sin(now / 34) * 0.2; - character.position.y += Math.sin(now / 26) * 8 - 14; + characterMaterial.rotation = + Math.sin(now / 42) * 0.16 + nextCharacterFrame.progress * Math.PI * 1.65; + } else if (isLandingRecoilAnimatingRef.current) { + const now = window.performance.now(); + characterMaterial.rotation = Math.sin(now / 52) * 0.05; } else { - character.rotation.z = nextCharacterPosition.isMiss ? -0.32 : 0; - character.rotation.x = 0; - character.rotation.y = 0; + characterMaterial.rotation = nextCharacterPosition.isMiss ? -0.24 : 0; } character.scale.set( - 1 + nextChargeRatio * 0.08, - 1 - nextChargeRatio * 0.12, - 1 + nextChargeRatio * 0.08, + JUMP_HOP_THREE_CHARACTER_WIDTH * (1 + nextChargeRatio * 0.1), + JUMP_HOP_THREE_CHARACTER_HEIGHT * + (1 - nextChargeRatio * 0.28) * + (isLandingRecoilAnimatingRef.current ? 1.04 : 1), + 1, ); } else if (character) { character.visible = false; @@ -1095,6 +1294,8 @@ function JumpHopThreeScene({ } }); fallbackMaterialCache.forEach((material) => material.dispose()); + characterTexture.dispose(); + characterMaterial.dispose(); shadowMaterial.dispose(); platformGeometry.dispose(); shadowGeometry.dispose(); @@ -1223,6 +1424,7 @@ export function JumpHopRuntimeShell({ const landingRecoilEndTimerRef = useRef(null); const animationStartAtRef = useRef(0); const hasJumpAnimationReachedTargetRef = useRef(false); + const postLandingCameraDelayTimerRef = useRef(null); const platformAdvanceEndTimerRef = useRef(null); const activeRunRef = useRef(activeRun); const displayRunRef = useRef(displayRun); @@ -1314,11 +1516,12 @@ export function JumpHopRuntimeShell({ () => isThreePlatformLayerReady && platformRenderItems.every((item) => - hasJumpHopTileTexturesReady( - platformTextureUrlsByRenderKey, - item.renderKey, - item.asset, - ), + hasCompleteJumpHopTileFaceAssets(item.asset) && + hasJumpHopTileTexturesReady( + platformTextureUrlsByRenderKey, + item.renderKey, + item.asset, + ), ), [ isThreePlatformLayerReady, @@ -1379,17 +1582,6 @@ export function JumpHopRuntimeShell({ visiblePlatforms, landingAssistStageSize, ); - const currentPlatformOriginPosition = useMemo(() => { - if (!stageRun) { - return null; - } - const currentPlatform = visiblePlatforms.find( - (item) => item.index === stageRun.currentPlatformIndex, - ); - return currentPlatform - ? buildJumpHopCharacterVisualPositionFromPlatform(currentPlatform) - : null; - }, [stageRun, visiblePlatforms]); const jumpTargetPlatform = useMemo(() => { if (!stageRun) { return null; @@ -1401,12 +1593,12 @@ export function JumpHopRuntimeShell({ ); }, [stageRun, visiblePlatforms]); const targetDirection = useMemo(() => { - const directionOrigin = currentPlatformOriginPosition ?? characterPosition; + const directionOrigin = characterPosition; if (!directionOrigin || !jumpTargetPlatform) { return null; } const targetCharacterPosition = - buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform); + getJumpHopCharacterTopFaceVisualPosition(jumpTargetPlatform); const directionX = targetCharacterPosition.screenX - directionOrigin.screenX; const directionY = targetCharacterPosition.screenY - directionOrigin.screenY; const distance = Math.hypot(directionX, directionY); @@ -1420,7 +1612,27 @@ export function JumpHopRuntimeShell({ unitScreenX: directionX / distance, unitScreenY: directionY / distance, }; - }, [characterPosition, currentPlatformOriginPosition, jumpTargetPlatform]); + }, [characterPosition, jumpTargetPlatform]); + const landingAssistPosition = useMemo(() => { + if (!isCharging || !stageRun || !characterPosition) { + return null; + } + + return getJumpHopLandingAssistVisualPosition( + stageRun, + visiblePlatforms, + characterPosition, + landingAssistStageSize, + dragDistance, + ); + }, [ + characterPosition, + dragDistance, + isCharging, + landingAssistStageSize, + stageRun, + visiblePlatforms, + ]); const visualCharacterPosition = useMemo(() => { if (!characterPosition) { return null; @@ -1434,6 +1646,21 @@ export function JumpHopRuntimeShell({ isJumpAnimating, visualJump, ]); + const threeCharacterFrame = useMemo( + () => + resolveJumpHopThreeCharacterFrame({ + characterPosition, + visualJump, + jumpAnimationProgress, + isJumpAnimating, + }), + [ + characterPosition, + isJumpAnimating, + jumpAnimationProgress, + visualJump, + ], + ); const characterMotionStyle = useMemo(() => { const idleTransform = 'matrix(1, 0, 0, 1, 0, 0)'; const recoilDistance = Math.hypot(dragVector.x, dragVector.y); @@ -1597,6 +1824,13 @@ export function JumpHopRuntimeShell({ setIsLandingRecoilAnimating(false); }, []); + const clearPostLandingCameraDelay = useCallback(() => { + if (postLandingCameraDelayTimerRef.current != null) { + window.clearTimeout(postLandingCameraDelayTimerRef.current); + postLandingCameraDelayTimerRef.current = null; + } + }, []); + const stopChargeFrame = useCallback(() => { if (chargeFrameRef.current != null) { window.cancelAnimationFrame(chargeFrameRef.current); @@ -1634,7 +1868,7 @@ export function JumpHopRuntimeShell({ (item) => item.index === toRun.currentPlatformIndex, ); return fromLandingPlatform - ? buildJumpHopCharacterVisualPositionFromPlatform( + ? getJumpHopCharacterTopFaceVisualPosition( fromLandingPlatform, ) : null; @@ -1650,7 +1884,7 @@ export function JumpHopRuntimeShell({ (item) => item.index === toRun.currentPlatformIndex, ); return toCurrentPlatform - ? buildJumpHopCharacterVisualPositionFromPlatform( + ? getJumpHopCharacterTopFaceVisualPosition( toCurrentPlatform, ) : null; @@ -1746,6 +1980,32 @@ export function JumpHopRuntimeShell({ [beginPlatformAdvance, clearLandingRecoilState], ); + const resolveJumpHopFlightAnimation = useCallback( + ( + fromRun: JumpHopRuntimeRunSnapshotResponse, + toRun: JumpHopRuntimeRunSnapshotResponse, + ) => { + if (!shouldAnimateJumpHopPlatformAdvance(fromRun, toRun)) { + clearPostLandingCameraDelay(); + finishJumpHopFlightAnimation(fromRun, toRun); + return; + } + + if (postLandingCameraDelayTimerRef.current != null) { + return; + } + + postLandingCameraDelayTimerRef.current = window.setTimeout(() => { + postLandingCameraDelayTimerRef.current = null; + finishJumpHopFlightAnimation(fromRun, toRun); + }, JUMP_HOP_POST_LANDING_CAMERA_DELAY_MS); + }, + [ + clearPostLandingCameraDelay, + finishJumpHopFlightAnimation, + ], + ); + useEffect(() => { if (stageRun?.status !== 'playing') { return undefined; @@ -1793,6 +2053,7 @@ export function JumpHopRuntimeShell({ window.clearTimeout(animationEndTimerRef.current); animationEndTimerRef.current = null; } + clearPostLandingCameraDelay(); clearPlatformAdvanceState(); clearLandingRecoilState(); hasJumpAnimationReachedTargetRef.current = false; @@ -1818,6 +2079,7 @@ export function JumpHopRuntimeShell({ window.clearTimeout(animationEndTimerRef.current); animationEndTimerRef.current = null; } + clearPostLandingCameraDelay(); clearPlatformAdvanceState(); clearLandingRecoilState(); hasJumpAnimationReachedTargetRef.current = false; @@ -1842,7 +2104,7 @@ export function JumpHopRuntimeShell({ displayRun.runId === activeRun.runId && hasJumpHopRunDisplayChange(displayRun, activeRun) ) { - finishJumpHopFlightAnimation(displayRun, activeRun); + resolveJumpHopFlightAnimation(displayRun, activeRun); } return; } @@ -1856,10 +2118,11 @@ export function JumpHopRuntimeShell({ activeRun, clearLandingRecoilState, clearPlatformAdvanceState, + clearPostLandingCameraDelay, displayRun, - finishJumpHopFlightAnimation, isJumpAnimating, jumpAnimationProgress, + resolveJumpHopFlightAnimation, stopChargeFrame, ]); @@ -1877,6 +2140,9 @@ export function JumpHopRuntimeShell({ if (landingRecoilEndTimerRef.current != null) { window.clearTimeout(landingRecoilEndTimerRef.current); } + if (postLandingCameraDelayTimerRef.current != null) { + window.clearTimeout(postLandingCameraDelayTimerRef.current); + } if (chargeFrameRef.current != null) { window.cancelAnimationFrame(chargeFrameRef.current); } @@ -1912,7 +2178,7 @@ export function JumpHopRuntimeShell({ latestDisplayRun.runId === latestActiveRun.runId && hasJumpHopRunDisplayChange(latestDisplayRun, latestActiveRun) ) { - finishJumpHopFlightAnimation(latestDisplayRun, latestActiveRun); + resolveJumpHopFlightAnimation(latestDisplayRun, latestActiveRun); } }, JUMP_HOP_ANIMATION_DURATION_MS); const tick = (now: number) => { @@ -1946,7 +2212,7 @@ export function JumpHopRuntimeShell({ animationEndTimerRef.current = null; } }; - }, [finishJumpHopFlightAnimation, isJumpAnimating]); + }, [isJumpAnimating, resolveJumpHopFlightAnimation]); const beginCharge = (event: PointerEvent) => { if (!canJump) { @@ -2012,21 +2278,16 @@ export function JumpHopRuntimeShell({ ) : null; if (characterPosition) { - const predictionOrigin = - currentPlatformOriginPosition ?? characterPosition; - const visualDeltaX = predictedLandingPosition - ? predictedLandingPosition.screenX - predictionOrigin.screenX - : 0; - const visualDeltaY = predictedLandingPosition - ? predictedLandingPosition.screenY - predictionOrigin.screenY - : 0; setVisualJump({ from: characterPosition, to: predictedLandingPosition ? { ...characterPosition, - screenX: clamp(characterPosition.screenX + visualDeltaX, 6, 94), - screenY: clamp(characterPosition.screenY + visualDeltaY, 10, 92), + screenX: predictedLandingPosition.screenX, + screenY: predictedLandingPosition.screenY, + sceneX: predictedLandingPosition.sceneX, + sceneY: predictedLandingPosition.sceneY, + sceneZ: predictedLandingPosition.sceneZ, isMiss: !predictedLandingPosition.isOnTargetPlatform, } : characterPosition, @@ -2046,17 +2307,8 @@ export function JumpHopRuntimeShell({ x: targetDirection ? -targetDirection.unitScreenX : 0, y: targetDirection ? -targetDirection.unitScreenY : 0, }); - const backendDragVector = getJumpHopBackendDragVector( - predictionRun ?? activeRun, - visiblePlatforms, - landingAssistStageSize, - targetDirection ? -targetDirection.unitScreenX : 0, - targetDirection ? -targetDirection.unitScreenY : 0, - ); await onJump({ dragDistance: nextDragDistance, - dragVectorX: backendDragVector.dragVectorX, - dragVectorY: backendDragVector.dragVectorY, }); }; @@ -2123,12 +2375,14 @@ export function JumpHopRuntimeShell({ } > ) : null} + + {landingAssistPosition ? ( + ) : null} -
- - } - onClick={() => { - if (!isModelLocked) { - togglePanel('model'); - } - }} + {includeModel ? ( +
- - - {selectedModelLabel} - - - {!isModelLocked && openPanel === 'model' - ? renderEditorPortal( - event.stopPropagation()} + + } + onClick={() => { + if (!isModelLocked) { + togglePanel('model'); + } + }} + > + +
+ , + ) + : null} +
+ ) : null} {typeof cost === 'number' ? ( CSSProperties; - onRequestUpload?: (target: 'quick-edit-reference') => void; + onRequestUpload?: (target: UploadTarget) => void; onRememberImageModel?: (model: string) => void; onSubmit: () => void; }; -type OpenPanel = 'dimensions' | 'model' | null; - -function resetFailedPanelStatus( - panel: T, -) { +function resetFailedPanelStatus< + T extends { status: string; errorMessage?: string }, +>(panel: T) { return { ...panel, status: panel.status === 'failed' ? 'idle' : panel.status, @@ -67,88 +45,44 @@ function resetFailedPanelStatus void; -}) { - const label = `图${index + 1}`; - return ( -