1493 lines
50 KiB
Rust
1493 lines
50 KiB
Rust
use std::{error::Error, fmt};
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use shared_kernel::normalize_required_string;
|
||
#[cfg(feature = "spacetime-types")]
|
||
use spacetimedb::SpacetimeType;
|
||
|
||
pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-";
|
||
pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-";
|
||
pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-";
|
||
pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-";
|
||
pub const BIG_FISH_RUN_ID_PREFIX: &str = "big-fish-run-";
|
||
pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8;
|
||
pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6;
|
||
pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12;
|
||
pub const BIG_FISH_MERGE_COUNT_PER_UPGRADE: u32 = 3;
|
||
pub const BIG_FISH_OFFSCREEN_CULL_SECONDS: f32 = 3.0;
|
||
pub const BIG_FISH_TARGET_WILD_COUNT: usize = 12;
|
||
pub const BIG_FISH_VIEW_WIDTH: f32 = 720.0;
|
||
pub const BIG_FISH_VIEW_HEIGHT: f32 = 1280.0;
|
||
pub const BIG_FISH_WORLD_HALF_WIDTH: f32 = 900.0;
|
||
pub const BIG_FISH_WORLD_HALF_HEIGHT: f32 = 1600.0;
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum BigFishCreationStage {
|
||
CollectingAnchors,
|
||
DraftReady,
|
||
AssetRefining,
|
||
ReadyToPublish,
|
||
Published,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum BigFishAnchorStatus {
|
||
Confirmed,
|
||
Inferred,
|
||
Missing,
|
||
Locked,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum BigFishAgentMessageRole {
|
||
User,
|
||
Assistant,
|
||
System,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum BigFishAgentMessageKind {
|
||
Chat,
|
||
Summary,
|
||
ActionResult,
|
||
Warning,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum BigFishAssetKind {
|
||
LevelMainImage,
|
||
LevelMotion,
|
||
StageBackground,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum BigFishAssetStatus {
|
||
Missing,
|
||
Ready,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum BigFishRunStatus {
|
||
Running,
|
||
Won,
|
||
Failed,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishAnchorItem {
|
||
pub key: String,
|
||
pub label: String,
|
||
pub value: String,
|
||
pub status: BigFishAnchorStatus,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishAnchorPack {
|
||
pub gameplay_promise: BigFishAnchorItem,
|
||
pub ecology_visual_theme: BigFishAnchorItem,
|
||
pub growth_ladder: BigFishAnchorItem,
|
||
pub risk_tempo: BigFishAnchorItem,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishLevelBlueprint {
|
||
pub level: u32,
|
||
pub name: String,
|
||
pub one_line_fantasy: String,
|
||
pub silhouette_direction: String,
|
||
pub size_ratio: f32,
|
||
pub visual_prompt_seed: String,
|
||
pub motion_prompt_seed: String,
|
||
pub merge_source_level: Option<u32>,
|
||
pub prey_window: Vec<u32>,
|
||
pub threat_window: Vec<u32>,
|
||
pub is_final_level: bool,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishBackgroundBlueprint {
|
||
pub theme: String,
|
||
pub color_mood: String,
|
||
pub foreground_hints: String,
|
||
pub midground_composition: String,
|
||
pub background_depth: String,
|
||
pub safe_play_area_hint: String,
|
||
pub spawn_edge_hint: String,
|
||
pub background_prompt_seed: String,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishRuntimeParams {
|
||
pub level_count: u32,
|
||
pub merge_count_per_upgrade: u32,
|
||
pub spawn_target_count: u32,
|
||
pub leader_move_speed: f32,
|
||
pub follower_catch_up_speed: f32,
|
||
pub offscreen_cull_seconds: f32,
|
||
pub prey_spawn_delta_levels: Vec<u32>,
|
||
pub threat_spawn_delta_levels: Vec<u32>,
|
||
pub win_level: u32,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishGameDraft {
|
||
pub title: String,
|
||
pub subtitle: String,
|
||
pub core_fun: String,
|
||
pub ecology_theme: String,
|
||
pub levels: Vec<BigFishLevelBlueprint>,
|
||
pub background: BigFishBackgroundBlueprint,
|
||
pub runtime_params: BigFishRuntimeParams,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishAgentMessageSnapshot {
|
||
pub message_id: String,
|
||
pub session_id: String,
|
||
pub role: BigFishAgentMessageRole,
|
||
pub kind: BigFishAgentMessageKind,
|
||
pub text: String,
|
||
pub created_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishAssetSlotSnapshot {
|
||
pub slot_id: String,
|
||
pub session_id: String,
|
||
pub asset_kind: BigFishAssetKind,
|
||
pub level: Option<u32>,
|
||
pub motion_key: Option<String>,
|
||
pub status: BigFishAssetStatus,
|
||
pub asset_url: Option<String>,
|
||
pub prompt_snapshot: String,
|
||
pub updated_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishAssetCoverage {
|
||
pub level_main_image_ready_count: u32,
|
||
pub level_motion_ready_count: u32,
|
||
pub background_ready: bool,
|
||
pub required_level_count: u32,
|
||
pub publish_ready: bool,
|
||
pub blockers: Vec<String>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishSessionSnapshot {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub seed_text: String,
|
||
pub current_turn: u32,
|
||
pub progress_percent: u32,
|
||
pub stage: BigFishCreationStage,
|
||
pub anchor_pack: BigFishAnchorPack,
|
||
pub draft: Option<BigFishGameDraft>,
|
||
pub asset_slots: Vec<BigFishAssetSlotSnapshot>,
|
||
pub asset_coverage: BigFishAssetCoverage,
|
||
pub messages: Vec<BigFishAgentMessageSnapshot>,
|
||
pub last_assistant_reply: Option<String>,
|
||
pub publish_ready: bool,
|
||
pub created_at_micros: i64,
|
||
pub updated_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishVector2 {
|
||
pub x: f32,
|
||
pub y: f32,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishRuntimeEntity {
|
||
pub entity_id: String,
|
||
pub level: u32,
|
||
pub position: BigFishVector2,
|
||
pub radius: f32,
|
||
pub offscreen_seconds: f32,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishRuntimeSnapshot {
|
||
pub run_id: String,
|
||
pub session_id: String,
|
||
pub status: BigFishRunStatus,
|
||
pub tick: u64,
|
||
pub player_level: u32,
|
||
pub win_level: u32,
|
||
pub leader_entity_id: Option<String>,
|
||
pub owned_entities: Vec<BigFishRuntimeEntity>,
|
||
pub wild_entities: Vec<BigFishRuntimeEntity>,
|
||
pub camera_center: BigFishVector2,
|
||
pub last_input: BigFishVector2,
|
||
pub event_log: Vec<String>,
|
||
pub updated_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishSessionProcedureResult {
|
||
pub ok: bool,
|
||
pub session: Option<BigFishSessionSnapshot>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishWorkSummarySnapshot {
|
||
pub work_id: String,
|
||
pub source_session_id: String,
|
||
pub owner_user_id: String,
|
||
pub title: String,
|
||
pub subtitle: String,
|
||
pub summary: String,
|
||
pub cover_image_src: Option<String>,
|
||
pub status: String,
|
||
pub updated_at_micros: i64,
|
||
pub publish_ready: bool,
|
||
pub level_count: u32,
|
||
pub level_main_image_ready_count: u32,
|
||
pub level_motion_ready_count: u32,
|
||
pub background_ready: bool,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishWorksListInput {
|
||
pub owner_user_id: String,
|
||
pub published_only: bool,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishWorkDeleteInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishWorksProcedureResult {
|
||
pub ok: bool,
|
||
pub items_json: Option<String>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishRunProcedureResult {
|
||
pub ok: bool,
|
||
pub run: Option<BigFishRuntimeSnapshot>,
|
||
pub error_message: Option<String>,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishSessionCreateInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub seed_text: String,
|
||
pub welcome_message_id: String,
|
||
pub welcome_message_text: String,
|
||
pub created_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishSessionGetInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishMessageSubmitInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub user_message_id: String,
|
||
pub user_message_text: String,
|
||
pub assistant_message_id: String,
|
||
pub submitted_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishMessageFinalizeInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub assistant_message_id: Option<String>,
|
||
pub assistant_reply_text: Option<String>,
|
||
pub stage: BigFishCreationStage,
|
||
pub progress_percent: u32,
|
||
pub anchor_pack_json: String,
|
||
pub error_message: Option<String>,
|
||
pub updated_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishDraftCompileInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub compiled_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishAssetGenerateInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub asset_kind: BigFishAssetKind,
|
||
pub level: Option<u32>,
|
||
pub motion_key: Option<String>,
|
||
pub asset_url: Option<String>,
|
||
pub generated_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishPublishInput {
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub published_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishRunStartInput {
|
||
pub run_id: String,
|
||
pub session_id: String,
|
||
pub owner_user_id: String,
|
||
pub started_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct BigFishRunInputSubmitInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
pub input_x: f32,
|
||
pub input_y: f32,
|
||
pub submitted_at_micros: i64,
|
||
}
|
||
|
||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BigFishRunGetInput {
|
||
pub run_id: String,
|
||
pub owner_user_id: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum BigFishFieldError {
|
||
MissingSessionId,
|
||
MissingOwnerUserId,
|
||
MissingMessageId,
|
||
MissingMessageText,
|
||
MissingRunId,
|
||
MissingDraft,
|
||
InvalidLevel,
|
||
InvalidAssetKind,
|
||
InvalidRunState,
|
||
}
|
||
|
||
impl BigFishCreationStage {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::CollectingAnchors => "collecting_anchors",
|
||
Self::DraftReady => "draft_ready",
|
||
Self::AssetRefining => "asset_refining",
|
||
Self::ReadyToPublish => "ready_to_publish",
|
||
Self::Published => "published",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl BigFishAnchorStatus {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Confirmed => "confirmed",
|
||
Self::Inferred => "inferred",
|
||
Self::Missing => "missing",
|
||
Self::Locked => "locked",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl BigFishAgentMessageRole {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::User => "user",
|
||
Self::Assistant => "assistant",
|
||
Self::System => "system",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl BigFishAgentMessageKind {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Chat => "chat",
|
||
Self::Summary => "summary",
|
||
Self::ActionResult => "action_result",
|
||
Self::Warning => "warning",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl BigFishAssetKind {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::LevelMainImage => "level_main_image",
|
||
Self::LevelMotion => "level_motion",
|
||
Self::StageBackground => "stage_background",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl BigFishAssetStatus {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Missing => "missing",
|
||
Self::Ready => "ready",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl BigFishRunStatus {
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Running => "running",
|
||
Self::Won => "won",
|
||
Self::Failed => "failed",
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn empty_anchor_pack() -> BigFishAnchorPack {
|
||
BigFishAnchorPack {
|
||
gameplay_promise: BigFishAnchorItem {
|
||
key: "gameplayPromise".to_string(),
|
||
label: "玩法承诺".to_string(),
|
||
value: String::new(),
|
||
status: BigFishAnchorStatus::Missing,
|
||
},
|
||
ecology_visual_theme: BigFishAnchorItem {
|
||
key: "ecologyVisualTheme".to_string(),
|
||
label: "生态与视觉母题".to_string(),
|
||
value: String::new(),
|
||
status: BigFishAnchorStatus::Missing,
|
||
},
|
||
growth_ladder: BigFishAnchorItem {
|
||
key: "growthLadder".to_string(),
|
||
label: "成长阶梯".to_string(),
|
||
value: String::new(),
|
||
status: BigFishAnchorStatus::Missing,
|
||
},
|
||
risk_tempo: BigFishAnchorItem {
|
||
key: "riskTempo".to_string(),
|
||
label: "风险节奏".to_string(),
|
||
value: "平衡".to_string(),
|
||
status: BigFishAnchorStatus::Inferred,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> BigFishAnchorPack {
|
||
let source = normalize_required_string(latest_message.unwrap_or(seed_text))
|
||
.or_else(|| normalize_required_string(seed_text))
|
||
.unwrap_or_else(|| "深海弱小逆袭,逐级吞噬成长".to_string());
|
||
let mut pack = empty_anchor_pack();
|
||
pack.gameplay_promise.value = if source.contains("可爱") {
|
||
"可爱生态成长".to_string()
|
||
} else if source.contains("机械") {
|
||
"机械微生物吞并进化".to_string()
|
||
} else {
|
||
"弱小逆袭和群体吞并".to_string()
|
||
};
|
||
pack.gameplay_promise.status = BigFishAnchorStatus::Inferred;
|
||
pack.ecology_visual_theme.value = if source.contains("机械") {
|
||
"机械微生物水域".to_string()
|
||
} else if source.contains("梦") {
|
||
"梦境纸鱼生态".to_string()
|
||
} else {
|
||
"深海生物生态".to_string()
|
||
};
|
||
pack.ecology_visual_theme.status = BigFishAnchorStatus::Inferred;
|
||
pack.growth_ladder.value = "8 级连续进化,从幼小个体成长为终局巨兽".to_string();
|
||
pack.growth_ladder.status = BigFishAnchorStatus::Inferred;
|
||
pack.risk_tempo.value = if source.contains("爽") {
|
||
"偏爽快".to_string()
|
||
} else if source.contains("压迫") {
|
||
"偏压迫".to_string()
|
||
} else {
|
||
"平衡".to_string()
|
||
};
|
||
pack
|
||
}
|
||
|
||
pub fn compile_default_draft(anchor_pack: &BigFishAnchorPack) -> BigFishGameDraft {
|
||
let level_count = BIG_FISH_DEFAULT_LEVEL_COUNT;
|
||
let theme = fallback_anchor_value(&anchor_pack.ecology_visual_theme, "深海生物生态");
|
||
let core_fun = fallback_anchor_value(&anchor_pack.gameplay_promise, "弱小逆袭和群体吞并");
|
||
let risk_tempo = fallback_anchor_value(&anchor_pack.risk_tempo, "平衡");
|
||
|
||
let levels = (1..=level_count)
|
||
.map(|level| build_level_blueprint(level, level_count, &theme))
|
||
.collect();
|
||
|
||
BigFishGameDraft {
|
||
title: format!("{theme} 大鱼吃小鱼"),
|
||
subtitle: format!("{core_fun} · {risk_tempo}节奏"),
|
||
core_fun,
|
||
ecology_theme: theme.clone(),
|
||
levels,
|
||
background: BigFishBackgroundBlueprint {
|
||
theme: theme.clone(),
|
||
color_mood: "深蓝、青绿、带少量暖色生物光".to_string(),
|
||
foreground_hints: "轻微漂浮颗粒和边缘水草,不遮挡中央操作区".to_string(),
|
||
midground_composition: "中央留出清晰活动区域,边缘有出生缓冲层".to_string(),
|
||
background_depth: "纵深水域与远处体型剪影".to_string(),
|
||
safe_play_area_hint: "9:16 竖屏中央 70% 为主要活动区".to_string(),
|
||
spawn_edge_hint: "四周边缘作为野生实体出生区".to_string(),
|
||
background_prompt_seed: format!("{theme},竖屏 9:16,全屏游戏背景,无文字,无 UI 框"),
|
||
},
|
||
runtime_params: BigFishRuntimeParams {
|
||
level_count,
|
||
merge_count_per_upgrade: BIG_FISH_MERGE_COUNT_PER_UPGRADE,
|
||
spawn_target_count: BIG_FISH_TARGET_WILD_COUNT as u32,
|
||
leader_move_speed: 160.0,
|
||
follower_catch_up_speed: 120.0,
|
||
offscreen_cull_seconds: BIG_FISH_OFFSCREEN_CULL_SECONDS,
|
||
prey_spawn_delta_levels: vec![1, 2],
|
||
threat_spawn_delta_levels: vec![1, 2],
|
||
win_level: level_count,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub fn build_asset_coverage(
|
||
draft: Option<&BigFishGameDraft>,
|
||
asset_slots: &[BigFishAssetSlotSnapshot],
|
||
) -> BigFishAssetCoverage {
|
||
let required_level_count = draft
|
||
.map(|value| value.runtime_params.level_count)
|
||
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT);
|
||
let main_ready = asset_slots
|
||
.iter()
|
||
.filter(|slot| {
|
||
slot.asset_kind == BigFishAssetKind::LevelMainImage
|
||
&& slot.status == BigFishAssetStatus::Ready
|
||
})
|
||
.count() as u32;
|
||
let motion_ready = asset_slots
|
||
.iter()
|
||
.filter(|slot| {
|
||
slot.asset_kind == BigFishAssetKind::LevelMotion
|
||
&& slot.status == BigFishAssetStatus::Ready
|
||
})
|
||
.count() as u32;
|
||
let background_ready = asset_slots.iter().any(|slot| {
|
||
slot.asset_kind == BigFishAssetKind::StageBackground
|
||
&& slot.status == BigFishAssetStatus::Ready
|
||
});
|
||
|
||
let required_motion_count = required_level_count * 2;
|
||
let mut blockers = Vec::new();
|
||
if draft.is_none() {
|
||
blockers.push("玩法草稿尚未编译".to_string());
|
||
}
|
||
if main_ready < required_level_count {
|
||
blockers.push(format!(
|
||
"还缺少 {} 个等级主图",
|
||
required_level_count.saturating_sub(main_ready)
|
||
));
|
||
}
|
||
if motion_ready < required_motion_count {
|
||
blockers.push(format!(
|
||
"还缺少 {} 个基础动作",
|
||
required_motion_count.saturating_sub(motion_ready)
|
||
));
|
||
}
|
||
if !background_ready {
|
||
blockers.push("还缺少活动区域背景图".to_string());
|
||
}
|
||
|
||
BigFishAssetCoverage {
|
||
level_main_image_ready_count: main_ready,
|
||
level_motion_ready_count: motion_ready,
|
||
background_ready,
|
||
required_level_count,
|
||
publish_ready: blockers.is_empty(),
|
||
blockers,
|
||
}
|
||
}
|
||
|
||
pub fn build_generated_asset_slot(
|
||
session_id: &str,
|
||
draft: &BigFishGameDraft,
|
||
asset_kind: BigFishAssetKind,
|
||
level: Option<u32>,
|
||
motion_key: Option<String>,
|
||
asset_url: Option<String>,
|
||
updated_at_micros: i64,
|
||
) -> Result<BigFishAssetSlotSnapshot, BigFishFieldError> {
|
||
let session_id =
|
||
normalize_required_string(session_id).ok_or(BigFishFieldError::MissingSessionId)?;
|
||
let prompt_snapshot =
|
||
build_asset_prompt_snapshot(draft, asset_kind, level, motion_key.as_deref())?;
|
||
let slot_id = build_asset_slot_id(&session_id, asset_kind, level, motion_key.as_deref());
|
||
let resolved_asset_url = normalize_required_string(asset_url.as_deref().unwrap_or_default())
|
||
.unwrap_or_else(|| build_placeholder_asset_url(asset_kind, level, updated_at_micros));
|
||
|
||
Ok(BigFishAssetSlotSnapshot {
|
||
slot_id,
|
||
session_id,
|
||
asset_kind,
|
||
level,
|
||
motion_key,
|
||
status: BigFishAssetStatus::Ready,
|
||
asset_url: Some(resolved_asset_url),
|
||
prompt_snapshot,
|
||
updated_at_micros,
|
||
})
|
||
}
|
||
|
||
pub fn build_initial_runtime_snapshot(
|
||
run_id: String,
|
||
session_id: String,
|
||
draft: &BigFishGameDraft,
|
||
now_micros: i64,
|
||
) -> BigFishRuntimeSnapshot {
|
||
let mut snapshot = BigFishRuntimeSnapshot {
|
||
run_id,
|
||
session_id,
|
||
status: BigFishRunStatus::Running,
|
||
tick: 0,
|
||
player_level: 1,
|
||
win_level: draft.runtime_params.win_level,
|
||
leader_entity_id: Some("owned-1".to_string()),
|
||
owned_entities: vec![BigFishRuntimeEntity {
|
||
entity_id: "owned-1".to_string(),
|
||
level: 1,
|
||
position: BigFishVector2 { x: 0.0, y: 0.0 },
|
||
radius: entity_radius(1),
|
||
offscreen_seconds: 0.0,
|
||
}],
|
||
wild_entities: vec![
|
||
BigFishRuntimeEntity {
|
||
entity_id: "wild-open-1".to_string(),
|
||
level: 1,
|
||
position: BigFishVector2 { x: 72.0, y: 0.0 },
|
||
radius: entity_radius(1),
|
||
offscreen_seconds: 0.0,
|
||
},
|
||
BigFishRuntimeEntity {
|
||
entity_id: "wild-open-2".to_string(),
|
||
level: 1,
|
||
position: BigFishVector2 { x: -88.0, y: 30.0 },
|
||
radius: entity_radius(1),
|
||
offscreen_seconds: 0.0,
|
||
},
|
||
],
|
||
camera_center: BigFishVector2 { x: 0.0, y: 0.0 },
|
||
last_input: BigFishVector2 { x: 0.0, y: 0.0 },
|
||
event_log: vec!["开局生成 2 个同级可收编目标".to_string()],
|
||
updated_at_micros: now_micros,
|
||
};
|
||
maintain_wild_pool(&mut snapshot, &draft.runtime_params);
|
||
snapshot
|
||
}
|
||
|
||
pub fn advance_runtime_snapshot(
|
||
mut snapshot: BigFishRuntimeSnapshot,
|
||
params: &BigFishRuntimeParams,
|
||
input_x: f32,
|
||
input_y: f32,
|
||
now_micros: i64,
|
||
) -> BigFishRuntimeSnapshot {
|
||
if snapshot.status != BigFishRunStatus::Running {
|
||
return snapshot;
|
||
}
|
||
|
||
let step_seconds = resolve_step_seconds(&snapshot, now_micros);
|
||
snapshot.tick = snapshot.tick.saturating_add(1);
|
||
snapshot.last_input = normalize_input(input_x, input_y);
|
||
move_owned_entities(&mut snapshot, params, step_seconds);
|
||
resolve_collisions(&mut snapshot, params);
|
||
apply_chain_merges(&mut snapshot, params);
|
||
refresh_player_leader(&mut snapshot);
|
||
apply_win_or_fail(&mut snapshot, params);
|
||
update_wild_culling(&mut snapshot, params, step_seconds);
|
||
maintain_wild_pool(&mut snapshot, params);
|
||
snapshot.updated_at_micros = now_micros;
|
||
snapshot
|
||
}
|
||
|
||
pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), BigFishFieldError> {
|
||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||
}
|
||
|
||
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
|
||
if input.published_only {
|
||
return Ok(());
|
||
}
|
||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn validate_session_create_input(
|
||
input: &BigFishSessionCreateInput,
|
||
) -> Result<(), BigFishFieldError> {
|
||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||
if normalize_required_string(&input.welcome_message_id).is_none() {
|
||
return Err(BigFishFieldError::MissingMessageId);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn validate_message_submit_input(
|
||
input: &BigFishMessageSubmitInput,
|
||
) -> Result<(), BigFishFieldError> {
|
||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||
if normalize_required_string(&input.user_message_id).is_none()
|
||
|| normalize_required_string(&input.assistant_message_id).is_none()
|
||
{
|
||
return Err(BigFishFieldError::MissingMessageId);
|
||
}
|
||
if normalize_required_string(&input.user_message_text).is_none() {
|
||
return Err(BigFishFieldError::MissingMessageText);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn validate_message_finalize_input(
|
||
input: &BigFishMessageFinalizeInput,
|
||
) -> Result<(), BigFishFieldError> {
|
||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||
}
|
||
|
||
pub fn validate_draft_compile_input(
|
||
input: &BigFishDraftCompileInput,
|
||
) -> Result<(), BigFishFieldError> {
|
||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||
}
|
||
|
||
pub fn validate_asset_generate_input(
|
||
input: &BigFishAssetGenerateInput,
|
||
draft: &BigFishGameDraft,
|
||
) -> Result<(), BigFishFieldError> {
|
||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||
match input.asset_kind {
|
||
BigFishAssetKind::LevelMainImage => validate_level(input.level, draft),
|
||
BigFishAssetKind::LevelMotion => {
|
||
validate_level(input.level, draft)?;
|
||
match input.motion_key.as_deref() {
|
||
Some("idle_float" | "move_swim") => Ok(()),
|
||
_ => Err(BigFishFieldError::InvalidAssetKind),
|
||
}
|
||
}
|
||
BigFishAssetKind::StageBackground => Ok(()),
|
||
}
|
||
}
|
||
|
||
pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFishFieldError> {
|
||
validate_session_owner(&input.session_id, &input.owner_user_id)
|
||
}
|
||
|
||
pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> {
|
||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||
if normalize_required_string(&input.run_id).is_none() {
|
||
return Err(BigFishFieldError::MissingRunId);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> {
|
||
if normalize_required_string(&input.run_id).is_none() {
|
||
return Err(BigFishFieldError::MissingRunId);
|
||
}
|
||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn validate_run_input_submit_input(
|
||
input: &BigFishRunInputSubmitInput,
|
||
) -> Result<(), BigFishFieldError> {
|
||
if normalize_required_string(&input.run_id).is_none() {
|
||
return Err(BigFishFieldError::MissingRunId);
|
||
}
|
||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
|
||
serde_json::to_string(anchor_pack)
|
||
}
|
||
|
||
pub fn deserialize_anchor_pack(value: &str) -> Result<BigFishAnchorPack, serde_json::Error> {
|
||
serde_json::from_str(value)
|
||
}
|
||
|
||
pub fn serialize_draft(draft: &BigFishGameDraft) -> Result<String, serde_json::Error> {
|
||
serde_json::to_string(draft)
|
||
}
|
||
|
||
pub fn deserialize_draft(value: &str) -> Result<BigFishGameDraft, serde_json::Error> {
|
||
serde_json::from_str(value)
|
||
}
|
||
|
||
pub fn serialize_asset_coverage(
|
||
coverage: &BigFishAssetCoverage,
|
||
) -> Result<String, serde_json::Error> {
|
||
serde_json::to_string(coverage)
|
||
}
|
||
|
||
pub fn deserialize_asset_coverage(value: &str) -> Result<BigFishAssetCoverage, serde_json::Error> {
|
||
serde_json::from_str(value)
|
||
}
|
||
|
||
pub fn serialize_runtime_snapshot(
|
||
snapshot: &BigFishRuntimeSnapshot,
|
||
) -> Result<String, serde_json::Error> {
|
||
serde_json::to_string(snapshot)
|
||
}
|
||
|
||
pub fn deserialize_runtime_snapshot(
|
||
value: &str,
|
||
) -> Result<BigFishRuntimeSnapshot, serde_json::Error> {
|
||
serde_json::from_str(value)
|
||
}
|
||
|
||
fn fallback_anchor_value(anchor: &BigFishAnchorItem, fallback: &str) -> String {
|
||
normalize_required_string(&anchor.value).unwrap_or_else(|| fallback.to_string())
|
||
}
|
||
|
||
fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLevelBlueprint {
|
||
let prey_window = (1..level)
|
||
.rev()
|
||
.take(2)
|
||
.collect::<Vec<_>>()
|
||
.into_iter()
|
||
.rev()
|
||
.collect();
|
||
let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::<Vec<_>>();
|
||
BigFishLevelBlueprint {
|
||
level,
|
||
name: format!("{theme} L{level}"),
|
||
one_line_fantasy: if level == level_count {
|
||
"终局巨兽形态,获得即可通关".to_string()
|
||
} else {
|
||
format!("第 {level} 阶实体,继续吞噬同级和低级个体成长")
|
||
},
|
||
silhouette_direction: format!(
|
||
"体型约为初始的 {:.1} 倍,轮廓更清晰",
|
||
1.0 + level as f32 * 0.22
|
||
),
|
||
size_ratio: 1.0 + (level.saturating_sub(1) as f32 * 0.22),
|
||
visual_prompt_seed: format!("{theme} 第 {level} 级实体主图,透明背景,清晰轮廓"),
|
||
motion_prompt_seed: format!("{theme} 第 {level} 级实体 idle_float 与 move_swim 动作"),
|
||
merge_source_level: if level == 1 { None } else { Some(level - 1) },
|
||
prey_window,
|
||
threat_window,
|
||
is_final_level: level == level_count,
|
||
}
|
||
}
|
||
|
||
fn build_asset_prompt_snapshot(
|
||
draft: &BigFishGameDraft,
|
||
asset_kind: BigFishAssetKind,
|
||
level: Option<u32>,
|
||
motion_key: Option<&str>,
|
||
) -> Result<String, BigFishFieldError> {
|
||
match asset_kind {
|
||
BigFishAssetKind::LevelMainImage => {
|
||
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
|
||
let blueprint = draft
|
||
.levels
|
||
.iter()
|
||
.find(|item| item.level == level)
|
||
.ok_or(BigFishFieldError::InvalidLevel)?;
|
||
Ok(blueprint.visual_prompt_seed.clone())
|
||
}
|
||
BigFishAssetKind::LevelMotion => {
|
||
let level = level.ok_or(BigFishFieldError::InvalidLevel)?;
|
||
let blueprint = draft
|
||
.levels
|
||
.iter()
|
||
.find(|item| item.level == level)
|
||
.ok_or(BigFishFieldError::InvalidLevel)?;
|
||
let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?;
|
||
Ok(format!(
|
||
"{},动作位:{}",
|
||
blueprint.motion_prompt_seed, motion_key
|
||
))
|
||
}
|
||
BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()),
|
||
}
|
||
}
|
||
|
||
fn build_asset_slot_id(
|
||
session_id: &str,
|
||
asset_kind: BigFishAssetKind,
|
||
level: Option<u32>,
|
||
motion_key: Option<&str>,
|
||
) -> String {
|
||
let level_part = level
|
||
.map(|value| value.to_string())
|
||
.unwrap_or_else(|| "stage".to_string());
|
||
let motion_part = motion_key.unwrap_or("main");
|
||
format!(
|
||
"{BIG_FISH_ASSET_SLOT_ID_PREFIX}{session_id}_{}_{}_{}",
|
||
asset_kind.as_str(),
|
||
level_part,
|
||
motion_part
|
||
)
|
||
}
|
||
|
||
fn build_placeholder_asset_url(
|
||
asset_kind: BigFishAssetKind,
|
||
level: Option<u32>,
|
||
seed_micros: i64,
|
||
) -> String {
|
||
let level_part = level
|
||
.map(|value| format!("level-{value}"))
|
||
.unwrap_or_else(|| "stage".to_string());
|
||
format!(
|
||
"/generated-big-fish/{}/{}/{}.png",
|
||
asset_kind.as_str(),
|
||
level_part,
|
||
seed_micros
|
||
)
|
||
}
|
||
|
||
fn validate_session_owner(session_id: &str, owner_user_id: &str) -> Result<(), BigFishFieldError> {
|
||
if normalize_required_string(session_id).is_none() {
|
||
return Err(BigFishFieldError::MissingSessionId);
|
||
}
|
||
if normalize_required_string(owner_user_id).is_none() {
|
||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_level(level: Option<u32>, draft: &BigFishGameDraft) -> Result<(), BigFishFieldError> {
|
||
match level {
|
||
Some(value) if (1..=draft.runtime_params.level_count).contains(&value) => Ok(()),
|
||
_ => Err(BigFishFieldError::InvalidLevel),
|
||
}
|
||
}
|
||
|
||
fn normalize_input(x: f32, y: f32) -> BigFishVector2 {
|
||
let length = (x * x + y * y).sqrt();
|
||
if length <= 1.0 {
|
||
return BigFishVector2 { x, y };
|
||
}
|
||
BigFishVector2 {
|
||
x: x / length,
|
||
y: y / length,
|
||
}
|
||
}
|
||
|
||
/// 运行态仍由 `POST input` 触发推进,因此“屏外 3 秒”这类规则必须按真实秒数累计,
|
||
/// 否则会随着输入频率变化而漂移。
|
||
fn resolve_step_seconds(snapshot: &BigFishRuntimeSnapshot, now_micros: i64) -> f32 {
|
||
((now_micros - snapshot.updated_at_micros).max(0) as f32) / 1_000_000.0
|
||
}
|
||
|
||
fn move_owned_entities(
|
||
snapshot: &mut BigFishRuntimeSnapshot,
|
||
params: &BigFishRuntimeParams,
|
||
step_seconds: f32,
|
||
) {
|
||
let input = snapshot.last_input.clone();
|
||
if let Some(leader) = snapshot.owned_entities.first_mut() {
|
||
leader.position.x = clamp_world(
|
||
leader.position.x + input.x * params.leader_move_speed * step_seconds,
|
||
true,
|
||
);
|
||
leader.position.y = clamp_world(
|
||
leader.position.y + input.y * params.leader_move_speed * step_seconds,
|
||
false,
|
||
);
|
||
snapshot.camera_center = leader.position.clone();
|
||
}
|
||
|
||
let leader_position = snapshot.camera_center.clone();
|
||
for (index, follower) in snapshot.owned_entities.iter_mut().enumerate().skip(1) {
|
||
let slot_offset = ((index as f32) * 0.7).sin() * 36.0;
|
||
let target = BigFishVector2 {
|
||
x: leader_position.x - 42.0 - index as f32 * 8.0,
|
||
y: leader_position.y + slot_offset,
|
||
};
|
||
let delta_x = target.x - follower.position.x;
|
||
let delta_y = target.y - follower.position.y;
|
||
let distance = (delta_x * delta_x + delta_y * delta_y).sqrt();
|
||
if distance <= f32::EPSILON {
|
||
continue;
|
||
}
|
||
let catch_up_ratio =
|
||
(params.follower_catch_up_speed * step_seconds / distance).clamp(0.0, 1.0);
|
||
follower.position.x += delta_x * catch_up_ratio;
|
||
follower.position.y += delta_y * catch_up_ratio;
|
||
}
|
||
}
|
||
|
||
fn resolve_collisions(snapshot: &mut BigFishRuntimeSnapshot, _params: &BigFishRuntimeParams) {
|
||
let mut owned_to_remove = Vec::new();
|
||
let mut wild_to_remove = Vec::new();
|
||
let mut newly_owned = Vec::new();
|
||
|
||
for (owned_index, owned) in snapshot.owned_entities.iter().enumerate() {
|
||
for (wild_index, wild) in snapshot.wild_entities.iter().enumerate() {
|
||
if wild_to_remove.contains(&wild_index) || owned_to_remove.contains(&owned_index) {
|
||
continue;
|
||
}
|
||
if distance(&owned.position, &wild.position) > owned.radius + wild.radius {
|
||
continue;
|
||
}
|
||
|
||
if owned.level >= wild.level {
|
||
wild_to_remove.push(wild_index);
|
||
newly_owned.push(BigFishRuntimeEntity {
|
||
entity_id: format!("owned-from-{}-{}", wild.entity_id, snapshot.tick),
|
||
level: wild.level,
|
||
position: wild.position.clone(),
|
||
radius: entity_radius(wild.level),
|
||
offscreen_seconds: 0.0,
|
||
});
|
||
snapshot
|
||
.event_log
|
||
.push(format!("收编 {} 级实体", wild.level));
|
||
} else {
|
||
owned_to_remove.push(owned_index);
|
||
snapshot.event_log.push(format!(
|
||
"{} 级己方实体被 {} 级野生实体吃掉",
|
||
owned.level, wild.level
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
remove_indices(&mut snapshot.wild_entities, &wild_to_remove);
|
||
remove_indices(&mut snapshot.owned_entities, &owned_to_remove);
|
||
snapshot.owned_entities.extend(newly_owned);
|
||
}
|
||
|
||
fn apply_chain_merges(snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams) {
|
||
loop {
|
||
let mut merged = false;
|
||
for level in 1..params.win_level {
|
||
let indices = snapshot
|
||
.owned_entities
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, entity)| (entity.level == level).then_some(index))
|
||
.take(params.merge_count_per_upgrade as usize)
|
||
.collect::<Vec<_>>();
|
||
if indices.len() < params.merge_count_per_upgrade as usize {
|
||
continue;
|
||
}
|
||
|
||
let center = average_position(&indices, &snapshot.owned_entities);
|
||
remove_indices(&mut snapshot.owned_entities, &indices);
|
||
snapshot.owned_entities.push(BigFishRuntimeEntity {
|
||
entity_id: format!("owned-merge-{}-{}", level + 1, snapshot.tick),
|
||
level: level + 1,
|
||
position: center,
|
||
radius: entity_radius(level + 1),
|
||
offscreen_seconds: 0.0,
|
||
});
|
||
snapshot
|
||
.event_log
|
||
.push(format!("3 个 {} 级实体合成 {} 级", level, level + 1));
|
||
merged = true;
|
||
break;
|
||
}
|
||
|
||
if !merged {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn refresh_player_leader(snapshot: &mut BigFishRuntimeSnapshot) {
|
||
snapshot.owned_entities.sort_by(|left, right| {
|
||
right
|
||
.level
|
||
.cmp(&left.level)
|
||
.then_with(|| {
|
||
distance(&left.position, &snapshot.camera_center)
|
||
.partial_cmp(&distance(&right.position, &snapshot.camera_center))
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
})
|
||
.then_with(|| left.entity_id.cmp(&right.entity_id))
|
||
});
|
||
snapshot.leader_entity_id = snapshot
|
||
.owned_entities
|
||
.first()
|
||
.map(|entity| entity.entity_id.clone());
|
||
snapshot.player_level = snapshot
|
||
.owned_entities
|
||
.iter()
|
||
.map(|entity| entity.level)
|
||
.max()
|
||
.unwrap_or(0);
|
||
if let Some(leader) = snapshot.owned_entities.first() {
|
||
snapshot.camera_center = leader.position.clone();
|
||
}
|
||
}
|
||
|
||
fn apply_win_or_fail(snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams) {
|
||
if snapshot.owned_entities.is_empty() {
|
||
snapshot.status = BigFishRunStatus::Failed;
|
||
snapshot
|
||
.event_log
|
||
.push("己方实体归零,本局失败".to_string());
|
||
return;
|
||
}
|
||
if snapshot.player_level >= params.win_level {
|
||
snapshot.status = BigFishRunStatus::Won;
|
||
snapshot
|
||
.event_log
|
||
.push("获得最高等级实体,通关".to_string());
|
||
}
|
||
}
|
||
|
||
fn update_wild_culling(
|
||
snapshot: &mut BigFishRuntimeSnapshot,
|
||
params: &BigFishRuntimeParams,
|
||
step_seconds: f32,
|
||
) {
|
||
let player_level = snapshot.player_level;
|
||
for wild in &mut snapshot.wild_entities {
|
||
let should_cull_level = wild.level == player_level
|
||
|| wild.level >= player_level.saturating_add(3)
|
||
|| wild.level.saturating_add(3) <= player_level;
|
||
if !should_cull_level {
|
||
wild.offscreen_seconds = 0.0;
|
||
continue;
|
||
}
|
||
|
||
if is_offscreen(&wild.position, &snapshot.camera_center, wild.radius) {
|
||
wild.offscreen_seconds += step_seconds;
|
||
} else {
|
||
wild.offscreen_seconds = 0.0;
|
||
}
|
||
}
|
||
snapshot
|
||
.wild_entities
|
||
.retain(|wild| wild.offscreen_seconds < params.offscreen_cull_seconds);
|
||
}
|
||
|
||
fn maintain_wild_pool(snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams) {
|
||
if snapshot.status != BigFishRunStatus::Running {
|
||
return;
|
||
}
|
||
let mut next_index = snapshot.wild_entities.len() + snapshot.tick as usize;
|
||
while snapshot.wild_entities.len() < params.spawn_target_count as usize {
|
||
let level = next_spawn_level(snapshot.player_level.max(1), params.win_level, next_index);
|
||
snapshot.wild_entities.push(BigFishRuntimeEntity {
|
||
entity_id: format!("wild-{}-{}", snapshot.tick, next_index),
|
||
level,
|
||
position: spawn_position(&snapshot.camera_center, next_index),
|
||
radius: entity_radius(level),
|
||
offscreen_seconds: 0.0,
|
||
});
|
||
next_index += 1;
|
||
}
|
||
}
|
||
|
||
fn next_spawn_level(player_level: u32, win_level: u32, index: usize) -> u32 {
|
||
if player_level == 1 && index % 4 < 2 {
|
||
return 1;
|
||
}
|
||
let deltas = [-2_i32, -1, 1, 2];
|
||
let delta = deltas[index % deltas.len()];
|
||
(player_level as i32 + delta).clamp(1, win_level as i32) as u32
|
||
}
|
||
|
||
fn spawn_position(center: &BigFishVector2, index: usize) -> BigFishVector2 {
|
||
let side = index % 4;
|
||
let offset = ((index as f32 * 37.0) % 420.0) - 210.0;
|
||
match side {
|
||
0 => BigFishVector2 {
|
||
x: center.x - BIG_FISH_VIEW_WIDTH * 0.62,
|
||
y: center.y + offset,
|
||
},
|
||
1 => BigFishVector2 {
|
||
x: center.x + BIG_FISH_VIEW_WIDTH * 0.62,
|
||
y: center.y + offset,
|
||
},
|
||
2 => BigFishVector2 {
|
||
x: center.x + offset,
|
||
y: center.y - BIG_FISH_VIEW_HEIGHT * 0.58,
|
||
},
|
||
_ => BigFishVector2 {
|
||
x: center.x + offset,
|
||
y: center.y + BIG_FISH_VIEW_HEIGHT * 0.58,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn remove_indices<T>(items: &mut Vec<T>, indices: &[usize]) {
|
||
let mut sorted = indices.to_vec();
|
||
sorted.sort_unstable();
|
||
sorted.dedup();
|
||
for index in sorted.into_iter().rev() {
|
||
if index < items.len() {
|
||
items.remove(index);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn average_position(indices: &[usize], entities: &[BigFishRuntimeEntity]) -> BigFishVector2 {
|
||
let mut x = 0.0;
|
||
let mut y = 0.0;
|
||
for index in indices {
|
||
x += entities[*index].position.x;
|
||
y += entities[*index].position.y;
|
||
}
|
||
let count = indices.len().max(1) as f32;
|
||
BigFishVector2 {
|
||
x: x / count,
|
||
y: y / count,
|
||
}
|
||
}
|
||
|
||
fn distance(left: &BigFishVector2, right: &BigFishVector2) -> f32 {
|
||
let dx = left.x - right.x;
|
||
let dy = left.y - right.y;
|
||
(dx * dx + dy * dy).sqrt()
|
||
}
|
||
|
||
fn is_offscreen(position: &BigFishVector2, camera: &BigFishVector2, radius: f32) -> bool {
|
||
let half_w = BIG_FISH_VIEW_WIDTH / 2.0;
|
||
let half_h = BIG_FISH_VIEW_HEIGHT / 2.0;
|
||
position.x + radius < camera.x - half_w
|
||
|| position.x - radius > camera.x + half_w
|
||
|| position.y + radius < camera.y - half_h
|
||
|| position.y - radius > camera.y + half_h
|
||
}
|
||
|
||
fn clamp_world(value: f32, horizontal: bool) -> f32 {
|
||
let limit = if horizontal {
|
||
BIG_FISH_WORLD_HALF_WIDTH
|
||
} else {
|
||
BIG_FISH_WORLD_HALF_HEIGHT
|
||
};
|
||
value.clamp(-limit, limit)
|
||
}
|
||
|
||
fn entity_radius(level: u32) -> f32 {
|
||
18.0 + level as f32 * 4.0
|
||
}
|
||
|
||
impl fmt::Display for BigFishFieldError {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match self {
|
||
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
|
||
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
|
||
Self::MissingMessageId => f.write_str("big_fish.message_id 不能为空"),
|
||
Self::MissingMessageText => f.write_str("big_fish.message_text 不能为空"),
|
||
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
|
||
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
|
||
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
|
||
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
|
||
Self::InvalidRunState => f.write_str("big_fish.run 当前状态不允许推进"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Error for BigFishFieldError {}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn default_draft_compiles_eight_levels_with_fixed_runtime_params() {
|
||
let draft = compile_default_draft(&infer_anchor_pack("机械深海,节奏偏爽", None));
|
||
|
||
assert_eq!(draft.levels.len(), BIG_FISH_DEFAULT_LEVEL_COUNT as usize);
|
||
assert_eq!(draft.runtime_params.merge_count_per_upgrade, 3);
|
||
assert_eq!(draft.runtime_params.offscreen_cull_seconds, 3.0);
|
||
assert_eq!(draft.runtime_params.prey_spawn_delta_levels, vec![1, 2]);
|
||
assert_eq!(draft.runtime_params.threat_spawn_delta_levels, vec![1, 2]);
|
||
assert!(
|
||
draft
|
||
.levels
|
||
.last()
|
||
.is_some_and(|level| level.is_final_level)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn asset_coverage_requires_main_images_two_motions_and_background() {
|
||
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
|
||
let coverage = build_asset_coverage(Some(&draft), &[]);
|
||
|
||
assert!(!coverage.publish_ready);
|
||
assert_eq!(coverage.required_level_count, 8);
|
||
assert!(
|
||
coverage
|
||
.blockers
|
||
.iter()
|
||
.any(|item| item.contains("等级主图"))
|
||
);
|
||
assert!(
|
||
coverage
|
||
.blockers
|
||
.iter()
|
||
.any(|item| item.contains("基础动作"))
|
||
);
|
||
assert!(coverage.blockers.iter().any(|item| item.contains("背景图")));
|
||
}
|
||
|
||
#[test]
|
||
fn same_level_wild_entity_can_be_collected_at_start() {
|
||
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
|
||
let mut snapshot =
|
||
build_initial_runtime_snapshot("run-1".to_string(), "session-1".to_string(), &draft, 1);
|
||
snapshot.wild_entities[0].position = BigFishVector2 { x: 1.0, y: 0.0 };
|
||
|
||
let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 2);
|
||
|
||
assert!(next.owned_entities.len() >= 2);
|
||
assert!(
|
||
next.event_log
|
||
.iter()
|
||
.any(|event| event.contains("收编 1 级实体"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn three_owned_entities_merge_into_next_level() {
|
||
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
|
||
let mut snapshot = build_initial_runtime_snapshot(
|
||
"run-merge".to_string(),
|
||
"session-merge".to_string(),
|
||
&draft,
|
||
1,
|
||
);
|
||
snapshot.wild_entities.clear();
|
||
snapshot.owned_entities.push(BigFishRuntimeEntity {
|
||
entity_id: "owned-2".to_string(),
|
||
level: 1,
|
||
position: BigFishVector2 { x: 4.0, y: 0.0 },
|
||
radius: entity_radius(1),
|
||
offscreen_seconds: 0.0,
|
||
});
|
||
snapshot.owned_entities.push(BigFishRuntimeEntity {
|
||
entity_id: "owned-3".to_string(),
|
||
level: 1,
|
||
position: BigFishVector2 { x: 8.0, y: 0.0 },
|
||
radius: entity_radius(1),
|
||
offscreen_seconds: 0.0,
|
||
});
|
||
|
||
let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 2);
|
||
|
||
assert!(next.owned_entities.iter().any(|entity| entity.level == 2));
|
||
}
|
||
|
||
#[test]
|
||
fn final_level_immediately_wins() {
|
||
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
|
||
let mut snapshot = build_initial_runtime_snapshot(
|
||
"run-win".to_string(),
|
||
"session-win".to_string(),
|
||
&draft,
|
||
1,
|
||
);
|
||
snapshot.owned_entities[0].level = draft.runtime_params.win_level;
|
||
|
||
let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 2);
|
||
|
||
assert_eq!(next.status, BigFishRunStatus::Won);
|
||
}
|
||
|
||
#[test]
|
||
fn offscreen_same_level_wild_entity_is_removed_after_three_seconds() {
|
||
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
|
||
let mut snapshot = build_initial_runtime_snapshot(
|
||
"run-cull".to_string(),
|
||
"session-cull".to_string(),
|
||
&draft,
|
||
1,
|
||
);
|
||
snapshot.wild_entities.clear();
|
||
snapshot.wild_entities.push(BigFishRuntimeEntity {
|
||
entity_id: "wild-cull".to_string(),
|
||
level: 1,
|
||
position: BigFishVector2 { x: 1000.0, y: 0.0 },
|
||
radius: entity_radius(1),
|
||
offscreen_seconds: 2.8,
|
||
});
|
||
snapshot.updated_at_micros = 1_000_000;
|
||
|
||
let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 1_250_000);
|
||
|
||
assert!(
|
||
!next
|
||
.wild_entities
|
||
.iter()
|
||
.any(|entity| entity.entity_id == "wild-cull")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn offscreen_same_level_wild_entity_is_kept_before_three_seconds_elapsed() {
|
||
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
|
||
let mut snapshot = build_initial_runtime_snapshot(
|
||
"run-cull-safe".to_string(),
|
||
"session-cull-safe".to_string(),
|
||
&draft,
|
||
1,
|
||
);
|
||
snapshot.wild_entities.clear();
|
||
snapshot.wild_entities.push(BigFishRuntimeEntity {
|
||
entity_id: "wild-cull-safe".to_string(),
|
||
level: 1,
|
||
position: BigFishVector2 { x: 1000.0, y: 0.0 },
|
||
radius: entity_radius(1),
|
||
offscreen_seconds: 2.7,
|
||
});
|
||
snapshot.updated_at_micros = 1_000_000;
|
||
|
||
let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 1_200_000);
|
||
|
||
assert!(
|
||
next.wild_entities
|
||
.iter()
|
||
.any(|entity| entity.entity_id == "wild-cull-safe")
|
||
);
|
||
}
|
||
}
|