390 lines
18 KiB
Rust
390 lines
18 KiB
Rust
use module_big_fish::BigFishAnchorPack;
|
||
use serde_json::Value as JsonValue;
|
||
use spacetime_client::{
|
||
BigFishAgentMessageRecord, BigFishAnchorPackRecord, BigFishGameDraftRecord,
|
||
BigFishLevelBlueprintRecord, BigFishSessionRecord,
|
||
};
|
||
|
||
use crate::creation_agent_anchor_templates::{
|
||
get_creation_agent_anchor_template, render_anchor_question_block,
|
||
};
|
||
use crate::creation_agent_chat::render_quick_fill_extra_rules;
|
||
|
||
pub(crate) const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥儿主共创“大鱼吃小鱼”竖屏玩法的中文创意策划。
|
||
|
||
你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。
|
||
|
||
你必须同时输出:
|
||
1. 一段直接发给用户的中文回复 replyText
|
||
2. 当前进度 progressPercent
|
||
3. 下一轮完整可用的 nextAnchorPack
|
||
|
||
硬约束:
|
||
1. 只能输出 JSON,不能输出代码块或解释
|
||
2. nextAnchorPack 必须是完整对象,不能只输出 patch
|
||
3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词
|
||
4. replyText 一次最多推进一个最关键问题
|
||
5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果
|
||
6. progressPercent 范围只能是 0 到 100
|
||
7. status 只能使用 missing / inferred / confirmed / locked
|
||
"#;
|
||
|
||
const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
|
||
{
|
||
"replyText": "",
|
||
"progressPercent": 0,
|
||
"nextAnchorPack": {
|
||
"gameplayPromise": {
|
||
"key": "gameplayPromise",
|
||
"label": "玩法承诺",
|
||
"value": "",
|
||
"status": "missing"
|
||
},
|
||
"ecologyVisualTheme": {
|
||
"key": "ecologyVisualTheme",
|
||
"label": "生态视觉主题",
|
||
"value": "",
|
||
"status": "missing"
|
||
},
|
||
"growthLadder": {
|
||
"key": "growthLadder",
|
||
"label": "成长阶梯",
|
||
"value": "",
|
||
"status": "missing"
|
||
},
|
||
"riskTempo": {
|
||
"key": "riskTempo",
|
||
"label": "风险节奏",
|
||
"value": "",
|
||
"status": "missing"
|
||
}
|
||
}
|
||
}"#;
|
||
|
||
/// 大鱼吃小鱼草稿生成对话提示词脚本。
|
||
///
|
||
/// 这里单独承载 Agent 共创阶段的 system prompt 与 user prompt 组装,
|
||
/// 避免聊天契约、草稿编译路由和结果页资产生成混在同一个业务文件里。
|
||
pub(crate) fn build_big_fish_agent_prompt(
|
||
session: &BigFishSessionRecord,
|
||
quick_fill_requested: bool,
|
||
) -> String {
|
||
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
|
||
.map(render_anchor_question_block)
|
||
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
|
||
let quick_fill_rules = if quick_fill_requested {
|
||
format!(
|
||
"\n\n{}",
|
||
render_quick_fill_extra_rules(
|
||
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
|
||
"不要要求用户再提供等级、鱼群、场景或节奏信息",
|
||
"输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项",
|
||
"生成结果页",
|
||
)
|
||
)
|
||
} else {
|
||
String::new()
|
||
};
|
||
format!(
|
||
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
||
anchor_question_block = anchor_question_block,
|
||
quick_fill_rules = quick_fill_rules,
|
||
turn = session.current_turn.saturating_add(1),
|
||
progress = session.progress_percent,
|
||
quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" },
|
||
anchor_pack = serialize_record_anchor_pack(&session.anchor_pack),
|
||
chat_history =
|
||
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
|
||
.unwrap_or_else(|_| "[]".to_string()),
|
||
contract = BIG_FISH_AGENT_OUTPUT_CONTRACT,
|
||
)
|
||
}
|
||
|
||
/// 大鱼吃小鱼主图生成提示词脚本。
|
||
pub(crate) fn build_big_fish_level_main_image_prompt(
|
||
_draft: &BigFishGameDraftRecord,
|
||
level: &BigFishLevelBlueprintRecord,
|
||
) -> String {
|
||
vec![
|
||
"生成角色形象图片。".to_string(),
|
||
format!(
|
||
"等级:Lv.{},名称:{},幻想描述:{}。",
|
||
level.level, level.name, level.one_line_fantasy
|
||
),
|
||
format!("文字描述:{}。", level.text_description),
|
||
format!("轮廓方向:{}。", level.silhouette_direction),
|
||
format!("形象描述:{}。", level.visual_description),
|
||
format!("主图提示词:{}。", level.visual_prompt_seed),
|
||
"等级对形象的影响规则:等级越高越霸气、有气场、看起来强大、画面细节丰富,等级级别越低越弱小、普通。最低等级为1级,最高等级可能是6-12级".to_string(),
|
||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(),
|
||
"背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要出现多只主体。".to_string(),
|
||
]
|
||
.join("")
|
||
}
|
||
|
||
/// 大鱼吃小鱼动作关键帧生成提示词脚本。
|
||
pub(crate) fn build_big_fish_level_motion_prompt(
|
||
draft: &BigFishGameDraftRecord,
|
||
level: &BigFishLevelBlueprintRecord,
|
||
motion_key: &str,
|
||
) -> String {
|
||
let motion_text = match motion_key {
|
||
"move_swim" => format!(
|
||
"{} 向右游动的关键帧预览,身体与尾鳍有清晰推进姿态,带轻微水流拖尾。",
|
||
level.move_motion_description
|
||
),
|
||
_ => format!(
|
||
"{} 待机漂浮的关键帧预览,身体轻微摆动,姿态稳定,适合作为 idle 状态。",
|
||
level.idle_motion_description
|
||
),
|
||
};
|
||
vec![
|
||
format!(
|
||
"为竖屏移动游戏《{}》生成一张等级生物动作关键帧静态预览图。",
|
||
draft.title
|
||
),
|
||
format!("生态主题:{}。", draft.ecology_theme),
|
||
format!(
|
||
"等级:Lv.{},名称:{},幻想描述:{}。",
|
||
level.level, level.name, level.one_line_fantasy
|
||
),
|
||
format!("文字描述:{}。", level.text_description),
|
||
format!("动作提示词种子:{}。", level.motion_prompt_seed),
|
||
format!("动作要求:{motion_text}"),
|
||
"画面要求:按 RPG 角色动画资产口径生成,单体鱼形生物完整入镜,轮廓清晰,动作方向明确,2D 高完成度游戏插画,适合作为 Big Fish 动作槽位的静态 keyframe。".to_string(),
|
||
"背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要生成序列帧拼图,不要出现多只主体。".to_string(),
|
||
]
|
||
.join("")
|
||
}
|
||
|
||
/// 大鱼吃小鱼场地背景生成提示词脚本。
|
||
pub(crate) fn build_big_fish_stage_background_prompt(draft: &BigFishGameDraftRecord) -> String {
|
||
let background = &draft.background;
|
||
vec![
|
||
"生成一张 9:16 的游戏场景背景图。".to_string(),
|
||
format!("生态主题:{}。", draft.ecology_theme),
|
||
format!("背景主题:{}。色彩氛围:{}。", background.theme, background.color_mood),
|
||
format!("前景提示:{}。", background.foreground_hints),
|
||
format!("中景构图:{}。", background.midground_composition),
|
||
format!("背景纵深:{}。", background.background_depth),
|
||
format!("安全操作区:{}。", background.safe_play_area_hint),
|
||
format!("出生边缘:{}。", background.spawn_edge_hint),
|
||
format!("背景提示词种子:{}。", background.background_prompt_seed),
|
||
"画面要求:竖屏9:16,大场地,全屏运行态背景,中央 80% 保持开阔清爽,边缘只保留少量出生区环境提示。".to_string(),
|
||
"元素要求:画面中不出现任何形象主体、密集装饰、UI、文字、logo、水印、对话框或边框;不要把中央操作区画得过暗或过复杂。".to_string(),
|
||
]
|
||
.join("")
|
||
}
|
||
|
||
/// 大鱼吃小鱼图片生成默认反向提示词脚本。
|
||
pub(crate) const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,复杂背景";
|
||
|
||
/// 大鱼吃小鱼透明主体类图片生成默认反向提示词脚本。
|
||
pub(crate) const BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,多余肢体,畸形鱼体,低清晰度,模糊,压缩噪点,现代摄影棚,写实照片背景,场景背景,水草背景,气泡背景,多只主体,阴影地面";
|
||
|
||
fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec<JsonValue> {
|
||
messages
|
||
.iter()
|
||
.map(|message| {
|
||
serde_json::json!({
|
||
"role": message.role,
|
||
"kind": message.kind,
|
||
"content": message.text,
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) fn serialize_record_anchor_pack(anchor_pack: &BigFishAnchorPackRecord) -> String {
|
||
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
|
||
.unwrap_or_else(|_| "{}".to_string())
|
||
}
|
||
|
||
fn map_big_fish_record_anchor_pack(record: &BigFishAnchorPackRecord) -> BigFishAnchorPack {
|
||
BigFishAnchorPack {
|
||
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
|
||
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
|
||
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
|
||
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
|
||
}
|
||
}
|
||
|
||
fn map_big_fish_record_anchor_item(
|
||
record: &spacetime_client::BigFishAnchorItemRecord,
|
||
) -> module_big_fish::BigFishAnchorItem {
|
||
module_big_fish::BigFishAnchorItem {
|
||
key: record.key.clone(),
|
||
label: record.label.clone(),
|
||
value: record.value.clone(),
|
||
status: match record.status.as_str() {
|
||
"confirmed" => module_big_fish::BigFishAnchorStatus::Confirmed,
|
||
"locked" => module_big_fish::BigFishAnchorStatus::Locked,
|
||
"inferred" => module_big_fish::BigFishAnchorStatus::Inferred,
|
||
_ => module_big_fish::BigFishAnchorStatus::Missing,
|
||
},
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
BIG_FISH_DEFAULT_NEGATIVE_PROMPT, BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT,
|
||
build_big_fish_agent_prompt, build_big_fish_level_main_image_prompt,
|
||
build_big_fish_level_motion_prompt, build_big_fish_stage_background_prompt,
|
||
};
|
||
|
||
fn anchor_item(
|
||
key: &str,
|
||
label: &str,
|
||
value: &str,
|
||
status: &str,
|
||
) -> spacetime_client::BigFishAnchorItemRecord {
|
||
spacetime_client::BigFishAnchorItemRecord {
|
||
key: key.to_string(),
|
||
label: label.to_string(),
|
||
value: value.to_string(),
|
||
status: status.to_string(),
|
||
}
|
||
}
|
||
|
||
fn empty_session_record() -> spacetime_client::BigFishSessionRecord {
|
||
spacetime_client::BigFishSessionRecord {
|
||
session_id: "big-fish-session-test".to_string(),
|
||
current_turn: 2,
|
||
progress_percent: 60,
|
||
stage: "collecting_anchors".to_string(),
|
||
anchor_pack: spacetime_client::BigFishAnchorPackRecord {
|
||
gameplay_promise: anchor_item(
|
||
"gameplayPromise",
|
||
"玩法承诺",
|
||
"微光小鱼逆袭深海巨兽",
|
||
"confirmed",
|
||
),
|
||
ecology_visual_theme: anchor_item(
|
||
"ecologyVisualTheme",
|
||
"生态视觉主题",
|
||
"幽蓝珊瑚海沟",
|
||
"confirmed",
|
||
),
|
||
growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"),
|
||
risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"),
|
||
},
|
||
draft: None,
|
||
asset_slots: Vec::new(),
|
||
asset_coverage: spacetime_client::BigFishAssetCoverageRecord {
|
||
level_main_image_ready_count: 0,
|
||
level_motion_ready_count: 0,
|
||
background_ready: false,
|
||
required_level_count: 8,
|
||
publish_ready: false,
|
||
blockers: Vec::new(),
|
||
},
|
||
messages: Vec::new(),
|
||
last_assistant_reply: None,
|
||
publish_ready: false,
|
||
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
|
||
}
|
||
}
|
||
|
||
fn sample_draft() -> spacetime_client::BigFishGameDraftRecord {
|
||
spacetime_client::BigFishGameDraftRecord {
|
||
title: "深海逆袭".to_string(),
|
||
subtitle: "从微光幼体吞噬到深渊王座".to_string(),
|
||
core_fun: "吞噬成长与风险闪避".to_string(),
|
||
ecology_theme: "幽蓝海沟珊瑚裂谷".to_string(),
|
||
levels: vec![spacetime_client::BigFishLevelBlueprintRecord {
|
||
level: 3,
|
||
name: "裂潮猎游者".to_string(),
|
||
one_line_fantasy: "在电光海沟中疾行收割的中阶猎鱼".to_string(),
|
||
text_description: "裂潮猎游者是中阶进化体,已经具备更清晰的猎食轮廓和压迫感。"
|
||
.to_string(),
|
||
silhouette_direction: "长尾前探、背鳍后掠".to_string(),
|
||
size_ratio: 1.8,
|
||
visual_description: "深海霓虹风格的中阶猎鱼,长尾锐利,骨质鳍刃明显,轮廓成熟。"
|
||
.to_string(),
|
||
visual_prompt_seed: "深海霓虹、锐利长尾、骨质鳍刃".to_string(),
|
||
idle_motion_description:
|
||
"待机时身体轻微悬停,尾鳍保持低频摆动,像是在观察猎物距离。".to_string(),
|
||
move_motion_description: "移动时长尾快速摆动,身体前探,形成明显突进巡游姿态。"
|
||
.to_string(),
|
||
motion_prompt_seed: "突进摆尾、鳍面拉伸、水流拖尾".to_string(),
|
||
merge_source_level: Some(2),
|
||
prey_window: vec![1, 2],
|
||
threat_window: vec![4, 5],
|
||
is_final_level: false,
|
||
}],
|
||
background: spacetime_client::BigFishBackgroundBlueprintRecord {
|
||
theme: "裂谷荧光水域".to_string(),
|
||
color_mood: "蓝绿冷光、边缘紫雾".to_string(),
|
||
foreground_hints: "边角保留细碎水母草和岩脊".to_string(),
|
||
midground_composition: "中央留大面积清爽水道".to_string(),
|
||
background_depth: "远处海沟层叠透视".to_string(),
|
||
safe_play_area_hint: "中央 80% 为操作安全区".to_string(),
|
||
spawn_edge_hint: "左右边缘保留出生点环境提示".to_string(),
|
||
background_prompt_seed: "荧光裂谷、冷色纵深、轻体积光".to_string(),
|
||
},
|
||
runtime_params: spacetime_client::BigFishRuntimeParamsRecord {
|
||
level_count: 8,
|
||
merge_count_per_upgrade: 3,
|
||
spawn_target_count: 12,
|
||
leader_move_speed: 240.0,
|
||
follower_catch_up_speed: 280.0,
|
||
offscreen_cull_seconds: 4.5,
|
||
prey_spawn_delta_levels: vec![0, 1],
|
||
threat_spawn_delta_levels: vec![1, 2],
|
||
win_level: 8,
|
||
},
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn quick_fill_prompt_forbids_follow_up_questions() {
|
||
let prompt = build_big_fish_agent_prompt(&empty_session_record(), true);
|
||
|
||
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
|
||
assert!(prompt.contains("不要再继续提问"));
|
||
assert!(prompt.contains("progressPercent 直接输出为 100"));
|
||
}
|
||
|
||
#[test]
|
||
fn level_main_image_prompt_keeps_core_constraints() {
|
||
let draft = sample_draft();
|
||
let prompt = build_big_fish_level_main_image_prompt(&draft, &draft.levels[0]);
|
||
|
||
assert!(prompt.contains("裂潮猎游者"));
|
||
assert!(prompt.contains("形象描述"));
|
||
assert!(prompt.contains("透明背景 PNG 风格"));
|
||
assert!(prompt.contains("主图提示词"));
|
||
}
|
||
|
||
#[test]
|
||
fn level_motion_prompt_varies_with_motion_key() {
|
||
let draft = sample_draft();
|
||
let move_prompt = build_big_fish_level_motion_prompt(&draft, &draft.levels[0], "move_swim");
|
||
let idle_prompt =
|
||
build_big_fish_level_motion_prompt(&draft, &draft.levels[0], "idle_float");
|
||
|
||
assert!(move_prompt.contains("向右游动的关键帧预览"));
|
||
assert!(idle_prompt.contains("待机漂浮的关键帧预览"));
|
||
assert!(move_prompt.contains("透明背景 PNG 风格"));
|
||
}
|
||
|
||
#[test]
|
||
fn stage_background_prompt_keeps_runtime_field_constraints() {
|
||
let draft = sample_draft();
|
||
let prompt = build_big_fish_stage_background_prompt(&draft);
|
||
|
||
assert!(prompt.contains("生成一张 9:16 的游戏场景背景图"));
|
||
assert!(prompt.contains("中央 80% 保持开阔清爽"));
|
||
assert!(prompt.contains("背景提示词种子"));
|
||
}
|
||
|
||
#[test]
|
||
fn negative_prompts_keep_text_and_background_blockers() {
|
||
assert!(BIG_FISH_DEFAULT_NEGATIVE_PROMPT.contains("文字"));
|
||
assert!(BIG_FISH_DEFAULT_NEGATIVE_PROMPT.contains("复杂背景"));
|
||
assert!(BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.contains("场景背景"));
|
||
assert!(BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT.contains("多只主体"));
|
||
}
|
||
}
|