feat: polish jump hop themed runtime assets

This commit is contained in:
2026-06-05 22:55:40 +08:00
parent a215852381
commit cd8088d1fd
22 changed files with 719 additions and 354 deletions

View File

@@ -61,6 +61,9 @@ const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF";
const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536";
const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024;
const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536;
const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024";
const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024;
const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
struct JumpHopTileAtlasSlice {
@@ -444,8 +447,12 @@ async fn maybe_generate_jump_hop_assets(
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some_and(|value| !is_jump_hop_legacy_cover_composite_placeholder(value));
let has_back_button_asset = payload
.back_button_asset
.as_ref()
.is_some_and(is_jump_hop_image_asset_usable);
if has_complete_tile_assets && has_real_background {
if has_complete_tile_assets && has_real_background && has_back_button_asset {
return Ok(());
}
let profile_id = payload
@@ -529,6 +536,58 @@ async fn maybe_generate_jump_hop_assets(
payload.cover_composite = Some(background_asset.image_src);
}
if !has_back_button_asset {
let back_button_prompt = build_jump_hop_back_button_prompt(theme_text.as_str());
let back_button_generated = create_openai_image_generation(
&http_client,
&settings,
back_button_prompt.as_str(),
Some(build_jump_hop_back_button_negative_prompt()),
JUMP_HOP_BACK_BUTTON_IMAGE_SIZE,
1,
&[],
"跳一跳返回按钮图生成失败",
)
.await
.map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let back_button_image =
back_button_generated
.images
.into_iter()
.next()
.ok_or_else(|| {
jump_hop_error_response(
request_context,
JUMP_HOP_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "跳一跳返回按钮图生成成功但未返回图片。",
})),
)
})?;
let back_button_image =
prepare_jump_hop_green_screen_image_for_persist(back_button_image, "跳一跳返回按钮图")
.map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let back_button_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"back-button",
back_button_prompt.as_str(),
back_button_image,
LegacyAssetPrefix::JumpHopAssets,
JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH,
JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT,
request_context,
)
.await?;
payload.back_button_asset = Some(back_button_asset);
}
if !has_complete_tile_assets {
let sheet_prompt =
build_jump_hop_tile_atlas_prompt(theme_text.as_str(), tile_prompt.as_str());
@@ -604,33 +663,110 @@ fn is_jump_hop_legacy_cover_composite_placeholder(value: &str) -> bool {
&& (value.ends_with("/cover-composite.png") || value.contains("/cover-composite-"))
}
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
fn is_jump_hop_image_asset_usable(asset: &JumpHopCharacterAsset) -> bool {
!asset.image_src.trim().is_empty()
&& !asset.image_object_key.trim().is_empty()
&& !asset.asset_object_id.trim().is_empty()
&& !asset.generation_provider.trim().is_empty()
}
fn prepare_jump_hop_green_screen_image_for_persist(
image: crate::openai_image_generation::DownloadedOpenAiImage,
failure_label: &str,
) -> Result<crate::openai_image_generation::DownloadedOpenAiImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": JUMP_HOP_CREATION_PROVIDER,
"message": format!("{failure_label}解码失败:{error}"),
}))
})?;
let mut encoded = std::io::Cursor::new(Vec::new());
crate::generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(source)
.write_to(&mut encoded, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": JUMP_HOP_CREATION_PROVIDER,
"message": format!("{failure_label}绿幕去背失败:{error}"),
}))
})?;
Ok(crate::openai_image_generation::DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String {
let theme_text = theme_text.trim();
let theme_text = if theme_text.is_empty() {
"跳一跳"
} else {
theme_text
};
if theme_text.is_empty() {
return "跳一跳".to_string();
}
replace_jump_hop_pokemon_prompt_terms(theme_text)
}
fn replace_jump_hop_pokemon_prompt_terms(value: &str) -> String {
let mut value = value.trim().to_string();
if value.is_empty() {
return value;
}
// 中文注释:仅对宝可梦相关词做生成侧脱敏,避免地块图集触发上游安全拦截。
const POKEMON_REPLACEMENTS: [(&str, &str); 15] = [
("宝可梦", "原创幻想萌宠冒险道具"),
("神奇宝贝", "原创幻想萌宠冒险道具"),
("口袋妖怪", "原创幻想萌宠冒险道具"),
("精灵球", "彩色冒险能量球"),
("皮卡丘", "黄色闪电萌宠符号"),
("Pokémon", "原创幻想萌宠冒险道具"),
("Pokemon", "原创幻想萌宠冒险道具"),
("POKEMON", "原创幻想萌宠冒险道具"),
("pokemon", "原创幻想萌宠冒险道具"),
("Pikachu", "黄色闪电萌宠符号"),
("PIKACHU", "黄色闪电萌宠符号"),
("pikachu", "黄色闪电萌宠符号"),
("Poké Ball", "彩色冒险能量球"),
("Poke Ball", "彩色冒险能量球"),
("pokeball", "彩色冒险能量球"),
];
for (from, to) in POKEMON_REPLACEMENTS {
value = value.replace(from, to);
}
value
}
fn build_jump_hop_background_prompt(theme_text: &str) -> String {
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
format!(
"生成一张9:16竖版跳一跳游戏背景底图主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊从画面底部延伸到上方该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感但元素数量必须少不能抢跳板、角色和交互层的视觉两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印。\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然哑光手绘质感柔和光照主体背景不油亮、不厚重CG、不暗黑中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no text, consistent 2D/2.5D front-facing 30-degree game perspective."
"生成一张9:16竖版跳一跳游戏背景底图主题关键词严格只使用“{theme_text}”,不要额外改换主题;整体风格需要和同一主题的跳一跳游戏元素一致。\n画面结构必须以左右两侧氛围为主:左侧和右侧可以使用符合主题的环境元素、装饰层次、前中后景遮挡、透视节奏和行进感,让玩家感到从画面下方向上方前进。\n中央纵轴1/2区域必须是清爽的跳跃路径视觉走廊从画面底部延伸到上方该区域只能使用少量低对比度纹理、柔和光影、空气透视和纵深引导线禁止堆放大型主体。\n中央纵轴1/2区域要有明显纵深感但元素数量必须少不能抢跳板、角色和交互层的视觉两侧可以更有立体感、空间层次和主题氛围。\n背景只作为底图不画任何跳板、地块、落脚物、角色、UI按钮、标题、文字、路径箭头、分数、边框、海报排版、Logo或水印;左上角也不要画返回按钮或任何固定图标,运行态会叠加独立可点击按钮资产\n视角保持正面约30度的2D/2.5D休闲手游视角,相机位于场景正前方略高位置,画面有轻微向上行进的纵深,不要画成纯俯视地图、平铺俯拍、扁平壁纸或真实摄影。\n色彩清爽自然哑光手绘质感柔和光照主体背景不油亮、不厚重CG、不暗黑中央区域需要给运行态地块和陶泥儿角色留出干净可读空间。\nEnglish guardrail: vertical 9:16 mobile game background only, theme keywords strictly from \"{theme_text}\", left and right sides carry the atmosphere, the central vertical half-width corridor stays simple with sparse low-contrast details and clear depth, no platforms, no landing objects, no character, no UI, no top-left back button, no score UI, no other UI panels, consistent 2D/2.5D front-facing 30-degree game perspective."
)
}
fn build_jump_hop_background_negative_prompt() -> &'static str {
"文字、Logo、水印、UI按钮、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
"文字、Logo、水印、UI按钮、返回按钮、左上角图标、右上角按钮、底部按钮、UI面板、标题、说明文字、分数、边框、海报排版、角色、人物、跳板、地块、落脚物、平台、道路箭头、棋盘、格子、中心大型主体、中央堆满元素、中央遮挡、中央高对比装饰、中央复杂花纹、纯俯视地图、平铺俯拍、扁平壁纸、真实摄影、暗黑幻想风、厚重CG渲染、油亮高光、塑料质感"
}
fn build_jump_hop_back_button_prompt(theme_text: &str) -> String {
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
format!(
"生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标圆心居中主体视觉尺寸占画布约72%-82%外沿有一圈干净描边内部只有一个居中的向左箭头不要写“返回”文字不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1输出绿色背景主体图背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal."
)
}
fn build_jump_hop_back_button_negative_prompt() -> &'static str {
"文字、返回文字、Logo、水印、数字、多个按钮、UI面板、海报排版、复杂徽章、花盘、浮雕边、异形外框、主题物体主体、木槌、角色、跳板、地块、落脚物、平台、透明棋盘格、白底、黑底、灰底、真实摄影、厚重CG、暗黑幻想风、油亮塑料、纯绿色按钮主体、与绿幕混在一起"
}
fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> String {
let theme_text = theme_text.trim();
let theme_text = if theme_text.is_empty() {
"跳一跳"
} else {
theme_text
};
let theme_text = normalize_jump_hop_generation_theme_text(theme_text);
let sanitized_tile_prompt = sanitize_jump_hop_tile_prompt(tile_prompt);
let subject_text = if sanitized_tile_prompt.is_empty() {
theme_text
theme_text.as_str()
} else {
sanitized_tile_prompt.as_str()
};
@@ -649,6 +785,7 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String {
if value.is_empty() {
return value;
}
value = replace_jump_hop_pokemon_prompt_terms(value.as_str());
const REPLACEMENTS: [(&str, &str); 18] = [
("俯视角", "正面30度视角"),
@@ -1134,6 +1271,7 @@ fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraft
tile_assets: Vec::new(),
path: None,
cover_composite: None,
back_button_asset: None,
generation_status: JumpHopGenerationStatus::Draft,
}
}
@@ -1376,13 +1514,32 @@ mod tests {
assert!(prompt.contains("中央纵轴1/2区域要有明显纵深感"));
assert!(prompt.contains("两侧可以更有立体感、空间层次和主题氛围"));
assert!(prompt.contains("不画任何跳板、地块、落脚物、角色、UI按钮"));
assert!(prompt.contains("左上角也不要画返回按钮或任何固定图标"));
assert!(prompt.contains("运行态会叠加独立可点击按钮资产"));
assert!(prompt.contains("视角保持正面约30度"));
assert!(prompt.contains("中央区域需要给运行态地块和陶泥儿角色留出干净可读空间"));
assert!(prompt.contains("English guardrail"));
assert!(prompt.contains("left and right sides carry the atmosphere"));
assert!(prompt.contains("central vertical half-width corridor stays simple"));
assert!(prompt.contains("no top-left back button"));
assert!(prompt.contains("no platforms"));
assert!(prompt.contains("no landing objects"));
assert!(prompt.contains("no other UI panels"));
}
#[test]
fn jump_hop_back_button_prompt_builds_standalone_green_screen_asset() {
let prompt = build_jump_hop_back_button_prompt("水果");
assert!(prompt.contains("独立透明素材"));
assert!(prompt.contains("主题关键词严格只使用“水果”"));
assert!(prompt.contains("单个标准圆形图标"));
assert!(prompt.contains("内部只有一个居中的向左箭头"));
assert!(prompt.contains("不要写“返回”文字"));
assert!(prompt.contains("背景必须是单一纯绿色 #00FF00"));
assert!(prompt.contains("后续由服务端扣除绿色背景"));
assert!(prompt.contains("one standalone circular mobile game back button asset only"));
assert!(prompt.contains("solid #00FF00 green-screen background"));
}
#[test]
@@ -1394,6 +1551,11 @@ mod tests {
assert!(negative_prompt.contains("落脚物"));
assert!(negative_prompt.contains("角色"));
assert!(negative_prompt.contains("UI按钮"));
assert!(negative_prompt.contains("返回按钮"));
assert!(negative_prompt.contains("左上角图标"));
assert!(negative_prompt.contains("右上角按钮"));
assert!(negative_prompt.contains("底部按钮"));
assert!(negative_prompt.contains("UI面板"));
assert!(negative_prompt.contains("中央堆满元素"));
assert!(negative_prompt.contains("中央遮挡"));
assert!(negative_prompt.contains("纯俯视地图"));
@@ -1416,6 +1578,34 @@ mod tests {
));
}
#[test]
fn jump_hop_generation_prompt_only_rewrites_pokemon_terms() {
let background_prompt = build_jump_hop_background_prompt("宝可梦");
assert!(background_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
assert!(!background_prompt.contains("宝可梦"));
let back_button_prompt = build_jump_hop_back_button_prompt("Pokemon");
assert!(back_button_prompt.contains("主题关键词严格只使用“原创幻想萌宠冒险道具”"));
assert!(!back_button_prompt.contains("Pokemon"));
let tile_prompt = build_jump_hop_tile_atlas_prompt(
"宝可梦",
"宝可梦主题的正面30度视角主题物体图集包含皮卡丘和精灵球装饰",
);
assert!(tile_prompt.contains("主题为“原创幻想萌宠冒险道具”"));
assert!(tile_prompt.contains("画面内容是原创幻想萌宠冒险道具主题"));
assert!(tile_prompt.contains("黄色闪电萌宠符号"));
assert!(tile_prompt.contains("彩色冒险能量球"));
assert!(!tile_prompt.contains("宝可梦"));
assert!(!tile_prompt.contains("皮卡丘"));
assert!(!tile_prompt.contains("精灵球"));
let normal_prompt =
build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集");
assert!(normal_prompt.contains("主题为“水果”"));
assert!(normal_prompt.contains("画面内容是水果主题的正面30度视角主题物体图集"));
}
#[test]
fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() {
let prompt = build_jump_hop_tile_atlas_prompt(