完善抓大鹅创作入口与运行态表现

This commit is contained in:
2026-05-01 22:07:55 +08:00
parent 8c03ec95c6
commit 9a3db67e13
25 changed files with 1320 additions and 183 deletions

View File

@@ -15,10 +15,26 @@ pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3;
pub const MATCH3D_MIN_DIFFICULTY: u32 = 1;
pub const MATCH3D_MAX_DIFFICULTY: u32 = 10;
pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000;
pub const MATCH3D_BOARD_RADIUS: f32 = 1.0;
pub const MATCH3D_BOARD_CENTER: f32 = 0.5;
pub const MATCH3D_BOARD_RADIUS: f32 = 0.5;
pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035;
// 首版 demo 使用固定 10 组颜色形状 key后续真实题材素材接入时仍保持 item_type_id 三个一组
const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [
// 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键
const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [
"watermelon-green",
"apple-red",
"banana-yellow",
"grape-purple",
"melon-green",
"berry-blue",
"peach-pink",
"plum-indigo",
"lime-lime",
"orange-orange",
];
// 中文注释:非水果题材使用颜色形状兜底 key前端必须逐个渲染不能统一兜成同一图案。
const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [
"red_circle",
"yellow_triangle",
"purple_diamond",
@@ -428,7 +444,12 @@ pub fn start_run_with_seed_at(
total_item_count,
cleared_item_count: 0,
board_version: 1,
items: build_initial_items(config.clear_count, config.difficulty, seed),
items: build_initial_items(
config.clear_count,
config.difficulty,
seed,
&config.theme_text,
),
tray_slots: empty_tray_slots(),
failure_reason: None,
last_confirmed_action_id: None,
@@ -561,18 +582,26 @@ pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) {
}
}
fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec<Match3DItemSnapshot> {
fn build_initial_items(
clear_count: u32,
difficulty: u32,
seed: u64,
theme_text: &str,
) -> Vec<Match3DItemSnapshot> {
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
let radius = resolve_item_radius(difficulty);
let base_radius = resolve_item_radius(difficulty);
let visual_keys = visual_keys_for_theme(theme_text);
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
for clear_index in 0..clear_count {
let visual_index = (clear_index as usize) % MATCH3D_DEMO_VISUAL_KEYS.len();
let visual_index = (clear_index as usize) % visual_keys.len();
let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
let visual_key = MATCH3D_DEMO_VISUAL_KEYS[visual_index].to_string();
let visual_key = visual_keys[visual_index].to_string();
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
let (x, y) = random_point_in_circle(&mut rng, MATCH3D_BOARD_RADIUS - radius);
let radius =
resolve_item_radius_variant(base_radius, &visual_key, visual_index, copy_index);
let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius));
let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index;
items.push(Match3DItemSnapshot {
item_instance_id: format!("match3d-item-{instance_index:04}"),
@@ -601,21 +630,87 @@ fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec<Matc
items
}
fn visual_keys_for_theme(theme_text: &str) -> &'static [&'static str; 10] {
if is_fruit_theme(theme_text) {
&MATCH3D_FRUIT_VISUAL_KEYS
} else {
&MATCH3D_SHAPE_VISUAL_KEYS
}
}
fn is_fruit_theme(theme_text: &str) -> bool {
let normalized = theme_text.trim().to_lowercase();
[
"水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "",
"", "", "", "",
]
.iter()
.any(|marker| normalized.contains(marker))
}
fn resolve_item_radius(difficulty: u32) -> f32 {
let clamped = difficulty.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY);
let radius = 0.105 - (clamped as f32 - 1.0) * 0.0055;
radius.max(0.052)
}
fn resolve_item_radius_variant(
base_radius: f32,
visual_key: &str,
visual_index: usize,
copy_index: u32,
) -> f32 {
let copy_delta = (copy_index as f32 - 1.0) * 0.002;
if is_fruit_visual_key(visual_key) {
return (base_radius * fruit_visual_size_scale(visual_key) + copy_delta).clamp(0.04, 0.13);
}
let type_delta = ((visual_index % 5) as f32 - 2.0) * 0.004;
(base_radius + type_delta + copy_delta).clamp(0.045, 0.12)
}
fn is_fruit_visual_key(visual_key: &str) -> bool {
matches!(
visual_key,
"watermelon-green"
| "apple-red"
| "banana-yellow"
| "grape-purple"
| "melon-green"
| "berry-blue"
| "peach-pink"
| "plum-indigo"
| "lime-lime"
| "orange-orange"
| "pear-cyan"
)
}
fn fruit_visual_size_scale(visual_key: &str) -> f32 {
match visual_key {
"watermelon-green" => 1.24,
"melon-green" => 1.12,
"banana-yellow" => 1.04,
"apple-red" | "orange-orange" | "peach-pink" | "pear-cyan" => 1.0,
"plum-indigo" | "lime-lime" => 0.86,
"grape-purple" | "berry-blue" => 0.78,
_ => 1.0,
}
}
fn max_spawn_offset(radius: f32) -> f32 {
(MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN - radius).max(0.0)
}
fn random_point_in_circle(rng: &mut DeterministicRng, max_radius: f32) -> (f32, f32) {
for _ in 0..24 {
let x = rng.next_unit_signed() * max_radius;
let y = rng.next_unit_signed() * max_radius;
if x * x + y * y <= max_radius * max_radius {
return (x, y);
return (MATCH3D_BOARD_CENTER + x, MATCH3D_BOARD_CENTER + y);
}
}
(0.0, 0.0)
(MATCH3D_BOARD_CENTER, MATCH3D_BOARD_CENTER)
}
fn fully_covers(
@@ -888,6 +983,117 @@ mod tests {
assert!(counts.values().all(|count| count % 3 == 0));
}
#[test]
fn initial_run_uses_slightly_different_item_sizes() {
let run = start_run_with_seed_at(
"run-size".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(6),
21,
1_000,
)
.expect("run should start");
let mut radii = run
.items
.iter()
.map(|item| (item.radius * 1_000.0).round() as u32)
.collect::<Vec<_>>();
radii.sort();
radii.dedup();
assert!(radii.len() > 1);
}
#[test]
fn fruit_theme_generates_fruit_visuals_inside_board() {
let run = start_run_with_seed_at(
"run-fruit".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(10),
12,
1_000,
)
.expect("run should start");
let visual_keys = run
.items
.iter()
.map(|item| item.visual_key.as_str())
.collect::<Vec<_>>();
assert!(visual_keys.contains(&"watermelon-green"));
assert!(visual_keys.contains(&"apple-red"));
assert!(visual_keys.contains(&"banana-yellow"));
assert!(!visual_keys.contains(&"red_circle"));
for item in &run.items {
let dx = item.x - MATCH3D_BOARD_CENTER;
let dy = item.y - MATCH3D_BOARD_CENTER;
let distance = (dx * dx + dy * dy).sqrt();
assert!(
distance + item.radius <= MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN + 0.0001,
"item {} should stay inside board: x={}, y={}, radius={}",
item.item_instance_id,
item.x,
item.y,
item.radius
);
}
}
#[test]
fn fruit_theme_uses_common_sense_relative_sizes() {
let run = start_run_with_seed_at(
"run-fruit-size".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&test_config(10),
27,
1_000,
)
.expect("run should start");
let max_radius_for_visual = |visual_key: &str| {
run.items
.iter()
.filter(|item| item.visual_key == visual_key)
.map(|item| item.radius)
.fold(0.0, f32::max)
};
let watermelon = max_radius_for_visual("watermelon-green");
let apple = max_radius_for_visual("apple-red");
let grape = max_radius_for_visual("grape-purple");
assert!(watermelon > apple);
assert!(apple > grape);
}
#[test]
fn non_fruit_theme_generates_shape_visuals() {
let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid");
let run = start_run_with_seed_at(
"run-shapes".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
&config,
13,
1_000,
)
.expect("run should start");
let visual_keys = run
.items
.iter()
.map(|item| item.visual_key.as_str())
.collect::<Vec<_>>();
assert!(visual_keys.contains(&"red_circle"));
assert!(visual_keys.contains(&"yellow_triangle"));
assert!(!visual_keys.contains(&"apple-red"));
}
#[test]
fn clicking_three_same_items_clears_and_wins() {
let mut run = start_run_with_seed_at(