feat(jump-hop): optimize generated assets and runtime background

This commit is contained in:
2026-06-04 22:34:19 +08:00
parent c442c3c3f0
commit 0041b95f72
17 changed files with 1160 additions and 200 deletions

View File

@@ -1,4 +1,4 @@
use axum::http::StatusCode;
use axum::http::StatusCode;
use platform_image::generated_asset_sheets as generated_asset_sheets_impl;
use crate::{
@@ -8,9 +8,12 @@ use crate::{
#[allow(unused_imports)]
pub(crate) use generated_asset_sheets_impl::{
GeneratedAssetSheetError, GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor,
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload,
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha,
crop_generated_asset_sheet_view_edge_matte,
crop_generated_asset_sheet_view_edge_matte_with_options,
};
pub(crate) fn build_generated_asset_sheet_prompt(

View File

@@ -29,7 +29,8 @@ use crate::{
api_response::json_success_body,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
generated_asset_sheets::{
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_alpha_with_options,
crop_generated_asset_sheet_view_edge_matte_with_options,
},
generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
@@ -56,6 +57,10 @@ 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 = 5;
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5;
const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF";
const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536";
const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024;
const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536;
#[derive(Clone, Debug, PartialEq, Eq)]
struct JumpHopTileAtlasSlice {
@@ -428,12 +433,19 @@ async fn maybe_generate_jump_hop_assets(
) {
return Ok(());
}
if payload.tile_atlas_asset.is_some()
let has_complete_tile_assets = payload.tile_atlas_asset.is_some()
&& payload
.tile_assets
.as_ref()
.is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT)
{
.is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT);
let has_real_background = payload
.cover_composite
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value));
if has_complete_tile_assets && has_real_background {
return Ok(());
}
let profile_id = payload
@@ -464,78 +476,151 @@ async fn maybe_generate_jump_hop_assets(
.theme_text
.as_deref()
.or(payload.work_title.as_deref())
.unwrap_or("跳一跳");
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or(theme_text);
.unwrap_or("跳一跳")
.to_string();
let tile_prompt = payload
.tile_prompt
.clone()
.unwrap_or_else(|| theme_text.clone());
let sheet_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
let tile_generated = create_openai_image_generation(
&http_client,
&settings,
sheet_prompt.as_str(),
Some(build_jump_hop_tile_atlas_negative_prompt()),
"1024*1024",
1,
&[],
"跳一跳地块图集生成失败",
)
.await
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳地块图集生成成功但未返回图片。",
})),
if !has_real_background {
let background_prompt = build_jump_hop_background_prompt(theme_text.as_str());
let background_generated = create_openai_image_generation(
&http_client,
&settings,
background_prompt.as_str(),
Some(build_jump_hop_background_negative_prompt()),
JUMP_HOP_BACKGROUND_IMAGE_SIZE,
1,
&[],
"跳一跳背景底图生成失败",
)
})?;
let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"tile-atlas",
tile_prompt,
tile_image,
LegacyAssetPrefix::JumpHopAssets,
1024,
1024,
request_context,
)
.await?;
let mut tile_assets = Vec::with_capacity(tile_slices.len());
for (index, tile_slice) in tile_slices.into_iter().enumerate() {
tile_assets.push(
persist_jump_hop_tile_asset(
state,
owner_user_id,
profile_id.as_str(),
index,
tile_slice,
.await
.map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let background_image = background_generated
.images
.into_iter()
.next()
.ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳背景底图生成成功但未返回图片。",
})),
)
})?;
let background_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"background",
background_prompt.as_str(),
background_image,
LegacyAssetPrefix::JumpHopAssets,
JUMP_HOP_BACKGROUND_IMAGE_WIDTH,
JUMP_HOP_BACKGROUND_IMAGE_HEIGHT,
request_context,
)
.await?;
payload.cover_composite = Some(background_asset.image_src);
}
if !has_complete_tile_assets {
let sheet_prompt =
build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str());
let tile_generated = create_openai_image_generation(
&http_client,
&settings,
sheet_prompt.as_str(),
Some(build_jump_hop_tile_atlas_negative_prompt()),
"1024*1024",
1,
&[],
"跳一跳地块图集生成失败",
)
.await
.map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let tile_image = tile_generated.images.into_iter().next().ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳地块图集生成成功但未返回图片。",
})),
)
.await?,
);
})?;
let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"tile-atlas",
tile_prompt.as_str(),
tile_image,
LegacyAssetPrefix::JumpHopAssets,
1024,
1024,
request_context,
)
.await?;
let mut tile_assets = Vec::with_capacity(tile_slices.len());
for (index, tile_slice) in tile_slices.into_iter().enumerate() {
tile_assets.push(
persist_jump_hop_tile_asset(
state,
owner_user_id,
profile_id.as_str(),
index,
tile_slice,
request_context,
)
.await?,
);
}
payload.tile_atlas_asset = Some(tile_atlas_asset);
payload.tile_assets = Some(tile_assets);
}
if payload.character_asset.is_none() {
payload.character_asset = Some(build_jump_hop_default_character_asset(
profile_id.as_str(),
theme_text,
theme_text.as_str(),
));
}
payload.tile_atlas_asset = Some(tile_atlas_asset);
payload.tile_assets = Some(tile_assets);
payload.cover_composite = payload.cover_composite.clone().or_else(|| {
Some(format!(
"/generated-jump-hop-assets/{profile_id}/cover-composite.png"
))
});
Ok(())
}
fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool {
let value = value.trim();
value.starts_with("/generated-jump-hop-assets/")
&& (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-"))
}
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
let theme_text = theme_text.trim();
let theme_text = if theme_text.is_empty() {
"跳一跳"
} else {
theme_text
};
format!(
"生成一张9:16竖版跳一跳游戏背景底图主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊从画面底部延伸到上方该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感但元素数量必须少不能抢跳板、角色和交互层的视觉两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印。\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然哑光手绘质感柔和光照主体背景不油亮、不厚重CG、不暗黑中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no text, consistent 2D/2.5D front-facing 30-degree game perspective."
)
}
fn build_jump_hop_background_negative_prompt() -> &'static str {
"文字、Logo、水印、UI按钮、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
}
fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String {
let theme_text = theme_text.trim();
let theme_text = if theme_text.is_empty() {
@@ -543,20 +628,57 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri
} else {
theme_text
};
let subject_text = tile_prompt.trim();
let subject_text = if subject_text.is_empty() {
let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt);
let subject_text = if sanitized_tile_prompt.is_empty() {
theme_text
} else {
subject_text
sanitized_tile_prompt.as_str()
};
format!(
"生成一张1:1图片主题为“{theme_text}”。\n画面只包含25个独立的跳一跳可落脚平台素材,按五行五列均匀摆放在纯绿色绿幕画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为俯视角平台跳跃游戏,画面内容是{subject_text}\n每一块平台都必须直接使用主题元素做主体造型,主题要一眼可见;例如主题为水果时,是苹果切片、橙子切片、西瓜块、草莓、菠萝、香蕉等水果造型平台,不得变成石板、金属按钮、徽章或装备\n只画平台裸素材,不画外层面板、棋盘底座、菜单、按钮、标题、文字、角标、装饰边框、工具栏、装备、武器、徽章、道具或角色\n整体风格为清爽自然的休闲手游平台素材偏2D/2.5D手绘质感哑光材质干净色块轻微主体内部明暗避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n格一个完整平台,是符合主题且有设计感的立体感平台,有顶面和清晰轮廓;不要默认生成灰色石板或金属地砖,除非主题本身就是石头或金属\n格主体必须居中视觉尺寸只占单格56%-64%四周至少保留18%纯绿色绿幕安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个平台只保留主体内部明暗外轮廓,不绘制落地投影、接触阴影、方形阴影、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个平台同一材质体系、同一光向,但形状和细节有变化;每个平台之间只能是纯绿色空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是接近 #00FF00 的纯绿色绿幕,背景平整无纹理、无渐变、无阴影、无黑底;主体自身不得使用接近 #00FF00 的纯绿\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板或道具界面\nEnglish guardrail: isolated top-down fruit-shaped jump pad assets only, green screen background, no text, no poster, no architecture, no building, no UI screen, no inventory icons."
"生成一张1:1图片主题为“{theme_text}”。\n画面只包含25个独立的跳跃落点主题物体,按五行五列均匀摆放在纯洋红抠图画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为正面30度视角的跳跃游戏素材,画面内容是{subject_text}所有落点素材都必须保持统一的正面30度视角相机位于物体正前方略高位置镜头向下约30度能看到清晰正面、侧壁、下沿和少量上表面。\n构图验收标准:主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;不要让顶面占据主要视觉,不要画成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标。\n水果主题尤其要避免俯拍:橙瓣必须看到橙皮正面外侧和果肉厚度,椰子必须看到壳的正面侧壁和切口厚度,浆果不能只是一个从上往下看的圆形球顶。\n每一个落点都必须直接使用主题物体或合理发散物体做主体造型,主题要一眼可见;例如主题为水果时,可以是苹果切片、橙、西瓜块、草莓、菠萝、香蕉、葡萄串等水果物体,苹果可近似圆,香蕉可近似长条或长方形,西瓜可近似扇形,造型以物体本身外轮廓为准。\n主题物体本身就是唯一可落脚体:雪花落点就是一枚带厚度的雪花,向日葵落点就是一朵带厚度的向日葵,水果落点就是水果切片或水果本体;不要在主题物体下面再垫任何石头、土块、木板、圆台、底盘、托盘、岛屿、花盆、地面块或通用承托物\n只画主题物体裸素材,不画外层面板、棋盘底座、菜单、UI按钮、标题、文字、角标、装饰边框、工具栏、装备栏、图标卡、角色或游戏界面\n整体风格为清爽自然的休闲手游主题物体素材偏2D/2.5D手绘质感哑光材质干净色块轻微主体内部明暗避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n个落点都是符合主题且有设计感的立体感物体,有清晰轮廓和明显自身厚度;不要把不同主题物体强行改造成统一地砖、统一按钮或统一抽象图标。\n造型规则完全由物体本身决定允许圆形、长条、弧形、三角、扇形、块状、枝叶状、多件组合、轻微夸张和一定程度发散只在同一2D/2.5D手绘风格、正面30度视角、材质包装、清晰轮廓、单格规格和安全留白上保持一致。\n25个落点应尽量选择不同主题物体或相关发散物体差异主要来自物体种类和原生轮廓不使用固定形状脚本相邻格可以形状相似只要物体不同且主题清楚。\n允许用主题物体自身的切面、边缘厚度、花瓣层、果皮边、雪花厚边或云朵体积表现可落脚感;禁止额外支撑层、承托底座、脚下地板、下方石台、下方土墩、下方圆盘、下方托盘或“物体摆在平台上”的画法\n个落点必须居中视觉尺寸只占单格56%-64%四周至少保留18%纯洋红安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个落点只保留主体内部明暗外轮廓和自身厚度,不绘制落地投影、接触阴影、方形阴影、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个落点同一材质体系、同一光向和同一正面30度视角但物体类别、外轮廓和细节有变化;每个落点之间只能是纯洋红空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX},背景平整无纹理、无渐变、无阴影、无黑底;主体允许使用绿色、白色、雪地、云朵、草地和花朵,但主体自身不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的洋红色\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板、图标集页面、物体下方额外底座或物体摆在地板上\nEnglish guardrail: isolated front-facing 30-degree camera-pitch theme-object assets only, camera slightly above the object and looking down about 30 degrees from the front; every object must show a clear front face, side wall, lower rim, object thickness, and only a small top surface; visible front/side area must be close to or larger than the top area; never produce top-down, overhead, bird's-eye, flat icon, round top-view disk assets; the theme object itself is the only landing object, each object's native silhouette decides the shape, no extra base under the object, no pedestal, no plinth, no floor slab, consistent 2D/2.5D style wrapper, solid magenta chroma key background {JUMP_HOP_TILE_ATLAS_KEY_HEX}, no text, no poster, no UI screen, no inventory icons."
)
}
fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str {
"文字、Logo、水印、按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、灰色石板、金属地砖、建筑、楼房、海报、装备、武器、徽章、道具图标、UI图标卡、标题、说明文字、装饰边框、落地投影、接触阴影、方形阴影、方形底板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界"
"文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、纯俯视角、正上方视角、鸟瞰视角、平铺俯拍、顶面占主画面、只看顶面、圆形顶视图、扁平图标、落地投影、接触阴影、方形阴影、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界"
}
fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String {
let mut value = tile_prompt.trim().to_string();
if value.is_empty() {
return value;
}
const REPLACEMENTS: [(&str, &str); 18] = [
("俯视角", "正面30度视角"),
("正上方视角", "正面30度视角"),
("鸟瞰视角", "正面30度视角"),
("平铺俯拍", "正面30度视角"),
("可落脚平台素材", "跳跃落点主题物体"),
("清爽游戏化立体感平台素材", "清爽游戏化立体感主题物体"),
("平台裸素材", "主题物体裸素材"),
("每格一个完整平台", "每格一个完整主题物体"),
("平台素材", "主题物体"),
("可落脚平台", "跳跃落点"),
("可落脚", "落点"),
("平台", "主题物体"),
("跳台", "落点"),
("地块", "主题物体"),
("地砖", "主题物体"),
("底座", "承托物"),
("底盘", "承托物"),
("地板", "承托物"),
];
for (from, to) in REPLACEMENTS {
value = value.replace(from, to);
}
while value.contains("正面30度视角正面30度视角") {
value = value.replace("正面30度视角正面30度视角", "正面30度视角");
}
value
}
fn slice_jump_hop_tile_atlas(
@@ -568,7 +690,8 @@ fn slice_jump_hop_tile_atlas(
"message": format!("跳一跳地块图集解码失败:{error}"),
}))
})?;
let source = apply_generated_asset_sheet_green_screen_alpha(source);
let alpha_options = GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen();
let source = apply_generated_asset_sheet_alpha_with_options(source, alpha_options);
let width = source.width();
let height = source.height();
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
@@ -596,9 +719,11 @@ fn slice_jump_hop_tile_atlas(
x1.saturating_sub(x0).max(1),
y1.saturating_sub(y0).max(1),
);
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
let cleaned =
crop_generated_asset_sheet_view_edge_matte_with_options(cropped, alpha_options);
let cleaned = keep_jump_hop_largest_alpha_component(cleaned);
let cleaned = crop_generated_asset_sheet_view_edge_matte(cleaned);
let cleaned =
crop_generated_asset_sheet_view_edge_matte_with_options(cleaned, alpha_options);
let cleaned = pad_jump_hop_tile_slice_image(cleaned);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned
@@ -997,7 +1122,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft
character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"),
tile_prompt: clean_or_default(
&payload.tile_prompt,
&format!("{theme_text}主题的俯视角清爽游戏化立体感平台素材"),
&format!("{theme_text}主题的正面30度视角主题物体图集物体本身作为跳跃落点"),
),
end_mood_prompt: payload
.end_mood_prompt
@@ -1164,17 +1289,67 @@ mod tests {
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
assert!(prompt.contains("五行五列"));
assert!(prompt.contains("25个"));
assert!(prompt.contains("可落脚平台素材"));
assert!(prompt.contains("25个独立"));
assert!(prompt.contains("跳跃落点主题物体"));
assert!(prompt.contains("不要画成游戏界面"));
assert!(prompt.contains("视觉方向为正面30度视角"));
assert!(prompt.contains("所有落点素材都必须保持统一的正面30度视角"));
assert!(prompt.contains("相机位于物体正前方略高位置"));
assert!(prompt.contains("镜头向下约30度"));
assert!(prompt.contains("能看到清晰正面、侧壁、下沿和少量上表面"));
assert!(prompt.contains("主体正面或侧壁可见面积必须接近或大于顶面面积"));
assert!(prompt.contains("顶面只能作为辅助可见面"));
assert!(prompt.contains("不要让顶面占据主要视觉"));
assert!(prompt.contains("不要画成纯俯视、正上方俯拍、鸟瞰地图块"));
assert!(prompt.contains("水果主题尤其要避免俯拍"));
assert!(prompt.contains("橙瓣必须看到橙皮正面外侧和果肉厚度"));
assert!(prompt.contains("浆果不能只是一个从上往下看的圆形球顶"));
assert!(prompt.contains("主题要一眼可见"));
assert!(prompt.contains("格一个完整平台"));
assert!(prompt.contains("清爽自然的休闲手游平台素材"));
assert!(prompt.contains("符合主题且有设计感的立体感平台"));
assert!(prompt.contains("四周至少保留18%纯绿色绿幕安全留白"));
assert!(prompt.contains("个落点都是符合主题且有设计感的立体感物体"));
assert!(prompt.contains("清爽自然的休闲手游主题物体素材"));
assert!(prompt.contains("符合主题且有设计感的立体感物体"));
assert!(prompt.contains("每一个落点都必须直接使用主题物体或合理发散物体"));
assert!(prompt.contains("苹果可近似圆"));
assert!(prompt.contains("香蕉可近似长条或长方形"));
assert!(prompt.contains("主题物体本身就是唯一可落脚体"));
assert!(prompt.contains("雪花落点就是一枚带厚度的雪花"));
assert!(prompt.contains("不要在主题物体下面再垫任何石头、土块、木板"));
assert!(prompt.contains("造型规则完全由物体本身决定"));
assert!(prompt.contains("允许圆形、长条、弧形、三角、扇形、块状"));
assert!(prompt.contains("只在同一2D/2.5D手绘风格"));
assert!(prompt.contains("同一正面30度视角"));
assert!(prompt.contains("不使用固定形状脚本"));
assert!(prompt.contains("允许用主题物体自身的切面、边缘厚度"));
assert!(prompt.contains("禁止额外支撑层、承托底座、脚下地板"));
assert!(prompt.contains("四周至少保留18%纯洋红安全留白"));
assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX));
assert!(prompt.contains("主体允许使用绿色、白色、雪地、云朵、草地和花朵"));
assert!(prompt.contains("不绘制落地投影"));
assert!(prompt.contains("不画分隔线、网格线、容器框或棋盘格"));
assert!(prompt.contains("English guardrail"));
assert!(prompt.contains("front-facing 30-degree camera-pitch"));
assert!(prompt.contains("camera slightly above the object"));
assert!(
prompt.contains("visible front/side area must be close to or larger than the top area")
);
assert!(prompt.contains("never produce top-down"));
assert!(prompt.contains("each object's native silhouette decides the shape"));
assert!(prompt.contains("no extra base under the object"));
assert!(prompt.contains("no pedestal"));
assert!(prompt.contains("no floor slab"));
assert!(!prompt.contains("可落脚平台素材"));
assert!(!prompt.contains("平台裸素材"));
assert!(!prompt.contains("每格一个完整平台"));
assert!(!prompt.contains("25个平台"));
assert!(!prompt.contains("platform, each"));
assert!(!prompt.contains("only platform"));
assert!(!prompt.contains("基础轮廓优先做不规则主题剪影"));
assert!(!prompt.contains("25格造型要混排"));
assert!(!prompt.contains("no simple circles"));
assert!(!prompt.contains("no simple squares"));
assert!(!prompt.contains("纯绿色绿幕"));
assert!(!prompt.contains("#00FF00"));
assert!(!prompt.contains("isolated top-down"));
assert!(!prompt.contains("按5行*5列"));
assert!(!prompt.contains("2D地板图标"));
assert!(!prompt.contains("清爽自然的游戏图标"));
@@ -1184,6 +1359,91 @@ mod tests {
assert!(!prompt.contains("不同视图"));
}
#[test]
fn jump_hop_background_prompt_keeps_center_corridor_and_side_atmosphere() {
let prompt = build_jump_hop_background_prompt("水果");
assert!(prompt.contains("9:16竖版跳一跳游戏背景底图"));
assert!(prompt.contains("主题关键词严格只使用“水果”"));
assert!(prompt.contains("整体风格需要和同一主题的跳一跳游戏元素一致"));
assert!(prompt.contains("左右两侧氛围为主"));
assert!(prompt.contains("中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊"));
assert!(prompt.contains("该区域只能使用少量低对比度纹理"));
assert!(prompt.contains("中央纵轴1/2区域要有明显纵深感"));
assert!(prompt.contains("两侧可以更有立体感、空间层次和主题氛围"));
assert!(prompt.contains("不画任何跳板、地块、落脚物、角色、UI按钮"));
assert!(prompt.contains("视角保持正面约30度"));
assert!(prompt.contains("中央区域需要给运行态地块和陶泥儿角色留出干净可读空间"));
assert!(prompt.contains("English guardrail"));
assert!(prompt.contains("left and right sides carry the atmosphere"));
assert!(prompt.contains("central vertical half-width corridor stays simple"));
assert!(prompt.contains("no platforms"));
assert!(prompt.contains("no landing objects"));
}
#[test]
fn jump_hop_background_negative_prompt_blocks_runtime_layer_conflicts() {
let negative_prompt = build_jump_hop_background_negative_prompt();
assert!(negative_prompt.contains("跳板"));
assert!(negative_prompt.contains("地块"));
assert!(negative_prompt.contains("落脚物"));
assert!(negative_prompt.contains("角色"));
assert!(negative_prompt.contains("UI按钮"));
assert!(negative_prompt.contains("中央堆满元素"));
assert!(negative_prompt.contains("中央遮挡"));
assert!(negative_prompt.contains("纯俯视地图"));
assert!(negative_prompt.contains("平铺俯拍"));
}
#[test]
fn jump_hop_legacy_cover_placeholder_is_not_treated_as_background() {
assert!(is_jump_hop_legacy_cover_composite_placeholder(
"/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png",
));
assert!(is_jump_hop_legacy_cover_composite_placeholder(
"/generated-jump-hop-assets/jump-hop-profile-test/cover-composite-123.png",
));
assert!(!is_jump_hop_legacy_cover_composite_placeholder(
"/generated-jump-hop-assets/jump-hop-profile-test/background/image.png",
));
assert!(!is_jump_hop_legacy_cover_composite_placeholder(
"/uploads/custom-cover.png",
));
}
#[test]
fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() {
let prompt = build_jump_hop_tile_atlas_prompt(
"科幻芯片",
"科幻芯片主题的俯视角清爽游戏化立体感平台素材",
);
assert!(prompt.contains("画面内容是科幻芯片主题的正面30度视角清爽游戏化立体感主题物体"));
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材"));
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角"));
let top_down_prompt =
build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台");
assert!(top_down_prompt.contains("画面内容是水果主题正面30度视角圆形主题物体"));
assert!(!top_down_prompt.contains("画面内容是水果主题鸟瞰视角"));
assert!(!top_down_prompt.contains("画面内容是水果主题平铺俯拍"));
let legacy_prompt = build_jump_hop_tile_atlas_prompt(
"雪花",
"雪花主题可落脚平台素材,每格一个完整平台,不要底座",
);
assert!(legacy_prompt.contains("雪花主题跳跃落点主题物体"));
assert!(legacy_prompt.contains("每格一个完整主题物体"));
assert!(legacy_prompt.contains("不要承托物"));
assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚平台素材"));
assert!(!legacy_prompt.contains("画面内容是雪花主题可落脚"));
assert!(!legacy_prompt.contains("画面内容是雪花主题平台"));
assert!(!legacy_prompt.contains("画面内容是雪花主题地块"));
}
#[test]
fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() {
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
@@ -1192,9 +1452,28 @@ mod tests {
assert!(negative_prompt.contains("厚重CG渲染"));
assert!(negative_prompt.contains("游戏界面"));
assert!(negative_prompt.contains("图标集页面"));
assert!(negative_prompt.contains("建筑"));
assert!(negative_prompt.contains("纯俯视角"));
assert!(negative_prompt.contains("正上方视角"));
assert!(negative_prompt.contains("鸟瞰视角"));
assert!(negative_prompt.contains("顶面占主画面"));
assert!(negative_prompt.contains("只看顶面"));
assert!(negative_prompt.contains("圆形顶视图"));
assert!(negative_prompt.contains("扁平图标"));
assert!(negative_prompt.contains("方形阴影"));
assert!(negative_prompt.contains("方形底板"));
assert!(negative_prompt.contains("额外底座"));
assert!(negative_prompt.contains("承托底座"));
assert!(negative_prompt.contains("台座"));
assert!(negative_prompt.contains("物体摆在平台上"));
assert!(negative_prompt.contains("物体下方垫地板"));
assert!(!negative_prompt.contains("规则圆盘"));
assert!(!negative_prompt.contains("正圆平台"));
assert!(!negative_prompt.contains("规则方块"));
assert!(!negative_prompt.contains("圆角矩形"));
assert!(!negative_prompt.contains("杯垫"));
assert!(!negative_prompt.contains("重复圆形"));
assert!(!negative_prompt.contains("建筑"));
assert!(!negative_prompt.contains("楼房"));
}
#[test]
@@ -1283,6 +1562,62 @@ mod tests {
}
}
#[test]
fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() {
let width = 500;
let height = 500;
let mut atlas =
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
for row in 0..5 {
for col in 0..5 {
let color = if row == 0 && col == 0 {
image::Rgba([62, 188, 74, 255])
} else if row == 0 && col == 1 {
image::Rgba([246, 246, 238, 255])
} else {
image::Rgba([120, 96, 72, 255])
};
let center_x = col as u32 * 100 + 50;
let center_y = row as u32 * 100 + 50;
for y in center_y - 24..center_y + 24 {
for x in center_x - 28..center_x + 28 {
atlas.put_pixel(x, y, color);
}
}
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(atlas)
.write_to(&mut encoded, image::ImageFormat::Png)
.expect("atlas should encode");
let image = crate::openai_image_generation::DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
let green_tile = image::load_from_memory(slices[0].bytes.as_slice())
.expect("green tile should decode")
.to_rgba8();
let white_tile = image::load_from_memory(slices[1].bytes.as_slice())
.expect("white tile should decode")
.to_rgba8();
assert!(
green_tile
.pixels()
.any(|pixel| pixel.0 == [62, 188, 74, 255])
);
assert!(
white_tile
.pixels()
.any(|pixel| pixel.0 == [246, 246, 238, 255])
);
assert_eq!(green_tile.get_pixel(0, 0).0[3], 0);
assert_eq!(white_tile.get_pixel(0, 0).0[3], 0);
}
#[test]
fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() {
let slots = (0..JUMP_HOP_TILE_ITEM_COUNT)