统一跳一跳三维地块与落点判定
修正跳一跳长按起跳预测为真实脚点指向下一块顶面中心 统一前端指示器飞行动画与后端顶面 footprint 判定 调整 Three.js 方块贴图与角色顶面投影表现 补充跳一跳 UV 图集切片与运行态规则文档
This commit is contained in:
@@ -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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<f32>,
|
||||
drag_vector_y: Option<f32>,
|
||||
_drag_vector_x: Option<f32>,
|
||||
_drag_vector_y: Option<f32>,
|
||||
jumped_at_ms: u64,
|
||||
) -> Result<JumpHopRunSnapshot, JumpHopError> {
|
||||
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<f32>,
|
||||
drag_vector_y: Option<f32>,
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<CreationEntryConfigProcedureResult, __sdk::InternalError>,
|
||||
) + 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<CreationEntryConfigProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, CreationEntryConfigProcedureResult>(
|
||||
"upsert_public_work_interaction_config",
|
||||
UpsertPublicWorkInteractionConfigArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user