Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -2,15 +2,56 @@ use shared_kernel::{normalize_optional_string, normalize_required_string, normal
|
||||
|
||||
use crate::commands::{default_tags_for_theme, validate_result_publish_fields};
|
||||
use crate::{
|
||||
MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BOARD_SAFE_MARGIN,
|
||||
MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_FRUIT_VISUAL_KEYS, MATCH3D_ITEMS_PER_CLEAR,
|
||||
MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, MATCH3D_SHAPE_VISUAL_KEYS,
|
||||
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason,
|
||||
Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot,
|
||||
Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot,
|
||||
Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile,
|
||||
MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS,
|
||||
MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR,
|
||||
MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY,
|
||||
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput,
|
||||
Match3DClickRejectReason, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError,
|
||||
Match3DItemSnapshot, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft,
|
||||
Match3DRunSnapshot, Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Match3DSizeTierRule {
|
||||
ratio: f32,
|
||||
radius_scale: f32,
|
||||
relative_volume: f32,
|
||||
tier: &'static str,
|
||||
}
|
||||
|
||||
const MATCH3D_SIZE_TIER_RULES: [Match3DSizeTierRule; 5] = [
|
||||
Match3DSizeTierRule {
|
||||
tier: "XL",
|
||||
ratio: 0.20,
|
||||
relative_volume: 1.86,
|
||||
radius_scale: 1.23,
|
||||
},
|
||||
Match3DSizeTierRule {
|
||||
tier: "L",
|
||||
ratio: 0.30,
|
||||
relative_volume: 1.40,
|
||||
radius_scale: 1.12,
|
||||
},
|
||||
Match3DSizeTierRule {
|
||||
tier: "M",
|
||||
ratio: 0.30,
|
||||
relative_volume: 1.00,
|
||||
radius_scale: 1.00,
|
||||
},
|
||||
Match3DSizeTierRule {
|
||||
tier: "XS",
|
||||
ratio: 0.15,
|
||||
relative_volume: 0.73,
|
||||
radius_scale: 0.90,
|
||||
},
|
||||
Match3DSizeTierRule {
|
||||
tier: "S",
|
||||
ratio: 0.05,
|
||||
relative_volume: 0.44,
|
||||
radius_scale: 0.76,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft {
|
||||
let game_name = format!("{}抓大鹅", config.theme_text);
|
||||
let summary = format!(
|
||||
@@ -268,17 +309,18 @@ fn build_initial_items(
|
||||
) -> Vec<Match3DItemSnapshot> {
|
||||
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
|
||||
let base_radius = resolve_item_radius(difficulty);
|
||||
let visual_keys = visual_keys_for_theme(theme_text);
|
||||
let selected_visual_keys = select_visual_keys(&mut rng, theme_text, clear_count);
|
||||
let item_type_count = resolve_item_type_count(clear_count);
|
||||
let size_tier_plan = resolve_size_tier_plan(item_type_count);
|
||||
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) % visual_keys.len();
|
||||
let visual_index = (clear_index as usize) % item_type_count;
|
||||
let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
|
||||
let visual_key = visual_keys[visual_index].to_string();
|
||||
let visual_key = selected_visual_keys[visual_index].to_string();
|
||||
let radius = resolve_item_radius_variant(base_radius, size_tier_plan[visual_index]);
|
||||
|
||||
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
|
||||
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 {
|
||||
@@ -308,22 +350,57 @@ fn build_initial_items(
|
||||
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 resolve_size_tier_plan(item_type_count: usize) -> Vec<Match3DSizeTierRule> {
|
||||
let mut plans = MATCH3D_SIZE_TIER_RULES
|
||||
.iter()
|
||||
.map(|rule| {
|
||||
let exact_count = item_type_count as f32 * rule.ratio;
|
||||
(exact_count.floor() as usize, exact_count.fract(), *rule)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut assigned_count = plans
|
||||
.iter()
|
||||
.map(|(count, _, _)| *count)
|
||||
.sum::<usize>();
|
||||
let mut remainder_order = (0..plans.len()).collect::<Vec<_>>();
|
||||
remainder_order.sort_by(|left, right| {
|
||||
plans[*right]
|
||||
.1
|
||||
.partial_cmp(&plans[*left].1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
let mut cursor = 0;
|
||||
while assigned_count < item_type_count {
|
||||
let plan_index = remainder_order[cursor % remainder_order.len()];
|
||||
plans[plan_index].0 += 1;
|
||||
assigned_count += 1;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
plans
|
||||
.into_iter()
|
||||
.flat_map(|(count, _, rule)| std::iter::repeat(rule).take(count))
|
||||
.collect()
|
||||
}
|
||||
|
||||
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_type_count(clear_count: u32) -> usize {
|
||||
clear_count.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize
|
||||
}
|
||||
|
||||
fn select_visual_keys(
|
||||
rng: &mut DeterministicRng,
|
||||
_theme_text: &str,
|
||||
clear_count: u32,
|
||||
) -> Vec<&'static str> {
|
||||
let item_type_count = resolve_item_type_count(clear_count);
|
||||
let mut visual_keys = MATCH3D_BLOCK_VISUAL_KEYS.to_vec();
|
||||
// 中文注释:只打乱类型池顺序,不改变每个类型三件一组的可通关结构。
|
||||
for index in (1..visual_keys.len()).rev() {
|
||||
let swap_index = (rng.next_u32() as usize) % (index + 1);
|
||||
visual_keys.swap(index, swap_index);
|
||||
}
|
||||
visual_keys.truncate(item_type_count);
|
||||
visual_keys
|
||||
}
|
||||
|
||||
fn resolve_item_radius(difficulty: u32) -> f32 {
|
||||
@@ -332,48 +409,10 @@ fn resolve_item_radius(difficulty: u32) -> f32 {
|
||||
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 resolve_item_radius_variant(base_radius: f32, size_tier: Match3DSizeTierRule) -> f32 {
|
||||
debug_assert!(!size_tier.tier.is_empty());
|
||||
debug_assert!(size_tier.relative_volume > 0.0);
|
||||
(base_radius * size_tier.radius_scale).clamp(0.045, 0.13)
|
||||
}
|
||||
|
||||
fn max_spawn_offset(radius: f32) -> f32 {
|
||||
@@ -623,6 +662,79 @@ mod tests {
|
||||
assert!(counts.values().all(|count| count % 3 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_type_count_follows_clear_count_until_twenty_five() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-types-small".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(12),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||||
}
|
||||
|
||||
assert_eq!(counts.len(), 12);
|
||||
assert!(counts.values().all(|count| *count == 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_key_count_follows_fifteen_clear_count() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-types-fifteen".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(15),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
let mut item_types_by_visual_key = BTreeMap::<String, Vec<String>>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.visual_key.clone()).or_default() += 1;
|
||||
item_types_by_visual_key
|
||||
.entry(item.visual_key.clone())
|
||||
.or_default()
|
||||
.push(item.item_type_id.clone());
|
||||
}
|
||||
|
||||
assert_eq!(counts.len(), 15);
|
||||
assert!(counts.values().all(|count| *count == 3));
|
||||
assert!(item_types_by_visual_key.values().all(|item_type_ids| {
|
||||
item_type_ids
|
||||
.iter()
|
||||
.all(|item_type_id| item_type_id == &item_type_ids[0])
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_type_count_is_capped_at_twenty_five() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-types-large".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(100),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||||
}
|
||||
|
||||
assert_eq!(counts.len(), 25);
|
||||
assert!(counts.values().all(|count| count % 3 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_run_uses_slightly_different_item_sizes() {
|
||||
let run = start_run_with_seed_at(
|
||||
@@ -647,9 +759,58 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruit_theme_generates_fruit_visuals_inside_board() {
|
||||
fn size_tier_plan_follows_ratio_for_twenty_five_types() {
|
||||
let plan = resolve_size_tier_plan(25);
|
||||
let mut counts = BTreeMap::<&str, usize>::new();
|
||||
for rule in plan {
|
||||
*counts.entry(rule.tier).or_default() += 1;
|
||||
match rule.tier {
|
||||
"XL" => assert!((1.60..=2.30).contains(&rule.relative_volume)),
|
||||
"L" => assert!((1.25..=1.60).contains(&rule.relative_volume)),
|
||||
"M" => assert_eq!(rule.relative_volume, 1.00),
|
||||
"XS" => assert!((0.65..=0.85).contains(&rule.relative_volume)),
|
||||
"S" => assert!((0.35..=0.50).contains(&rule.relative_volume)),
|
||||
_ => panic!("unknown size tier"),
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(counts.get("XL"), Some(&5));
|
||||
assert_eq!(counts.get("L"), Some(&8));
|
||||
assert_eq!(counts.get("M"), Some(&7));
|
||||
assert_eq!(counts.get("XS"), Some(&4));
|
||||
assert_eq!(counts.get("S"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_visual_key_keeps_one_size_in_run() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-fruit".to_string(),
|
||||
"run-size-unique".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(30),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut radii_by_visual_key = BTreeMap::<String, Vec<u32>>::new();
|
||||
for item in &run.items {
|
||||
radii_by_visual_key
|
||||
.entry(item.visual_key.clone())
|
||||
.or_default()
|
||||
.push((item.radius * 10_000.0).round() as u32);
|
||||
}
|
||||
|
||||
assert_eq!(radii_by_visual_key.len(), 25);
|
||||
assert!(radii_by_visual_key.values().all(|radii| {
|
||||
radii.iter().all(|radius| radius == &radii[0])
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_visuals_stay_inside_board() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-blocks".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(10),
|
||||
@@ -663,10 +824,7 @@ mod tests {
|
||||
.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"));
|
||||
assert!(visual_keys.iter().all(|visual_key| visual_key.starts_with("block-")));
|
||||
|
||||
for item in &run.items {
|
||||
let dx = item.x - MATCH3D_BOARD_CENTER;
|
||||
@@ -684,38 +842,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruit_theme_uses_common_sense_relative_sizes() {
|
||||
fn twenty_five_or_less_does_not_repeat_visual_keys() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-fruit-size".to_string(),
|
||||
"run-block-unique".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(10),
|
||||
&test_config(25),
|
||||
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 mut counts = BTreeMap::<String, u32>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.visual_key.clone()).or_default() += 1;
|
||||
}
|
||||
|
||||
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);
|
||||
assert_eq!(counts.len(), 25);
|
||||
assert!(counts.values().all(|count| *count == 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_fruit_theme_generates_shape_visuals() {
|
||||
fn block_visuals_have_different_relative_sizes() {
|
||||
let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid");
|
||||
let run = start_run_with_seed_at(
|
||||
"run-shapes".to_string(),
|
||||
"run-block-size".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&config,
|
||||
@@ -724,14 +875,15 @@ mod tests {
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let visual_keys = run
|
||||
let mut radii = run
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.visual_key.as_str())
|
||||
.map(|item| (item.radius * 1_000.0).round() as u32)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(visual_keys.contains(&"red_circle"));
|
||||
assert!(visual_keys.contains(&"yellow_triangle"));
|
||||
assert!(!visual_keys.contains(&"apple-red"));
|
||||
radii.sort();
|
||||
radii.dedup();
|
||||
|
||||
assert!(radii.len() > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,6 +9,8 @@ pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-";
|
||||
pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-";
|
||||
pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7;
|
||||
pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3;
|
||||
pub const MATCH3D_MAX_ITEM_TYPE_COUNT: u32 = 25;
|
||||
pub(crate) const MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE: usize = 25;
|
||||
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;
|
||||
@@ -16,32 +18,34 @@ 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 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键。
|
||||
pub(crate) 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;前端必须逐个渲染,不能统一兜成同一图案。
|
||||
pub(crate) const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [
|
||||
"red_circle",
|
||||
"yellow_triangle",
|
||||
"purple_diamond",
|
||||
"green_square",
|
||||
"blue_star",
|
||||
"orange_hexagon",
|
||||
"cyan_capsule",
|
||||
"pink_heart",
|
||||
"lime_leaf",
|
||||
"white_moon",
|
||||
// 中文注释:首版 demo 不接真实图片生成,当前先用程序化积木件作为稳定可辨认的默认素材。
|
||||
// 中文注释:当前 demo 使用 25 个积木件作为默认可消除物资源池,前端据 visual_key 程序化生成 3D 模型。
|
||||
pub(crate) const MATCH3D_BLOCK_VISUAL_KEYS: [&str; MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE] = [
|
||||
"block-red-2x4",
|
||||
"block-blue-1x2",
|
||||
"block-yellow-2x2",
|
||||
"block-green-1x4",
|
||||
"block-orange-1x6",
|
||||
"block-white-1x1",
|
||||
"block-black-1x8",
|
||||
"block-tan-2x3",
|
||||
"block-lime-1x2",
|
||||
"block-darkred-2x2",
|
||||
"block-blue-1x4",
|
||||
"block-pink-2x4",
|
||||
"block-gray-1x6",
|
||||
"block-lavender-tile-2x2",
|
||||
"block-teal-tile-1x3",
|
||||
"block-mint-tile-1x4",
|
||||
"block-magenta-tile-2x2",
|
||||
"block-orange-tile-2x2-stud",
|
||||
"block-purple-slope-1x2",
|
||||
"block-brown-slope-1x2",
|
||||
"block-sky-slope-2x2",
|
||||
"block-green-cylinder",
|
||||
"block-clear-ring",
|
||||
"block-mint-arch",
|
||||
"block-gold-cone",
|
||||
];
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
|
||||
Reference in New Issue
Block a user