feat(jump-hop): redesign sling platform gameplay

This commit is contained in:
2026-06-03 22:21:00 +08:00
parent 40ef89aeb5
commit 7d2d67a3f5
59 changed files with 6930 additions and 1973 deletions

View File

@@ -94,13 +94,11 @@ pub async fn generate_character_visual(
.map_err(|error| character_visual_error_response(&request_context, error))?;
let result = async {
let settings = require_openai_image_settings(&state)?
.with_external_api_audit_context(
&request_context,
Some(owner_user_id.clone()),
Some(character_id.clone()),
)
;
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(owner_user_id.clone()),
Some(character_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
state
@@ -324,10 +322,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile(
&model,
&prompt,
)?;
let settings = require_openai_image_settings(state)?.with_external_api_audit_actor(
Some(owner_user_id.to_string()),
Some(character_id.clone()),
);
let settings = require_openai_image_settings(state)?
.with_external_api_audit_actor(Some(owner_user_id.to_string()), Some(character_id.clone()));
let http_client = build_openai_image_http_client(&settings)?;
state
.ai_task_service()

View File

@@ -255,6 +255,29 @@ mod tests {
);
}
#[test]
fn test_creation_entry_config_response_updates_jump_hop_metadata() {
let config = test_creation_entry_config_response();
let jump_hop = config
.creation_types
.iter()
.find(|item| item.id == "jump-hop")
.expect("test creation entry config should include jump-hop");
assert_eq!(jump_hop.title, "\u{8df3}\u{4e00}\u{8df3}");
assert!(jump_hop.visible);
assert!(jump_hop.open);
assert_eq!(jump_hop.badge, "\u{53ef}\u{521b}\u{5efa}");
assert_eq!(
jump_hop.subtitle,
"\u{4e3b}\u{9898}\u{9a71}\u{52a8}\u{5e73}\u{53f0}\u{8df3}\u{8dc3}"
);
assert_eq!(
jump_hop.image_src,
"/creation-type-references/jump-hop.webp"
);
}
#[test]
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
let config = test_creation_entry_config_response();

View File

@@ -553,12 +553,11 @@ pub async fn generate_custom_world_scene_image(
"scene_image",
asset_id.as_str(),
async {
let settings = require_openai_image_settings(&state)?
.with_external_api_audit_context(
&request_context,
Some(owner_user_id.to_string()),
normalized.profile_id.clone(),
);
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(owner_user_id.to_string()),
normalized.profile_id.clone(),
);
let http_client = build_openai_image_http_client(&settings)?;
let reference_image =
if let Some(reference_image_src) = normalized.reference_image_src.as_deref() {

View File

@@ -13,15 +13,15 @@ use serde_json::{Value, json};
use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileType,
JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorksResponse,
JumpHopWorkspaceCreateRequest,
JumpHopLeaderboardResponse, JumpHopRestartRunRequest, JumpHopRunResponse,
JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest,
JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use std::{
collections::BTreeMap,
collections::{BTreeMap, VecDeque},
time::{SystemTime, UNIX_EPOCH},
};
@@ -46,8 +46,7 @@ use crate::{
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const JUMP_HOP_TILE_ITEM_NAMES: [&str; 6] =
["start", "normal", "target", "finish", "bonus", "accent"];
const JUMP_HOP_TILE_ITEM_COUNT: usize = 25;
const JUMP_HOP_PROVIDER: &str = "jump-hop";
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
@@ -55,8 +54,8 @@ 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 = 2;
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 5;
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 5;
#[derive(Clone, Debug, PartialEq, Eq)]
struct JumpHopTileAtlasSlice {
@@ -239,6 +238,35 @@ pub async fn get_jump_hop_runtime_work(
))
}
pub async fn get_jump_hop_leaderboard(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<RuntimePrincipal>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &profile_id, "profileId")?;
let leaderboard = state
.spacetime_client()
.get_jump_hop_leaderboard(profile_id, principal.subject().to_string())
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_RUNTIME_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopLeaderboardResponse {
profile_id: leaderboard.profile_id,
items: leaderboard.items,
viewer_best: leaderboard.viewer_best,
},
))
}
pub async fn start_jump_hop_run(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -247,6 +275,7 @@ pub async fn start_jump_hop_run(
) -> Result<Json<Value>, Response> {
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
let is_draft_runtime = payload.runtime_mode.as_deref() == Some("draft");
let owner_user_id = principal.subject().to_string();
let principal_kind = principal.kind().as_str();
let run = state
@@ -261,23 +290,25 @@ pub async fn start_jump_hop_run(
)
})?;
record_work_play_start_after_success(
&state,
&request_context,
build_jump_hop_work_play_tracking_draft(
&principal,
run.profile_id.clone(),
JUMP_HOP_RUNTIME_RUNS_ROUTE,
if !is_draft_runtime {
record_work_play_start_after_success(
&state,
&request_context,
build_jump_hop_work_play_tracking_draft(
&principal,
run.profile_id.clone(),
JUMP_HOP_RUNTIME_RUNS_ROUTE,
)
.owner_user_id(run.owner_user_id.clone())
.run_id(run.run_id.clone())
.profile_id(run.profile_id.clone())
.extra(json!({
"runStatus": run.status,
"principalKind": principal_kind,
})),
)
.owner_user_id(run.owner_user_id.clone())
.run_id(run.run_id.clone())
.profile_id(run.profile_id.clone())
.extra(json!({
"runStatus": run.status,
"principalKind": principal_kind,
})),
)
.await;
.await;
}
Ok(json_success_body(
Some(&request_context),
@@ -391,15 +422,17 @@ async fn maybe_generate_jump_hop_assets(
owner_user_id: &str,
payload: &mut JumpHopActionRequest,
) -> Result<(), Response> {
if !matches!(payload.action_type, JumpHopActionType::CompileDraft) {
if !matches!(
payload.action_type,
JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
) {
return Ok(());
}
if payload.character_asset.is_some()
&& payload.tile_atlas_asset.is_some()
if payload.tile_atlas_asset.is_some()
&& payload
.tile_assets
.as_ref()
.is_some_and(|assets| !assets.is_empty())
.is_some_and(|assets| assets.len() >= JUMP_HOP_TILE_ITEM_COUNT)
{
return Ok(());
}
@@ -414,12 +447,11 @@ async fn maybe_generate_jump_hop_assets(
let settings = require_openai_image_settings(state)
.map(|settings| {
settings
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.clone()),
)
settings.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.clone()),
)
})
.map_err(|error| {
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
@@ -428,58 +460,19 @@ async fn maybe_generate_jump_hop_assets(
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
})?;
let character_prompt = payload
.character_prompt
let theme_text = payload
.theme_text
.as_deref()
.unwrap_or("俯视角可爱主角,透明背景");
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or("等距立体地块图集");
.or(payload.work_title.as_deref())
.unwrap_or("跳一跳");
let tile_prompt = payload.tile_prompt.as_deref().unwrap_or(theme_text);
let character_generated = create_openai_image_generation(
&http_client,
&settings,
character_prompt,
Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
"1024*1024",
1,
&[],
"跳一跳角色资产生成失败",
)
.await
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
let character_image = character_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 character_asset = persist_jump_hop_generated_image_asset(
state,
owner_user_id,
profile_id.as_str(),
"character",
character_prompt,
character_image,
LegacyAssetPrefix::JumpHopAssets,
768,
768,
request_context,
)
.await?;
let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt);
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("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"),
Some(build_jump_hop_tile_atlas_negative_prompt()),
"1024*1024",
1,
&[],
@@ -527,7 +520,12 @@ async fn maybe_generate_jump_hop_assets(
.await?,
);
}
payload.character_asset = Some(character_asset);
if payload.character_asset.is_none() {
payload.character_asset = Some(build_jump_hop_default_character_asset(
profile_id.as_str(),
theme_text,
));
}
payload.tile_atlas_asset = Some(tile_atlas_asset);
payload.tile_assets = Some(tile_assets);
payload.cover_composite = payload.cover_composite.clone().or_else(|| {
@@ -538,28 +536,29 @@ async fn maybe_generate_jump_hop_assets(
Ok(())
}
fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String {
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 subject_text = tile_prompt.trim();
let subject_text = if subject_text.is_empty() {
"等距立体地块图集"
theme_text
} else {
subject_text
};
let cell_plan = [
"第1行第1列start 起点地块",
"第1行第2列normal 普通地块",
"第1行第3列target 目标地块",
"第2行第1列finish 终点地块",
"第2行第2列bonus 奖励地块",
"第2行第3列accent 视觉强调地块",
]
.join("");
format!(
"生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00,背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色场景"
"生成一张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."
)
}
fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str {
"文字、Logo、水印、按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、灰色石板、金属地砖、建筑、楼房、海报、装备、武器、徽章、道具图标、UI图标卡、标题、说明文字、装饰边框、落地投影、接触阴影、方形阴影、方形底板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界"
}
fn slice_jump_hop_tile_atlas(
image: &crate::openai_image_generation::DownloadedOpenAiImage,
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
@@ -583,8 +582,8 @@ fn slice_jump_hop_tile_atlas(
);
}
let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len());
for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() {
let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_COUNT);
for index in 0..JUMP_HOP_TILE_ITEM_COUNT {
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
@@ -598,6 +597,9 @@ fn slice_jump_hop_tile_atlas(
y1.saturating_sub(y0).max(1),
);
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
let cleaned = keep_jump_hop_largest_alpha_component(cleaned);
let cleaned = crop_generated_asset_sheet_view_edge_matte(cleaned);
let cleaned = pad_jump_hop_tile_slice_image(cleaned);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned
.write_to(&mut cursor, image::ImageFormat::Png)
@@ -617,26 +619,116 @@ fn slice_jump_hop_tile_atlas(
Ok(slices)
}
fn pad_jump_hop_tile_slice_image(image: image::DynamicImage) -> image::DynamicImage {
let source = image.to_rgba8();
let (width, height) = source.dimensions();
if width == 0 || height == 0 {
return image::DynamicImage::ImageRgba8(source);
}
// 中文注释:生图偶尔会让主体贴近单元格边缘;切片入库前补透明安全边,
// 避免运行态缩放或滤镜让主体看起来被裁掉。
let pad_x = (width / 12).clamp(8, 24);
let pad_y = (height / 12).clamp(8, 24);
let mut padded = image::RgbaImage::from_pixel(
width.saturating_add(pad_x.saturating_mul(2)),
height.saturating_add(pad_y.saturating_mul(2)),
image::Rgba([0, 0, 0, 0]),
);
image::imageops::overlay(&mut padded, &source, pad_x.into(), pad_y.into());
image::DynamicImage::ImageRgba8(padded)
}
fn keep_jump_hop_largest_alpha_component(image: image::DynamicImage) -> image::DynamicImage {
let mut source = image.to_rgba8();
let (width, height) = source.dimensions();
if width == 0 || height == 0 {
return image::DynamicImage::ImageRgba8(source);
}
// 中文注释:模型偶尔会让相邻格的叶片、果梗或阴影越界进当前格;
// 每格只保留最大的 alpha 连通主体,能去掉这些小碎片再入库。
let width_usize = width as usize;
let height_usize = height as usize;
let pixel_count = width_usize.saturating_mul(height_usize);
let mut visited = vec![false; pixel_count];
let mut best_component = Vec::<usize>::new();
for start in 0..pixel_count {
if visited[start] || source.as_raw()[start * 4 + 3] <= 16 {
visited[start] = true;
continue;
}
let mut queue = VecDeque::from([start]);
let mut component = Vec::<usize>::new();
visited[start] = true;
while let Some(index) = queue.pop_front() {
component.push(index);
let x = index % width_usize;
let y = index / width_usize;
for offset_y in -1i32..=1 {
for offset_x in -1i32..=1 {
if offset_x == 0 && offset_y == 0 {
continue;
}
let next_x = x as i32 + offset_x;
let next_y = y as i32 + offset_y;
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32
{
continue;
}
let next = next_y as usize * width_usize + next_x as usize;
if visited[next] {
continue;
}
visited[next] = true;
if source.as_raw()[next * 4 + 3] > 16 {
queue.push_back(next);
}
}
}
}
if component.len() > best_component.len() {
best_component = component;
}
}
if best_component.is_empty() {
return image::DynamicImage::ImageRgba8(source);
}
let mut keep = vec![false; pixel_count];
for index in best_component {
keep[index] = true;
}
for index in 0..pixel_count {
if keep[index] {
continue;
}
let pixel =
source.get_pixel_mut((index % width_usize) as u32, (index / width_usize) as u32);
pixel.0[3] = 0;
}
image::DynamicImage::ImageRgba8(source)
}
fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
match index {
0 => JumpHopTileType::Start,
1 => JumpHopTileType::Normal,
2 => JumpHopTileType::Target,
3 => JumpHopTileType::Finish,
4 => JumpHopTileType::Bonus,
_ => JumpHopTileType::Accent,
value if value % 11 == 0 => JumpHopTileType::Bonus,
value if value % 7 == 0 => JumpHopTileType::Accent,
value if value % 3 == 0 => JumpHopTileType::Target,
_ => JumpHopTileType::Normal,
}
}
fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str {
match tile_type {
JumpHopTileType::Start => "tile-start",
JumpHopTileType::Normal => "tile-normal",
JumpHopTileType::Target => "tile-target",
JumpHopTileType::Finish => "tile-finish",
JumpHopTileType::Bonus => "tile-bonus",
JumpHopTileType::Accent => "tile-accent",
}
fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String {
format!("tile-{:02}", tile_index + 1)
}
#[allow(clippy::too_many_arguments)]
@@ -648,7 +740,7 @@ async fn persist_jump_hop_tile_asset(
tile_slice: JumpHopTileAtlasSlice,
request_context: &RequestContext,
) -> Result<JumpHopTileAsset, Response> {
let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type);
let slot = jump_hop_tile_asset_slot_name(tile_index);
let image = crate::openai_image_generation::DownloadedOpenAiImage {
bytes: tile_slice.bytes,
mime_type: "image/png".to_string(),
@@ -658,7 +750,7 @@ async fn persist_jump_hop_tile_asset(
state,
owner_user_id,
profile_id,
slot,
slot.as_str(),
&format!(
"跳一跳地块切片 {}{}",
tile_index + 1,
@@ -674,10 +766,13 @@ async fn persist_jump_hop_tile_asset(
Ok(JumpHopTileAsset {
tile_type: tile_slice.tile_type,
tile_id: Some(slot),
image_src: persisted.image_src,
image_object_key: persisted.image_object_key,
asset_object_id: persisted.asset_object_id,
source_atlas_cell: tile_slice.source_atlas_cell,
atlas_row: Some((tile_index as u32 / JUMP_HOP_TILE_ATLAS_COLS) + 1),
atlas_col: Some((tile_index as u32 % JUMP_HOP_TILE_ATLAS_COLS) + 1),
visual_width: 256,
visual_height: 192,
top_surface_radius: 42.0,
@@ -685,6 +780,22 @@ async fn persist_jump_hop_tile_asset(
})
}
fn build_jump_hop_default_character_asset(
profile_id: &str,
theme_text: &str,
) -> JumpHopCharacterAsset {
JumpHopCharacterAsset {
asset_id: format!("{profile_id}-builtin-character"),
image_src: "builtin://jump-hop/default-character".to_string(),
image_object_key: String::new(),
asset_object_id: format!("{profile_id}-builtin-character"),
generation_provider: "builtin-three".to_string(),
prompt: format!("内置默认 3D 角色:{}", theme_text.trim()),
width: 0,
height: 0,
}
}
async fn persist_jump_hop_generated_image_asset(
state: &AppState,
owner_user_id: &str,
@@ -868,17 +979,26 @@ fn build_jump_hop_work_play_tracking_draft(
}
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title);
JumpHopDraftResponse {
template_id: JUMP_HOP_TEMPLATE_ID.to_string(),
template_name: JUMP_HOP_TEMPLATE_NAME.to_string(),
profile_id: None,
work_title: payload.work_title.trim().to_string(),
work_description: payload.work_description.trim().to_string(),
theme_text: theme_text.clone(),
work_title: clean_or_default(&payload.work_title, &theme_text),
work_description: clean_or_default(
&payload.work_description,
&format!("{theme_text}主题的俯视角跳跃作品"),
),
theme_tags: normalize_tags(payload.theme_tags.clone()),
difficulty: payload.difficulty.clone(),
style_preset: payload.style_preset.clone(),
character_prompt: payload.character_prompt.trim().to_string(),
tile_prompt: payload.tile_prompt.trim().to_string(),
default_character: Some(default_jump_hop_character()),
character_prompt: clean_or_default(&payload.character_prompt, "内置默认 3D 角色"),
tile_prompt: clean_or_default(
&payload.tile_prompt,
&format!("{theme_text}主题的俯视角清爽游戏化立体感平台素材"),
),
end_mood_prompt: payload
.end_mood_prompt
.as_ref()
@@ -897,13 +1017,7 @@ fn validate_workspace_request(
request_context: &RequestContext,
payload: &JumpHopWorkspaceCreateRequest,
) -> Result<(), Response> {
ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
ensure_non_empty(
request_context,
&payload.character_prompt,
"characterPrompt",
)?;
ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?;
ensure_non_empty(request_context, &payload.theme_text, "themeText")?;
if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID {
return Err(jump_hop_error_response(
request_context,
@@ -917,6 +1031,32 @@ fn validate_workspace_request(
Ok(())
}
fn normalize_theme_text(theme_text: &str, fallback: &str) -> String {
clean_or_default(theme_text, fallback)
.chars()
.take(60)
.collect::<String>()
}
fn clean_or_default(value: &str, fallback: &str) -> String {
let value = value.trim();
if value.is_empty() {
fallback.trim().to_string()
} else {
value.to_string()
}
}
fn default_jump_hop_character() -> shared_contracts::jump_hop::JumpHopDefaultCharacter {
shared_contracts::jump_hop::JumpHopDefaultCharacter {
character_id: "jump-hop-default-runner".to_string(),
display_name: "默认角色".to_string(),
model_kind: "builtin-three".to_string(),
body_color: "#f59e0b".to_string(),
accent_color: "#2563eb".to_string(),
}
}
fn ensure_non_empty(
request_context: &RequestContext,
value: &str,
@@ -1020,32 +1160,82 @@ mod tests {
use super::*;
#[test]
fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() {
let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块");
fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
assert!(prompt.contains("2行*3"));
assert!(prompt.contains("第1行第1列start 起点地块"));
assert!(prompt.contains("第2行第3列accent 视觉强调地块"));
assert!(prompt.contains("五行五"));
assert!(prompt.contains("共25个"));
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("English guardrail"));
assert!(!prompt.contains("按5行*5列"));
assert!(!prompt.contains("2D地板图标"));
assert!(!prompt.contains("清爽自然的游戏图标"));
assert!(!prompt.contains("边缘厚度暗示"));
assert!(!prompt.contains("统一投影"));
assert!(!prompt.contains("每个物品生成"));
assert!(!prompt.contains("不同视图"));
}
#[test]
fn jump_hop_tile_atlas_slices_one_png_per_tile_type() {
let width = 300;
let height = 200;
let colors = [
[220, 24, 24, 255],
[240, 150, 32, 255],
[248, 220, 72, 255],
[52, 168, 84, 255],
[38, 132, 255, 255],
[156, 92, 220, 255],
];
fn jump_hop_tile_atlas_negative_prompt_blocks_oily_and_square_shadow_artifacts() {
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
assert!(negative_prompt.contains("油亮高光"));
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("方形底板"));
}
#[test]
fn jump_hop_tile_slice_keeps_largest_alpha_component() {
let mut image = image::RgbaImage::from_pixel(80, 80, image::Rgba([0, 0, 0, 0]));
for y in 12..52 {
for x in 12..52 {
image.put_pixel(x, y, image::Rgba([220, 70, 50, 255]));
}
}
for y in 68..74 {
for x in 36..42 {
image.put_pixel(x, y, image::Rgba([40, 190, 80, 255]));
}
}
let cleaned = keep_jump_hop_largest_alpha_component(image::DynamicImage::ImageRgba8(image))
.to_rgba8();
assert_eq!(cleaned.get_pixel(20, 20).0[3], 255);
assert_eq!(
cleaned.get_pixel(38, 70).0[3],
0,
"相邻格侵入的小碎片不应扩大当前地块切片边界"
);
}
#[test]
fn jump_hop_tile_atlas_slices_twenty_five_png_tiles() {
let width = 500;
let height = 500;
let mut atlas = image::RgbaImage::new(width, height);
for row in 0..2 {
for col in 0..3 {
let color = image::Rgba(colors[row * 3 + col]);
for row in 0..5 {
for col in 0..5 {
let index = row * 5 + col;
let color = image::Rgba([
40 + index as u8 * 3,
24 + index as u8 * 5,
120 + index as u8 * 2,
255,
]);
for y in row as u32 * 100..(row as u32 + 1) * 100 {
for x in col as u32 * 100..(col as u32 + 1) * 100 {
atlas.put_pixel(x, y, color);
@@ -1065,20 +1255,48 @@ mod tests {
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len());
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT);
for (index, slice) in slices.iter().enumerate() {
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
assert_eq!(
slice.source_atlas_cell,
format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1)
format!("row-{}-col-{}", index / 5 + 1, index % 5 + 1)
);
let decoded = image::load_from_memory(slice.bytes.as_slice())
.expect("tile slice should decode")
.to_rgba8();
assert_eq!(
decoded.dimensions(),
(116, 116),
"跳一跳地块切片应在 100x100 单元格外补透明安全边"
);
let color = [
40 + index as u8 * 3,
24 + index as u8 * 5,
120 + index as u8 * 2,
255,
];
assert!(
decoded.pixels().any(|pixel| pixel.0 == colors[index]),
decoded.pixels().any(|pixel| pixel.0 == color),
"第 {index} 个地块切片应保留对应格子的主体颜色"
);
}
}
#[test]
fn jump_hop_tile_asset_slots_are_unique_for_twenty_five_slices() {
let slots = (0..JUMP_HOP_TILE_ITEM_COUNT)
.map(jump_hop_tile_asset_slot_name)
.collect::<Vec<_>>();
let unique_slots = slots
.iter()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
unique_slots.len(),
JUMP_HOP_TILE_ITEM_COUNT,
"25 个地块切片必须写入 25 个独立 slot/path不能按重复的 tile_type 互相覆盖"
);
}
}

View File

@@ -755,12 +755,11 @@ async fn generate_match3d_material_sheet_from_level_scene(
config: &Match3DConfigJson,
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let prompt = build_match3d_item_spritesheet_prompt();
let reference = load_match3d_level_scene_reference_image(state, background_asset).await?;

View File

@@ -304,12 +304,11 @@ pub(super) async fn generate_match3d_cover_image_asset(
reference_image_srcs: Vec<String>,
) -> Result<Match3DAssetUpload, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let cover_prompt = build_match3d_cover_generation_prompt(config, prompt);
let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit(
@@ -459,12 +458,11 @@ pub(super) async fn generate_match3d_level_asset_bundle(
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let level_scene_prompt = build_match3d_level_scene_generation_prompt(config);
@@ -607,12 +605,11 @@ pub(super) async fn generate_match3d_container_image(
prompt: &str,
) -> Result<Match3DGeneratedBackgroundAsset, AppError> {
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_match3d_container_reference_image()?;
let container_prompt = build_match3d_container_generation_prompt(config, prompt);

View File

@@ -7,8 +7,9 @@ use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
jump_hop::{
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run,
get_jump_hop_leaderboard, get_jump_hop_runtime_work, get_jump_hop_session,
jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work,
restart_jump_hop_run, start_jump_hop_run,
},
state::AppState,
};
@@ -54,6 +55,13 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/jump-hop/works/{profile_id}",
get(get_jump_hop_runtime_work),
)
.route(
"/api/runtime/jump-hop/works/{profile_id}/leaderboard",
get(get_jump_hop_leaderboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/jump-hop/runs",
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(

View File

@@ -310,12 +310,11 @@ pub(crate) async fn generate_puzzle_level_asset_bundle(
level_name: &str,
puzzle_image: &PuzzleDownloadedImage,
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
let settings = require_puzzle_vector_engine_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(session_id.to_string()),
);
let settings = require_puzzle_vector_engine_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(session_id.to_string()),
);
let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?;
let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image);
let scene_generated = create_puzzle_vector_engine_image_generation(

View File

@@ -117,11 +117,9 @@ impl PuzzleVectorEngineSettings {
) -> Self {
self.external_api_audit_user_id = user_id;
self.external_api_audit_profile_id = profile_id;
self.external_api_audit_request_id =
Some(request_context.request_id().to_string());
self.external_api_audit_request_id = Some(request_context.request_id().to_string());
self
}
}
pub(crate) struct ParsedPuzzleImageDataUrl {

View File

@@ -398,12 +398,11 @@ async fn generate_square_hole_image_data_url(
size: &str,
failure_context: &str,
) -> Result<String, AppError> {
let settings = require_openai_image_settings(state)?
.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
Some(owner_user_id.to_string()),
Some(profile_id.to_string()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,