2390 lines
100 KiB
Rust
2390 lines
100 KiB
Rust
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||
http::{HeaderName, StatusCode, header},
|
||
response::Response,
|
||
};
|
||
use module_assets::{
|
||
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||
generate_asset_binding_id, generate_asset_object_id,
|
||
};
|
||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::jump_hop::{
|
||
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
|
||
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
|
||
JumpHopLeaderboardEntry, JumpHopLeaderboardResponse, JumpHopRestartRunRequest,
|
||
JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse,
|
||
JumpHopStartRunRequest, JumpHopTileAsset, JumpHopTileFaceAsset, JumpHopTileFaceAssets,
|
||
JumpHopTileFaceKey, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
|
||
};
|
||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||
use spacetime_client::SpacetimeClientError;
|
||
use std::{
|
||
collections::BTreeMap,
|
||
time::{SystemTime, UNIX_EPOCH},
|
||
};
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||
generated_image_assets::{
|
||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||
normalize_generated_image_asset_mime,
|
||
},
|
||
http_error::AppError,
|
||
openai_image_generation::{
|
||
build_openai_image_http_client, create_openai_image_generation,
|
||
require_openai_image_settings,
|
||
},
|
||
request_context::RequestContext,
|
||
state::AppState,
|
||
wechat::subscribe_message::{
|
||
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
||
send_generation_result_subscribe_message_after_completion,
|
||
},
|
||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||
};
|
||
|
||
const JUMP_HOP_TILE_ITEM_COUNT: usize = 18;
|
||
|
||
const JUMP_HOP_PROVIDER: &str = "jump-hop";
|
||
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
|
||
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
||
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
|
||
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
||
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
||
const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 6;
|
||
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
|
||
const JUMP_HOP_TILE_UV_FACE_ROWS: u32 = 3;
|
||
const JUMP_HOP_TILE_UV_FACE_COLS: u32 = 4;
|
||
const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF";
|
||
const JUMP_HOP_TILE_ATLAS_IMAGE_SIZE: &str = "1024*1536";
|
||
const JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH: u32 = 1024;
|
||
const JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT: u32 = 1536;
|
||
const JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE: u32 = 256;
|
||
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 {
|
||
tile_type: JumpHopTileType,
|
||
source_atlas_cell: String,
|
||
faces: JumpHopTileFaceSlices,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
struct JumpHopTileFaceSlice {
|
||
face: JumpHopTileFaceKey,
|
||
source_atlas_cell: String,
|
||
bytes: Vec<u8>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
struct JumpHopTileFaceSlices {
|
||
top: JumpHopTileFaceSlice,
|
||
front: JumpHopTileFaceSlice,
|
||
right: JumpHopTileFaceSlice,
|
||
back: JumpHopTileFaceSlice,
|
||
left: JumpHopTileFaceSlice,
|
||
bottom: JumpHopTileFaceSlice,
|
||
}
|
||
|
||
pub async fn create_jump_hop_session(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<JumpHopWorkspaceCreateRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
||
validate_workspace_request(&request_context, &payload)?;
|
||
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let session_id = build_prefixed_uuid_id("jump-hop-session-");
|
||
let now = current_utc_micros();
|
||
let draft = build_jump_hop_draft(&payload);
|
||
let session = JumpHopSessionSnapshotResponse {
|
||
session_id,
|
||
owner_user_id,
|
||
status: JumpHopGenerationStatus::Draft,
|
||
draft: Some(draft),
|
||
created_at: format_timestamp_micros(now),
|
||
updated_at: format_timestamp_micros(now),
|
||
};
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopSessionResponse {
|
||
session: state
|
||
.spacetime_client()
|
||
.create_jump_hop_session(session)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_session(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let session = state
|
||
.spacetime_client()
|
||
.get_jump_hop_session(session_id, owner_user_id)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopSessionResponse { session },
|
||
))
|
||
}
|
||
|
||
pub async fn execute_jump_hop_action(
|
||
State(state): State<AppState>,
|
||
Path(session_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
payload: Result<Json<JumpHopActionRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?;
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let mut payload = payload;
|
||
let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft);
|
||
let generation_points_cost = if is_compile_draft {
|
||
resolve_jump_hop_generation_points_cost(&state).await
|
||
} else {
|
||
0
|
||
};
|
||
let result = async {
|
||
maybe_generate_jump_hop_assets(
|
||
&state,
|
||
&request_context,
|
||
session_id.as_str(),
|
||
owner_user_id.as_str(),
|
||
&mut payload,
|
||
)
|
||
.await?;
|
||
state
|
||
.spacetime_client()
|
||
.execute_jump_hop_action(session_id, owner_user_id.clone(), payload)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})
|
||
}
|
||
.await;
|
||
|
||
match result {
|
||
Ok(response) => {
|
||
if is_compile_draft && response.session.status == JumpHopGenerationStatus::Ready {
|
||
send_generation_result_subscribe_message_after_completion(
|
||
&state,
|
||
GenerationResultSubscribeMessage {
|
||
owner_user_id,
|
||
task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()),
|
||
work_name: response
|
||
.session
|
||
.draft
|
||
.as_ref()
|
||
.map(|draft| draft.work_title.clone()),
|
||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||
consumed_points: generation_points_cost,
|
||
completed_at_micros: current_utc_micros(),
|
||
page: Some("/pages/web-view/index".to_string()),
|
||
},
|
||
)
|
||
.await;
|
||
}
|
||
Ok(json_success_body(Some(&request_context), response))
|
||
}
|
||
Err(response) => {
|
||
if is_compile_draft && response.status().is_server_error() {
|
||
send_generation_result_subscribe_message_after_completion(
|
||
&state,
|
||
GenerationResultSubscribeMessage {
|
||
owner_user_id,
|
||
task_name: Some(JUMP_HOP_TEMPLATE_NAME.to_string()),
|
||
work_name: None,
|
||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||
consumed_points: 0,
|
||
completed_at_micros: current_utc_micros(),
|
||
page: Some("/pages/web-view/index".to_string()),
|
||
},
|
||
)
|
||
.await;
|
||
}
|
||
Err(response)
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 {
|
||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||
state,
|
||
JUMP_HOP_TEMPLATE_ID,
|
||
u64::from(shared_contracts::creation_entry_config::DEFAULT_UNIFIED_CREATION_MUD_POINT_COST),
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub async fn publish_jump_hop_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let work = state
|
||
.spacetime_client()
|
||
.publish_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorkMutationResponse { item: work },
|
||
))
|
||
}
|
||
|
||
pub async fn list_jump_hop_works(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let works = state
|
||
.spacetime_client()
|
||
.list_jump_hop_works(authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorksResponse {
|
||
items: works.into_iter().map(|work| work.summary).collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_work_detail(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_work_profile(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorkDetailResponse { item: work },
|
||
))
|
||
}
|
||
|
||
pub async fn delete_jump_hop_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let works = state
|
||
.spacetime_client()
|
||
.delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopWorksResponse {
|
||
items: works.into_iter().map(|work| work.summary).collect(),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_jump_hop_runtime_work(
|
||
State(state): State<AppState>,
|
||
Path(profile_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &profile_id, "profileId")?;
|
||
let work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_runtime_work(profile_id)
|
||
.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),
|
||
JumpHopWorkDetailResponse { item: 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
|
||
.into_iter()
|
||
.map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry))
|
||
.collect(),
|
||
viewer_best: leaderboard
|
||
.viewer_best
|
||
.map(|entry| resolve_jump_hop_leaderboard_entry_display_name(&state, entry)),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn start_jump_hop_run(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
|
||
) -> 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()
|
||
.is_some_and(is_jump_hop_draft_runtime_mode);
|
||
let owner_user_id = principal.subject().to_string();
|
||
let principal_kind = principal.kind().as_str();
|
||
let run = state
|
||
.spacetime_client()
|
||
.start_jump_hop_run(payload, owner_user_id.clone())
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
&request_context,
|
||
JUMP_HOP_RUNTIME_PROVIDER,
|
||
map_jump_hop_client_error(error),
|
||
)
|
||
})?;
|
||
|
||
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,
|
||
})),
|
||
)
|
||
.await;
|
||
}
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
JumpHopRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn jump_hop_run_jump(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||
let owner_user_id = principal.subject().to_string();
|
||
let run = state
|
||
.spacetime_client()
|
||
.jump_hop_run_jump(run_id, owner_user_id, payload)
|
||
.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),
|
||
JumpHopJumpResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn restart_jump_hop_run(
|
||
State(state): State<AppState>,
|
||
Path(run_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(principal): Extension<RuntimePrincipal>,
|
||
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
|
||
let owner_user_id = principal.subject().to_string();
|
||
let run = state
|
||
.spacetime_client()
|
||
.restart_jump_hop_run(run_id, owner_user_id, payload)
|
||
.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),
|
||
JumpHopRunResponse { run },
|
||
))
|
||
}
|
||
|
||
pub async fn list_jump_hop_gallery(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
let gallery = state
|
||
.spacetime_client()
|
||
.list_jump_hop_gallery()
|
||
.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), gallery))
|
||
}
|
||
|
||
pub async fn get_jump_hop_gallery_detail(
|
||
State(state): State<AppState>,
|
||
Path(public_work_code): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
) -> Result<Json<Value>, Response> {
|
||
ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?;
|
||
let work = state
|
||
.spacetime_client()
|
||
.get_jump_hop_gallery_detail(public_work_code)
|
||
.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),
|
||
JumpHopGalleryDetailResponse { item: work },
|
||
))
|
||
}
|
||
|
||
async fn maybe_generate_jump_hop_assets(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
_session_id: &str,
|
||
owner_user_id: &str,
|
||
payload: &mut JumpHopActionRequest,
|
||
) -> Result<(), Response> {
|
||
if !matches!(
|
||
payload.action_type,
|
||
JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles
|
||
) {
|
||
return Ok(());
|
||
}
|
||
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);
|
||
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));
|
||
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 && has_back_button_asset {
|
||
return Ok(());
|
||
}
|
||
let profile_id = payload
|
||
.profile_id
|
||
.as_ref()
|
||
.map(|value| value.trim())
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToString::to_string)
|
||
.unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-"));
|
||
payload.profile_id = Some(profile_id.clone());
|
||
|
||
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()),
|
||
)
|
||
})
|
||
.map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
let http_client = build_openai_image_http_client(&settings).map_err(|error| {
|
||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||
})?;
|
||
|
||
let theme_text = payload
|
||
.theme_text
|
||
.as_deref()
|
||
.or(payload.work_title.as_deref())
|
||
.unwrap_or("跳一跳")
|
||
.to_string();
|
||
let tile_prompt = payload
|
||
.tile_prompt
|
||
.clone()
|
||
.unwrap_or_else(|| theme_text.clone());
|
||
|
||
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,
|
||
&[],
|
||
"跳一跳背景底图生成失败",
|
||
)
|
||
.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_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());
|
||
let tile_generated = create_openai_image_generation(
|
||
&http_client,
|
||
&settings,
|
||
sheet_prompt.as_str(),
|
||
Some(build_jump_hop_tile_atlas_negative_prompt()),
|
||
JUMP_HOP_TILE_ATLAS_IMAGE_SIZE,
|
||
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": "跳一跳地板贴图图集生成成功但未返回图片。",
|
||
})),
|
||
)
|
||
})?;
|
||
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,
|
||
JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH,
|
||
JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT,
|
||
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.as_str(),
|
||
));
|
||
}
|
||
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 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();
|
||
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 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按钮、返回按钮、左上角图标、右上角按钮、底部按钮、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 = 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.as_str()
|
||
} else {
|
||
sanitized_tile_prompt.as_str()
|
||
};
|
||
|
||
format!(
|
||
"生成一张1024x1536竖版图片,主题为“{theme_text}”。\n画面只包含18个用于跳一跳地板的立方体主题物体 UV 展开包装图,按三列六行均匀排布;每个大单元格代表一个完整的 1x1x1 立方体方块物体,运行态会把该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上。\n画面内容是{subject_text}。这是一张 cube object UV unwrap atlas / 立方体主题物体六面展开图集,不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满,也不是已经渲染好的 3D 方块成品、游戏界面或图标集页面。\n每个大单元格内部必须使用固定 4列x3行 UV 展开结构,只有以下六个位置有贴图,其它位置保持纯洋红安全色:第1行第2列是 top;第2行第1列是 left;第2行第2列是 front;第2行第3列是 right;第2行第4列是 back;第3行第2列是 bottom。不要改变顺序,不要旋转面,不要把六个面画成一张连续透视图。\n每个方块都必须表现为“一个完整主题物体被塑造成 1x1x1 立方体后的六面包装”,六个面要属于同一个物体并能组合成完整方块造型;top/front/right/back/left/bottom 之间的颜色、边缘纹理、切面、果皮、籽点、条纹、果柄和叶片必须连续一致,不能六面各画互不相关的图案,也不能把同一张纹理重复六次。\n水果主题要生成18种可一眼辨认的方块水果 UV:方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴等;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质。\n每个面都是满版不透明正方形贴图 / full-bleed opaque square face texture:四角、边缘和中心都要有可识别内容,不留透明、不留空白、不留实底背景;允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点,但不要把一个小水果、小叶片、小石头或小物体放在面中央,也不要画小贴纸、小图标、徽章或孤立主体。\n这不是透视渲染图:不要画摄像机视角、透视块、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或高光光斑;真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成。每个面贴图在运行态会以约45度下压视角和较小尺寸显示,所以必须使用大色块、高对比、粗线条和简单图形,保证在64x64缩略图里仍能分辨主题物体身份。\n排布必须安全:18个大单元格必须完整落在自己的三列六行网格内,不能跨格、贴边串色或进入相邻方块;大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 作为切图安全色,允许极细纯洋红安全缝,但不要画可见网格线、边框、编号、face label 或裁切标记。\n贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色,但不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的纯洋红色;贴图边缘不得有洋红描边、紫色底边、粉色脏边、彩色光晕或发光边。\n禁止文字、Logo、水印、UI按钮、标题、角标、装饰边框、face label、top/front/right/back/left/bottom文字、背包、装备栏、菜单、角色、完整场景、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、小型果切贴纸、小型橙片贴纸、小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理、纯叶脉纹理、纯颜色块、透明背景、留白、3D平台、圆台、底座、托盘、物体摆在平台上、透视地块、正面30度物体图、鸟瞰地图块、落地投影、接触阴影、方形阴影、白底、灰底、黑底。\nEnglish guardrail: one vertical 1024x1536 image, exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas; each large cell is one complete cube object skin with a fixed 4x3 UV net: row1 col2 top, row2 col1 left, row2 col2 front, row2 col3 right, row2 col4 back, row3 col2 bottom; empty UV cells and gutters are solid magenta {JUMP_HOP_TILE_ATLAS_KEY_HEX}; generate six different face textures that stitch into one recognizable cubified theme object, not one repeated texture and not unrelated icons; fruit theme must create 18 distinct cubified fruits with continuous identity marks across faces; no text labels, no perspective cube render, no baked lighting, no baked shadows, no pedestal, no floor slab, no small centered stickers, no generic flat material; every face is full-bleed opaque square texture and remains recognizable at 64x64 in a 45-degree game camera."
|
||
)
|
||
}
|
||
|
||
fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str {
|
||
"文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界、可见网格线、编号、裁切标记"
|
||
}
|
||
|
||
fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String {
|
||
let mut value = tile_prompt.trim().to_string();
|
||
if value.is_empty() {
|
||
return value;
|
||
}
|
||
value = replace_jump_hop_pokemon_prompt_terms(value.as_str());
|
||
|
||
const REPLACEMENTS: [(&str, &str); 24] = [
|
||
("正面30度视角主题物体图集", "3D立方体主题身份方块包装图集"),
|
||
("物体本身作为跳跃落点", "主题物体方块化后作为立方体包装"),
|
||
("3D立方体主题方块包装图集", "3D立方体主题身份方块包装图集"),
|
||
("立方体主题方块包装图集", "立方体主题身份方块包装图集"),
|
||
("俯视角", "正交平面"),
|
||
("正上方视角", "正交平面"),
|
||
("鸟瞰视角", "正交平面"),
|
||
("平铺俯拍", "正交平面"),
|
||
("可落脚平台素材", "立方体主题身份方块包装贴图"),
|
||
(
|
||
"清爽游戏化立体感平台素材",
|
||
"清爽游戏化立方体主题身份方块包装贴图",
|
||
),
|
||
("平台裸素材", "立方体主题身份方块包装贴图"),
|
||
("每格一个完整平台", "每格一张完整身份方块包装贴图"),
|
||
("主题物体图集", "立方体主题身份方块包装图集"),
|
||
("主题物体", "主题身份方块包装"),
|
||
("平台素材", "立方体身份方块包装贴图"),
|
||
("可落脚平台", "立方体主题身份方块包装"),
|
||
("可落脚", "可贴图"),
|
||
("平台", "立方体地板"),
|
||
("跳台", "立方体地板"),
|
||
("地块", "身份方块包装贴图"),
|
||
("地砖", "身份方块包装贴图"),
|
||
("底座", "承托物"),
|
||
("底盘", "承托物"),
|
||
("地板", "立方体地板"),
|
||
];
|
||
|
||
for (from, to) in REPLACEMENTS {
|
||
value = value.replace(from, to);
|
||
}
|
||
while value.contains("立方体立方体") {
|
||
value = value.replace("立方体立方体", "立方体");
|
||
}
|
||
|
||
value
|
||
}
|
||
|
||
fn slice_jump_hop_tile_atlas(
|
||
image: &crate::openai_image_generation::DownloadedOpenAiImage,
|
||
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
||
let source = image::load_from_memory(image.bytes.as_slice())
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("跳一跳地板贴图图集解码失败:{error}"),
|
||
}))
|
||
})?
|
||
.to_rgba8();
|
||
let width = source.width();
|
||
let height = source.height();
|
||
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
if cell_width == 0 || cell_height == 0 {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": "跳一跳地板贴图图集尺寸过小,无法切割。",
|
||
})),
|
||
);
|
||
}
|
||
|
||
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;
|
||
let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||
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,
|
||
)?;
|
||
slices.push(JumpHopTileAtlasSlice {
|
||
tile_type: jump_hop_tile_type_by_index(index),
|
||
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
|
||
faces,
|
||
});
|
||
}
|
||
|
||
Ok(slices)
|
||
}
|
||
|
||
fn slice_jump_hop_tile_uv_faces(
|
||
source: &image::RgbaImage,
|
||
tile_x: u32,
|
||
tile_y: u32,
|
||
tile_width: u32,
|
||
tile_height: u32,
|
||
atlas_row: u32,
|
||
atlas_col: u32,
|
||
) -> Result<JumpHopTileFaceSlices, AppError> {
|
||
let face_side = (tile_width / JUMP_HOP_TILE_UV_FACE_COLS)
|
||
.min(tile_height / JUMP_HOP_TILE_UV_FACE_ROWS)
|
||
.max(1);
|
||
let uv_width = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_COLS);
|
||
let uv_height = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_ROWS);
|
||
let uv_x = tile_x.saturating_add(tile_width.saturating_sub(uv_width) / 2);
|
||
let uv_y = tile_y.saturating_add(tile_height.saturating_sub(uv_height) / 2);
|
||
|
||
Ok(JumpHopTileFaceSlices {
|
||
top: slice_jump_hop_tile_uv_face(
|
||
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,
|
||
)?,
|
||
right: slice_jump_hop_tile_uv_face(
|
||
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,
|
||
)?,
|
||
left: slice_jump_hop_tile_uv_face(
|
||
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,
|
||
)?,
|
||
})
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn slice_jump_hop_tile_uv_face(
|
||
source: &image::RgbaImage,
|
||
uv_x: u32,
|
||
uv_y: u32,
|
||
face_side: u32,
|
||
atlas_row: u32,
|
||
atlas_col: u32,
|
||
face: JumpHopTileFaceKey,
|
||
face_col: u32,
|
||
face_row: u32,
|
||
) -> Result<JumpHopTileFaceSlice, AppError> {
|
||
let cleaned = crop_jump_hop_tile_texture_cell(
|
||
source,
|
||
uv_x.saturating_add(face_col.saturating_mul(face_side)),
|
||
uv_y.saturating_add(face_row.saturating_mul(face_side)),
|
||
face_side,
|
||
face_side,
|
||
);
|
||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||
cleaned
|
||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||
"message": format!("跳一跳地板 UV 面贴图切割失败:{error}"),
|
||
}))
|
||
})?;
|
||
let face_label = jump_hop_tile_face_key_label(&face);
|
||
|
||
Ok(JumpHopTileFaceSlice {
|
||
face,
|
||
source_atlas_cell: format!(
|
||
"row-{}-col-{}/{}",
|
||
atlas_row + 1,
|
||
atlas_col + 1,
|
||
face_label
|
||
),
|
||
bytes: cursor.into_inner(),
|
||
})
|
||
}
|
||
|
||
fn crop_jump_hop_tile_texture_cell(
|
||
source: &image::RgbaImage,
|
||
x0: u32,
|
||
y0: u32,
|
||
width: u32,
|
||
height: u32,
|
||
) -> image::DynamicImage {
|
||
let min_side = width.min(height).max(1);
|
||
let safe_inset = (min_side / 32).clamp(2, 12);
|
||
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);
|
||
let crop_height = height.saturating_sub(inset_y.saturating_mul(2)).max(1);
|
||
let cropped = image::imageops::crop_imm(
|
||
source,
|
||
x0.saturating_add(inset_x),
|
||
y0.saturating_add(inset_y),
|
||
crop_width,
|
||
crop_height,
|
||
)
|
||
.to_image();
|
||
let mut resized = image::imageops::resize(
|
||
&cropped,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
image::imageops::FilterType::Lanczos3,
|
||
);
|
||
normalize_jump_hop_tile_texture_pixels(&mut resized);
|
||
image::DynamicImage::ImageRgba8(resized)
|
||
}
|
||
|
||
fn normalize_jump_hop_tile_texture_pixels(image: &mut image::RgbaImage) {
|
||
let fallback = average_jump_hop_tile_texture_color(image);
|
||
for pixel in image.pixels_mut() {
|
||
if is_jump_hop_tile_texture_key_pixel(*pixel) {
|
||
*pixel = fallback;
|
||
}
|
||
pixel.0[3] = 255;
|
||
}
|
||
}
|
||
|
||
fn average_jump_hop_tile_texture_color(image: &image::RgbaImage) -> image::Rgba<u8> {
|
||
let mut total_r = 0u64;
|
||
let mut total_g = 0u64;
|
||
let mut total_b = 0u64;
|
||
let mut count = 0u64;
|
||
|
||
for pixel in image.pixels() {
|
||
if is_jump_hop_tile_texture_key_pixel(*pixel) {
|
||
continue;
|
||
}
|
||
total_r += pixel.0[0] as u64;
|
||
total_g += pixel.0[1] as u64;
|
||
total_b += pixel.0[2] as u64;
|
||
count += 1;
|
||
}
|
||
|
||
if count == 0 {
|
||
return image::Rgba([148, 163, 184, 255]);
|
||
}
|
||
|
||
image::Rgba([
|
||
(total_r / count) as u8,
|
||
(total_g / count) as u8,
|
||
(total_b / count) as u8,
|
||
255,
|
||
])
|
||
}
|
||
|
||
fn is_jump_hop_tile_texture_key_pixel(pixel: image::Rgba<u8>) -> bool {
|
||
let [red, green, blue, _] = pixel.0;
|
||
let red_delta = red.abs_diff(255) as u32;
|
||
let green_delta = green as u32;
|
||
let blue_delta = blue.abs_diff(255) as u32;
|
||
|
||
red_delta.saturating_mul(red_delta)
|
||
+ green_delta.saturating_mul(green_delta)
|
||
+ blue_delta.saturating_mul(blue_delta)
|
||
<= 24u32.saturating_mul(24)
|
||
}
|
||
|
||
fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
|
||
match index {
|
||
0 => JumpHopTileType::Start,
|
||
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_index: usize) -> String {
|
||
format!("tile-{:02}", tile_index + 1)
|
||
}
|
||
|
||
fn jump_hop_tile_face_key_label(face: &JumpHopTileFaceKey) -> &'static str {
|
||
match face {
|
||
JumpHopTileFaceKey::Top => "top",
|
||
JumpHopTileFaceKey::Front => "front",
|
||
JumpHopTileFaceKey::Right => "right",
|
||
JumpHopTileFaceKey::Back => "back",
|
||
JumpHopTileFaceKey::Left => "left",
|
||
JumpHopTileFaceKey::Bottom => "bottom",
|
||
}
|
||
}
|
||
|
||
fn jump_hop_tile_face_asset_slot_name(tile_index: usize, face: &JumpHopTileFaceKey) -> String {
|
||
format!(
|
||
"{}-{}",
|
||
jump_hop_tile_asset_slot_name(tile_index),
|
||
jump_hop_tile_face_key_label(face)
|
||
)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn persist_jump_hop_tile_asset(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
tile_index: usize,
|
||
tile_slice: JumpHopTileAtlasSlice,
|
||
request_context: &RequestContext,
|
||
) -> Result<JumpHopTileAsset, Response> {
|
||
let slot = jump_hop_tile_asset_slot_name(tile_index);
|
||
let top = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.top,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let front = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.front,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let right = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.right,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let back = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.back,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let left = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.left,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let bottom = persist_jump_hop_tile_face_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
tile_index,
|
||
tile_slice.faces.bottom,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
let primary = top.clone();
|
||
|
||
Ok(JumpHopTileAsset {
|
||
tile_type: tile_slice.tile_type,
|
||
tile_id: Some(slot),
|
||
image_src: primary.image_src.clone(),
|
||
image_object_key: primary.image_object_key.clone(),
|
||
asset_object_id: primary.asset_object_id.clone(),
|
||
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: JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
visual_height: JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
top_surface_radius: 42.0,
|
||
landing_radius: 34.0,
|
||
face_assets: Some(JumpHopTileFaceAssets {
|
||
top,
|
||
front,
|
||
right,
|
||
back,
|
||
left,
|
||
bottom,
|
||
}),
|
||
})
|
||
}
|
||
|
||
async fn persist_jump_hop_tile_face_asset(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
tile_index: usize,
|
||
face_slice: JumpHopTileFaceSlice,
|
||
request_context: &RequestContext,
|
||
) -> Result<JumpHopTileFaceAsset, Response> {
|
||
let slot = jump_hop_tile_face_asset_slot_name(tile_index, &face_slice.face);
|
||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes: face_slice.bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
};
|
||
let persisted = persist_jump_hop_generated_image_asset(
|
||
state,
|
||
owner_user_id,
|
||
profile_id,
|
||
slot.as_str(),
|
||
&format!(
|
||
"跳一跳地板 UV 面贴图 {}:{}",
|
||
tile_index + 1,
|
||
face_slice.source_atlas_cell
|
||
),
|
||
image,
|
||
LegacyAssetPrefix::JumpHopAssets,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
request_context,
|
||
)
|
||
.await?;
|
||
|
||
Ok(JumpHopTileFaceAsset {
|
||
face: face_slice.face,
|
||
asset_id: persisted.asset_id,
|
||
image_src: persisted.image_src,
|
||
image_object_key: persisted.image_object_key,
|
||
asset_object_id: persisted.asset_object_id,
|
||
generation_provider: persisted.generation_provider,
|
||
prompt: persisted.prompt,
|
||
width: persisted.width,
|
||
height: persisted.height,
|
||
source_atlas_cell: face_slice.source_atlas_cell,
|
||
})
|
||
}
|
||
|
||
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,
|
||
profile_id: &str,
|
||
slot: &str,
|
||
prompt: &str,
|
||
image: crate::openai_image_generation::DownloadedOpenAiImage,
|
||
prefix: LegacyAssetPrefix,
|
||
width: u32,
|
||
height: u32,
|
||
request_context: &RequestContext,
|
||
) -> Result<JumpHopCharacterAsset, Response> {
|
||
let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
|
||
let prepared =
|
||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||
prefix,
|
||
path_segments: vec![profile_id.to_string(), slot.to_string()],
|
||
file_stem: "image".to_string(),
|
||
image: GeneratedImageAssetDataUrl {
|
||
format: image_format,
|
||
bytes: image.bytes,
|
||
},
|
||
access: OssObjectAccess::Private,
|
||
metadata: GeneratedImageAssetAdapterMetadata {
|
||
asset_kind: Some(format!("jump-hop-{slot}")),
|
||
owner_user_id: Some(owner_user_id.to_string()),
|
||
entity_kind: Some("jump_hop_work".to_string()),
|
||
entity_id: Some(profile_id.to_string()),
|
||
slot: Some(slot.to_string()),
|
||
provider: Some("vector-engine".to_string()),
|
||
task_id: None,
|
||
},
|
||
extra_metadata: BTreeMap::new(),
|
||
})
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||
"provider": "generated-image-assets",
|
||
"message": format!("准备跳一跳图片资产上传请求失败:{error:?}"),
|
||
})),
|
||
)
|
||
})?;
|
||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||
let oss_client = state.oss_client().ok_or_else(|| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"reason": "OSS 未完成环境变量配置",
|
||
})),
|
||
)
|
||
})?;
|
||
let http_client = reqwest::Client::new();
|
||
let put_result = oss_client
|
||
.put_object(&http_client, prepared.request)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let head = oss_client
|
||
.head_object(
|
||
&http_client,
|
||
OssHeadObjectRequest {
|
||
object_key: put_result.object_key.clone(),
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "aliyun-oss",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let now_micros = current_utc_micros();
|
||
let asset_object_input = build_asset_object_upsert_input(
|
||
generate_asset_object_id(now_micros),
|
||
head.bucket,
|
||
head.object_key.clone(),
|
||
AssetObjectAccessPolicy::Private,
|
||
head.content_type.or(Some(persisted_mime_type)),
|
||
head.content_length,
|
||
head.etag,
|
||
format!("jump-hop-{slot}"),
|
||
None,
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "asset-object",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let asset_object = state
|
||
.spacetime_client()
|
||
.confirm_asset_object(asset_object_input)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
let binding_input = build_asset_entity_binding_input(
|
||
generate_asset_binding_id(now_micros),
|
||
asset_object.asset_object_id.clone(),
|
||
"jump_hop_work".to_string(),
|
||
profile_id.to_string(),
|
||
slot.to_string(),
|
||
format!("jump-hop-{slot}"),
|
||
Some(owner_user_id.to_string()),
|
||
Some(profile_id.to_string()),
|
||
now_micros,
|
||
)
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "asset-entity-binding",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
state
|
||
.spacetime_client()
|
||
.bind_asset_object_to_entity(binding_input)
|
||
.await
|
||
.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})?;
|
||
Ok(JumpHopCharacterAsset {
|
||
asset_id: format!("{profile_id}-{slot}-{now_micros}"),
|
||
image_src: put_result.legacy_public_path,
|
||
image_object_key: head.object_key,
|
||
asset_object_id: asset_object.asset_object_id,
|
||
generation_provider: "vector-engine".to_string(),
|
||
prompt: prompt.to_string(),
|
||
width,
|
||
height,
|
||
})
|
||
}
|
||
|
||
fn build_jump_hop_work_play_tracking_draft(
|
||
principal: &RuntimePrincipal,
|
||
work_id: impl Into<String>,
|
||
source_route: &'static str,
|
||
) -> WorkPlayTrackingDraft {
|
||
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_entry_display_name(
|
||
state: &AppState,
|
||
mut entry: JumpHopLeaderboardEntry,
|
||
) -> JumpHopLeaderboardEntry {
|
||
entry.display_name = resolve_jump_hop_leaderboard_display_name(state, &entry.player_id);
|
||
entry
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_display_name(state: &AppState, player_id: &str) -> String {
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup(player_id, |user_id| {
|
||
state
|
||
.auth_user_service()
|
||
.get_user_by_id(user_id)
|
||
.ok()
|
||
.flatten()
|
||
.and_then(|user| normalize_non_empty_text(user.display_name.as_str()))
|
||
})
|
||
}
|
||
|
||
fn resolve_jump_hop_leaderboard_display_name_with_lookup(
|
||
player_id: &str,
|
||
lookup_display_name: impl FnOnce(&str) -> Option<String>,
|
||
) -> String {
|
||
let player_id = player_id.trim();
|
||
if player_id.is_empty() {
|
||
return "玩家".to_string();
|
||
}
|
||
if player_id.starts_with("guest-runtime-") {
|
||
return "游客玩家".to_string();
|
||
}
|
||
|
||
lookup_display_name(player_id)
|
||
.and_then(|display_name| normalize_non_empty_text(display_name.as_str()))
|
||
.unwrap_or_else(|| "失效玩家".to_string())
|
||
}
|
||
|
||
fn normalize_non_empty_text(value: &str) -> Option<String> {
|
||
let value = value.trim();
|
||
if value.is_empty() {
|
||
None
|
||
} else {
|
||
Some(value.to_string())
|
||
}
|
||
}
|
||
|
||
fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool {
|
||
runtime_mode.trim().eq_ignore_ascii_case("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,
|
||
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(),
|
||
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}主题的3D立方体主题身份方块包装图集"),
|
||
),
|
||
end_mood_prompt: payload
|
||
.end_mood_prompt
|
||
.as_ref()
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty()),
|
||
character_asset: None,
|
||
tile_atlas_asset: None,
|
||
tile_assets: Vec::new(),
|
||
path: None,
|
||
cover_composite: None,
|
||
back_button_asset: None,
|
||
generation_status: JumpHopGenerationStatus::Draft,
|
||
}
|
||
}
|
||
|
||
fn validate_workspace_request(
|
||
request_context: &RequestContext,
|
||
payload: &JumpHopWorkspaceCreateRequest,
|
||
) -> Result<(), Response> {
|
||
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,
|
||
JUMP_HOP_CREATION_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": JUMP_HOP_PROVIDER,
|
||
"message": "templateId 必须为 jump-hop",
|
||
})),
|
||
));
|
||
}
|
||
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,
|
||
field: &str,
|
||
) -> Result<(), Response> {
|
||
if value.trim().is_empty() {
|
||
return Err(jump_hop_error_response(
|
||
request_context,
|
||
JUMP_HOP_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": JUMP_HOP_PROVIDER,
|
||
"field": field,
|
||
"message": format!("{field} 不能为空"),
|
||
})),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||
let mut normalized = Vec::new();
|
||
for tag in tags {
|
||
let tag = tag.trim();
|
||
if tag.is_empty() || normalized.iter().any(|item| item == tag) {
|
||
continue;
|
||
}
|
||
normalized.push(tag.to_string());
|
||
if normalized.len() >= 6 {
|
||
break;
|
||
}
|
||
}
|
||
normalized
|
||
}
|
||
|
||
fn jump_hop_json<T>(
|
||
payload: Result<Json<T>, JsonRejection>,
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
) -> Result<Json<T>, Response> {
|
||
payload.map_err(|error| {
|
||
jump_hop_error_response(
|
||
request_context,
|
||
provider,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": provider,
|
||
"message": error.to_string(),
|
||
})),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn map_jump_hop_client_error(error: SpacetimeClientError) -> AppError {
|
||
let status = match &error {
|
||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("不存在")
|
||
|| message.contains("not found")
|
||
|| message.contains("does not exist") =>
|
||
{
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("发布需要")
|
||
|| message.contains("不能为空")
|
||
|| message.contains("必须") =>
|
||
{
|
||
StatusCode::BAD_REQUEST
|
||
}
|
||
_ => StatusCode::BAD_GATEWAY,
|
||
};
|
||
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
fn jump_hop_error_response(
|
||
request_context: &RequestContext,
|
||
provider: &str,
|
||
error: AppError,
|
||
) -> Response {
|
||
let mut response = error.into_response_with_context(Some(request_context));
|
||
response.headers_mut().insert(
|
||
HeaderName::from_static("x-genarrative-provider"),
|
||
header::HeaderValue::from_str(provider)
|
||
.unwrap_or_else(|_| header::HeaderValue::from_static("jump-hop")),
|
||
);
|
||
response
|
||
}
|
||
|
||
fn current_utc_micros() -> i64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn jump_hop_draft_runtime_mode_detection_matches_client_normalization() {
|
||
assert!(is_jump_hop_draft_runtime_mode("draft"));
|
||
assert!(is_jump_hop_draft_runtime_mode(" DRAFT "));
|
||
assert!(!is_jump_hop_draft_runtime_mode("published"));
|
||
assert!(!is_jump_hop_draft_runtime_mode(""));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_leaderboard_display_name_never_falls_back_to_player_id() {
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup(" user-secret-1 ", |user_id| {
|
||
assert_eq!(user_id, "user-secret-1");
|
||
Some(" 陶泥儿玩家 ".to_string())
|
||
}),
|
||
"陶泥儿玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("guest-runtime-1", |_| {
|
||
panic!("guest player should not query account display name")
|
||
}),
|
||
"游客玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("user-missing", |_| None),
|
||
"失效玩家"
|
||
);
|
||
assert_eq!(
|
||
resolve_jump_hop_leaderboard_display_name_with_lookup("", |_| {
|
||
panic!("empty player id should not query account display name")
|
||
}),
|
||
"玩家"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_prompt_uses_dedicated_uv_unwrap_floor_layout() {
|
||
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
|
||
|
||
assert!(prompt.contains("生成一张1024x1536竖版图片"));
|
||
assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图"));
|
||
assert!(prompt.contains("按三列六行均匀排布"));
|
||
assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体"));
|
||
assert!(prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上"));
|
||
assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六面展开图集"));
|
||
assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满"));
|
||
assert!(prompt.contains("游戏界面或图标集页面"));
|
||
assert!(prompt.contains("固定 4列x3行 UV 展开结构"));
|
||
assert!(prompt.contains("第1行第2列是 top"));
|
||
assert!(prompt.contains("第2行第1列是 left"));
|
||
assert!(prompt.contains("第2行第2列是 front"));
|
||
assert!(prompt.contains("第2行第3列是 right"));
|
||
assert!(prompt.contains("第2行第4列是 back"));
|
||
assert!(prompt.contains("第3行第2列是 bottom"));
|
||
assert!(prompt.contains("不要改变顺序,不要旋转面"));
|
||
assert!(prompt.contains("六个面要属于同一个物体并能组合成完整方块造型"));
|
||
assert!(prompt.contains("不能六面各画互不相关的图案,也不能把同一张纹理重复六次"));
|
||
assert!(prompt.contains("水果主题要生成18种可一眼辨认的方块水果 UV"));
|
||
assert!(prompt.contains("方块苹果、方块香蕉、方块橙子、方块西瓜"));
|
||
assert!(prompt.contains("苹果需要果柄叶片跨 top/front"));
|
||
assert!(prompt.contains("香蕉需要剥皮条带跨 front/right"));
|
||
assert!(prompt.contains("西瓜需要红瓤黑籽和绿皮条纹在各面连续"));
|
||
assert!(prompt.contains("不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质"));
|
||
assert!(prompt.contains("full-bleed opaque square face texture"));
|
||
assert!(prompt.contains("四角、边缘和中心都要有可识别内容"));
|
||
assert!(prompt.contains("不留透明、不留空白、不留实底背景"));
|
||
assert!(prompt.contains("允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点"));
|
||
assert!(prompt.contains("不要把一个小水果、小叶片、小石头或小物体放在面中央"));
|
||
assert!(prompt.contains("这不是透视渲染图"));
|
||
assert!(prompt.contains("不要画摄像机视角、透视块、已烘焙侧壁"));
|
||
assert!(prompt.contains("真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成"));
|
||
assert!(prompt.contains("64x64缩略图里仍能分辨主题物体身份"));
|
||
assert!(prompt.contains("18个大单元格必须完整落在自己的三列六行网格内"));
|
||
assert!(prompt.contains("大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红"));
|
||
assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX));
|
||
assert!(
|
||
prompt.contains("贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色")
|
||
);
|
||
assert!(prompt.contains("不得使用接近"));
|
||
assert!(prompt.contains("贴图边缘不得有洋红描边、紫色底边、粉色脏边"));
|
||
assert!(prompt.contains("自然圆形水果、自然长条香蕉、非方块化完整水果"));
|
||
assert!(prompt.contains("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理"));
|
||
assert!(prompt.contains("English guardrail"));
|
||
assert!(prompt.contains("one vertical 1024x1536 image"));
|
||
assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas"));
|
||
assert!(prompt.contains("row1 col2 top"));
|
||
assert!(prompt.contains("row2 col1 left"));
|
||
assert!(prompt.contains("row2 col2 front"));
|
||
assert!(prompt.contains("row2 col3 right"));
|
||
assert!(prompt.contains("row2 col4 back"));
|
||
assert!(prompt.contains("row3 col2 bottom"));
|
||
assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object"));
|
||
assert!(prompt.contains("no generic flat material"));
|
||
assert!(prompt.contains("no small centered stickers"));
|
||
assert!(prompt.contains("every face is full-bleed opaque square texture"));
|
||
assert!(prompt.contains("no perspective cube render"));
|
||
assert!(prompt.contains("no baked shadows"));
|
||
assert!(prompt.contains("no pedestal"));
|
||
assert!(prompt.contains("no floor slab"));
|
||
assert!(prompt.contains("empty UV cells and gutters are solid magenta"));
|
||
assert!(!prompt.contains("可落脚平台素材"));
|
||
assert!(!prompt.contains("平台裸素材"));
|
||
assert!(!prompt.contains("每格一个完整平台"));
|
||
assert!(!prompt.contains("25个平台"));
|
||
assert!(!prompt.contains("跳跃落点主题物体"));
|
||
assert!(!prompt.contains("正面30度视角"));
|
||
assert!(!prompt.contains("五行五列"));
|
||
assert!(!prompt.contains("25张用于跳一跳地板"));
|
||
assert!(!prompt.contains("25 full-bleed"));
|
||
assert!(!prompt.contains("one square 5x5"));
|
||
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("清爽自然的游戏图标"));
|
||
assert!(!prompt.contains("边缘厚度暗示"));
|
||
assert!(!prompt.contains("统一投影"));
|
||
assert!(!prompt.contains("每个物品生成"));
|
||
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("左上角也不要画返回按钮或任何固定图标"));
|
||
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]
|
||
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("底部按钮"));
|
||
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_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("画面内容是水果主题的3D立方体主题身份方块包装图集"));
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_prompt_sanitizes_legacy_platform_words() {
|
||
let prompt = build_jump_hop_tile_atlas_prompt(
|
||
"科幻芯片",
|
||
"科幻芯片主题的俯视角清爽游戏化立体感平台素材",
|
||
);
|
||
|
||
assert!(prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图"));
|
||
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材"));
|
||
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角"));
|
||
|
||
let top_down_prompt =
|
||
build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台");
|
||
|
||
assert!(top_down_prompt.contains("画面内容是水果主题正交平面"));
|
||
assert!(top_down_prompt.contains("圆形立方体地板"));
|
||
assert!(!top_down_prompt.contains("画面内容是水果主题鸟瞰视角"));
|
||
assert!(!top_down_prompt.contains("画面内容是水果主题平铺俯拍"));
|
||
|
||
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();
|
||
|
||
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("果切"));
|
||
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("3D平台"));
|
||
assert!(negative_prompt.contains("跳板成品"));
|
||
assert!(negative_prompt.contains("地块成品"));
|
||
assert!(negative_prompt.contains("物体剪影"));
|
||
assert!(negative_prompt.contains("正面30度物体图"));
|
||
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("杯垫"));
|
||
assert!(!negative_prompt.contains("重复圆形"));
|
||
assert!(!negative_prompt.contains("建筑"));
|
||
assert!(!negative_prompt.contains("楼房"));
|
||
}
|
||
|
||
fn paint_test_uv_face(
|
||
atlas: &mut image::RgbaImage,
|
||
atlas_col: u32,
|
||
atlas_row: u32,
|
||
face_col: u32,
|
||
face_row: u32,
|
||
color: image::Rgba<u8>,
|
||
) {
|
||
let cell_width = atlas.width() / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let cell_height = atlas.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 = 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,
|
||
);
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn load_test_png(bytes: Vec<u8>) -> crate::openai_image_generation::DownloadedOpenAiImage {
|
||
crate::openai_image_generation::DownloadedOpenAiImage {
|
||
bytes,
|
||
mime_type: "image/png".to_string(),
|
||
extension: "png".to_string(),
|
||
}
|
||
}
|
||
|
||
fn encode_test_atlas(atlas: image::RgbaImage) -> Vec<u8> {
|
||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||
image::DynamicImage::ImageRgba8(atlas)
|
||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||
.expect("atlas should encode");
|
||
encoded.into_inner()
|
||
}
|
||
|
||
fn assert_png_contains_color(bytes: &[u8], color: [u8; 4], message: &str) {
|
||
let decoded = image::load_from_memory(bytes)
|
||
.expect("tile face slice should decode")
|
||
.to_rgba8();
|
||
assert_eq!(
|
||
decoded.dimensions(),
|
||
(
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE,
|
||
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE
|
||
),
|
||
"{message}"
|
||
);
|
||
assert!(
|
||
decoded.pixels().any(|pixel| pixel.0 == color),
|
||
"{message}"
|
||
);
|
||
assert!(
|
||
decoded.pixels().all(|pixel| pixel.0[3] == 255),
|
||
"{message}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_slices_eighteen_cube_uv_unwrap_tiles() {
|
||
let width = 384;
|
||
let height = 576;
|
||
let mut atlas =
|
||
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
|
||
for row in 0..JUMP_HOP_TILE_ATLAS_ROWS {
|
||
for col in 0..JUMP_HOP_TILE_ATLAS_COLS {
|
||
let index = row * JUMP_HOP_TILE_ATLAS_COLS + col;
|
||
let base = index as u8;
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
0,
|
||
image::Rgba([40 + base * 3, 24 + base * 2, 100, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
1,
|
||
image::Rgba([50 + base * 3, 34 + base * 2, 110, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
2,
|
||
1,
|
||
image::Rgba([60 + base * 3, 44 + base * 2, 120, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
3,
|
||
1,
|
||
image::Rgba([70 + base * 3, 54 + base * 2, 130, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
0,
|
||
1,
|
||
image::Rgba([80 + base * 3, 64 + base * 2, 140, 255]),
|
||
);
|
||
paint_test_uv_face(
|
||
&mut atlas,
|
||
col,
|
||
row,
|
||
1,
|
||
2,
|
||
image::Rgba([90 + base * 3, 74 + base * 2, 150, 255]),
|
||
);
|
||
}
|
||
}
|
||
let image = load_test_png(encode_test_atlas(atlas));
|
||
|
||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||
|
||
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT);
|
||
for (index, slice) in slices.iter().enumerate() {
|
||
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
|
||
let base = index as u8;
|
||
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
|
||
assert_eq!(
|
||
slice.source_atlas_cell,
|
||
format!("row-{}-col-{}", row + 1, col + 1)
|
||
);
|
||
assert_eq!(
|
||
slice.faces.top.source_atlas_cell,
|
||
format!("row-{}-col-{}/top", row + 1, col + 1)
|
||
);
|
||
assert_eq!(
|
||
slice.faces.front.source_atlas_cell,
|
||
format!("row-{}-col-{}/front", row + 1, col + 1)
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.top.bytes.as_slice(),
|
||
[40 + base * 3, 24 + base * 2, 100, 255],
|
||
"top 面应从每格第1行第2列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.front.bytes.as_slice(),
|
||
[50 + base * 3, 34 + base * 2, 110, 255],
|
||
"front 面应从每格第2行第2列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.right.bytes.as_slice(),
|
||
[60 + base * 3, 44 + base * 2, 120, 255],
|
||
"right 面应从每格第2行第3列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.back.bytes.as_slice(),
|
||
[70 + base * 3, 54 + base * 2, 130, 255],
|
||
"back 面应从每格第2行第4列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.left.bytes.as_slice(),
|
||
[80 + base * 3, 64 + base * 2, 140, 255],
|
||
"left 面应从每格第2行第1列切出",
|
||
);
|
||
assert_png_contains_color(
|
||
slice.faces.bottom.bytes.as_slice(),
|
||
[90 + base * 3, 74 + base * 2, 150, 255],
|
||
"bottom 面应从每格第3行第2列切出",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_atlas_slicing_preserves_green_and_white_tile_materials() {
|
||
let width = 384;
|
||
let height = 576;
|
||
let mut atlas =
|
||
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
|
||
for row in 0..JUMP_HOP_TILE_ATLAS_ROWS {
|
||
for col in 0..JUMP_HOP_TILE_ATLAS_COLS {
|
||
let 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])
|
||
};
|
||
paint_test_uv_face(&mut atlas, col, row, 1, 0, color);
|
||
}
|
||
}
|
||
let image = load_test_png(encode_test_atlas(atlas));
|
||
|
||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||
let green_tile = image::load_from_memory(slices[0].faces.top.bytes.as_slice())
|
||
.expect("green tile should decode")
|
||
.to_rgba8();
|
||
let white_tile = image::load_from_memory(slices[1].faces.top.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], 255);
|
||
assert_eq!(white_tile.get_pixel(0, 0).0[3], 255);
|
||
assert!(
|
||
green_tile.pixels().all(|pixel| pixel.0[3] == 255),
|
||
"绿色主题材质不能被透明化扣掉"
|
||
);
|
||
assert!(
|
||
white_tile.pixels().all(|pixel| pixel.0[3] == 255),
|
||
"白色主题材质不能被透明化扣掉"
|
||
);
|
||
assert!(
|
||
green_tile
|
||
.pixels()
|
||
.all(|pixel| pixel.0 != [255, 0, 255, 255]),
|
||
"残留洋红 key 色应被转成不透明材质底色,不能留成可见边"
|
||
);
|
||
assert!(
|
||
white_tile
|
||
.pixels()
|
||
.all(|pixel| pixel.0 != [255, 0, 255, 255]),
|
||
"残留洋红 key 色应被转成不透明材质底色,不能留成可见边"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn jump_hop_tile_asset_slots_are_unique_for_eighteen_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,
|
||
"18 个地板 UV 大单元必须写入 18 个独立 slot/path,不能按重复的 tile_type 互相覆盖"
|
||
);
|
||
|
||
let face_slots = (0..JUMP_HOP_TILE_ITEM_COUNT)
|
||
.flat_map(|index| {
|
||
[
|
||
JumpHopTileFaceKey::Top,
|
||
JumpHopTileFaceKey::Front,
|
||
JumpHopTileFaceKey::Right,
|
||
JumpHopTileFaceKey::Back,
|
||
JumpHopTileFaceKey::Left,
|
||
JumpHopTileFaceKey::Bottom,
|
||
]
|
||
.into_iter()
|
||
.map(move |face| jump_hop_tile_face_asset_slot_name(index, &face))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let unique_face_slots = face_slots
|
||
.iter()
|
||
.cloned()
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
assert_eq!(
|
||
unique_face_slots.len(),
|
||
JUMP_HOP_TILE_ITEM_COUNT * 6,
|
||
"18 个地板 UV 大单元的 108 张面贴图必须写入独立 slot/path"
|
||
);
|
||
}
|
||
}
|