souprune 0.5.1

A game framework designed specifically for Deltarune / Undertale fangames.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
//!
//! # AM 动画集成模块
//!
//! ## Module Overview
//!
//! ## 模块概述
//!
//! This module integrates Alight Motion animations into the battle system.
//! It handles loading AM projects, spawning entities, and adding collision
//! components based on layer naming conventions.
//!
//! 此模块将 Alight Motion 动画集成到战斗系统中。
//! 它处理加载 AM 项目、生成实体,以及根据图层命名约定添加碰撞组件。
//!
//! ## Layer Naming Conventions / 图层命名约定
//!
//! - Layers matching `bullet_pattern` (default: `^#B`): Bullets with collision
//!   匹配 `bullet_pattern` 的图层(默认:`^#B`):带碰撞的弹幕
//!
//! - Layers matching `battle_box_pattern` (default: `^#C`): Battle box boundary
//!   匹配 `battle_box_pattern` 的图层(默认:`^#C`):战斗框边界
//!
//! - If a group layer matches, all children inherit the same behavior
//!   如果编组图层匹配,所有子元素继承相同行为

use bevy::prelude::*;
use bevy_alight_motion::prelude::*;
use regex::Regex;

use crate::app_state::battle::BattleEntity;
use crate::app_state::battle::collision::{AmBattleBoxBounds, BattleBox};
use crate::core::collision::TriggerCollider;
use crate::core::danmaku::{
    Bullet, BulletDamage, BulletHitBehavior, BulletLastHitTime, BulletMotionState,
};

/// Marker component for AM performance entities.
/// Used to identify and clean up AM-generated entities.
///
/// AM 演出实体的标记组件。
/// 用于识别和清理 AM 生成的实体。
#[derive(Component, Debug, Clone, Default)]
pub struct AmBattleEntity;

/// Marker for entities that should be treated as bullets (from #B group)
/// Inherited from parent group if parent has this marker.
#[derive(Component, Debug, Clone, Default)]
pub struct AmBulletMarker;

/// Marker for entities that should be treated as battle box (from #C group)
/// Inherited from parent group if parent has this marker.
#[derive(Component, Debug, Clone, Default)]
pub struct AmBattleBoxMarker;

/// Marker for entities that should be hidden (based on hidden_pattern config)
/// Inherited from parent group if parent has this marker.
#[derive(Component, Debug, Clone, Default)]
pub struct AmHiddenMarker;

/// Configuration for AM battle integration.
/// Place this in your mod's `battle/am_config.ron` file.
///
/// AM 战斗集成配置。
/// 将此配置放在 mod 的 `battle/am_config.ron` 文件中。
///
/// # Example RON file:
/// ```ron
/// (
///     scale: 2.0,
///     offset: (0.0, -50.0),
///     bullet_pattern: "^#B",
///     battle_box_pattern: "^#C",
///     bullet_damage: 1.0,
///     collision_scale: 0.1,  // Scale down collision boxes to 10% of sprite size
/// )
/// ```
#[derive(Resource, Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct AmBattleConfig {
    /// Scale multiplier for AM project (relative to base scale of 1.0/resolution_scale)
    /// Default: 1.0 (no additional scaling)
    ///
    /// AM 项目的缩放倍数(相对于基础缩放 1.0/resolution_scale)
    /// 默认:1.0(无额外缩放)
    #[serde(default = "default_scale")]
    pub scale: f32,

    /// Offset position for AM project (x, y)
    /// Default: (0.0, 0.0)
    ///
    /// AM 项目的偏移位置 (x, y)
    /// 默认:(0.0, 0.0)
    #[serde(default = "default_offset")]
    pub offset: (f32, f32),

    /// Regex pattern for bullet layers (default: "^#B")
    /// Layers with names matching this pattern are treated as bullets.
    /// If a group matches, all children inherit bullet behavior.
    ///
    /// 弹幕图层的正则表达式模式(默认:"^#B")
    /// 名称匹配此模式的图层被视为弹幕。
    /// 如果编组匹配,所有子元素继承弹幕行为。
    #[serde(default = "default_bullet_pattern")]
    pub bullet_pattern: String,

    /// Regex pattern for battle box layers (default: "^#C")
    /// Layers with names matching this pattern are treated as battle box boundaries.
    /// If a group matches, all children inherit battle box behavior.
    ///
    /// 战斗框图层的正则表达式模式(默认:"^#C")
    /// 名称匹配此模式的图层被视为战斗框边界。
    /// 如果编组匹配,所有子元素继承战斗框行为。
    #[serde(default = "default_battle_box_pattern")]
    pub battle_box_pattern: String,

    /// Regex pattern for layers that should be hidden (default: empty = hide nothing)
    /// Layers with names matching this pattern will have their visibility set to Hidden.
    /// This is useful for hiding collision marker layers that shouldn't be rendered.
    ///
    /// 应该隐藏的图层的正则表达式模式(默认:空 = 不隐藏任何内容)
    /// 名称匹配此模式的图层将被设置为隐藏。
    /// 这对于隐藏不应该渲染的碰撞标记图层很有用。
    #[serde(default = "default_hidden_pattern")]
    pub hidden_pattern: String,

    /// Damage dealt by bullets (default: 1.0)
    ///
    /// 弹幕造成的伤害(默认:1.0)
    #[serde(default = "default_bullet_damage")]
    pub bullet_damage: f32,

    /// Scale factor for bullet collision boxes relative to sprite size (default: 0.05)
    /// Since AM sprites often have large transparent areas, this scales down
    /// the collision box to better match the actual visible content.
    /// For example, 0.05 means collision box is 5% of the sprite size.
    ///
    /// 弹幕碰撞体相对于精灵大小的缩放因子(默认:0.05)
    /// 由于 AM 精灵通常有大面积透明区域,这个参数用于缩小
    /// 碰撞体以更好地匹配实际可见内容。
    /// 例如,0.05 表示碰撞体是精灵大小的 5%。
    #[serde(default = "default_collision_scale")]
    pub collision_scale: f32,
}

fn default_scale() -> f32 {
    1.0
}

fn default_offset() -> (f32, f32) {
    (0.0, 0.0)
}

fn default_bullet_pattern() -> String {
    "^#B".to_string()
}

fn default_battle_box_pattern() -> String {
    "^#C".to_string()
}

fn default_hidden_pattern() -> String {
    String::new() // Empty = hide nothing by default
}

fn default_bullet_damage() -> f32 {
    1.0
}

fn default_collision_scale() -> f32 {
    0.05 // Default to 5% of sprite size since AM sprites often have large transparent areas
}

impl Default for AmBattleConfig {
    fn default() -> Self {
        Self {
            scale: 1.0,
            offset: (0.0, 0.0),
            bullet_pattern: default_bullet_pattern(),
            battle_box_pattern: default_battle_box_pattern(),
            hidden_pattern: default_hidden_pattern(),
            bullet_damage: default_bullet_damage(),
            collision_scale: default_collision_scale(),
        }
    }
}

/// Compiled regex patterns for runtime matching
#[derive(Resource)]
pub struct AmBattlePatterns {
    pub bullet_regex: Option<Regex>,
    pub battle_box_regex: Option<Regex>,
    pub hidden_regex: Option<Regex>,
}

/// Resource to track active AM performance state.
///
/// 追踪活跃 AM 演出状态的资源。
#[derive(Resource, Default)]
pub struct AmPerformanceState {
    /// Whether an AM performance is currently playing
    pub is_playing: bool,
    /// Total duration of the performance in milliseconds
    pub total_duration_ms: f32,
    /// Entity ID of the AM project root (if any)
    pub project_entity: Option<Entity>,
    /// The final scale applied to the AM project (base_scale * config.scale)
    /// Used for collision calculations
    pub final_scale: f32,
}

/// Event to request starting an AM performance.
///
/// 请求开始 AM 演出的事件。
#[derive(bevy::ecs::message::Message, Debug, Clone)]
pub struct PlayAmPerformanceEvent {
    pub amproj_path: String,
    pub wait_for_completion: bool,
}

impl PlayAmPerformanceEvent {
    pub fn new(amproj_path: String) -> Self {
        Self {
            amproj_path,
            wait_for_completion: true,
        }
    }
}

/// Plugin for AM battle integration.
///
/// AM 战斗集成插件。
pub struct AmBattlePlugin;

impl Plugin for AmBattlePlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<AmPerformanceState>()
            .init_resource::<AmBattleConfig>()
            .add_message::<PlayAmPerformanceEvent>()
            .add_systems(
                OnEnter(crate::app_state::AppState::Battle),
                load_am_battle_config,
            )
            .add_systems(
                Update,
                (
                    handle_play_am_performance_event,
                    // Sync fit scale for mask coordinate calculation
                    sync_am_fit_scale_system,
                    // Apply commands so observer results (AmBattleEntity, AmHiddenMarker etc) are available
                    ApplyDeferred,
                    propagate_am_markers_system,
                    // Apply commands before checking markers for collision
                    ApplyDeferred,
                    add_am_collision_system,
                    apply_am_hidden_visibility,
                    // Dynamic update for animated battle boxes
                    update_am_battle_box_bounds_system,
                    check_am_performance_completion,
                )
                    .chain()
                    .in_set(crate::app_state::battle::BattleUpdate),
            )
            .add_systems(
                OnExit(crate::app_state::AppState::Battle),
                cleanup_am_entities,
            );
    }
}

/// System to load AM battle config from the mod's battle directory.
///
/// 从 mod 的 battle 目录加载 AM 战斗配置。
fn load_am_battle_config(
    mut commands: Commands,
    mut am_config: ResMut<AmBattleConfig>,
    project_config: Res<crate::config::SoupruneConfig>,
) {
    let config_path = format!(
        "projects/{}/battle/am_config.ron",
        project_config.project.mod_name
    );

    match std::fs::read_to_string(&config_path) {
        Ok(content) => match ron::from_str::<AmBattleConfig>(&content) {
            Ok(config) => {
                *am_config = config;
                info!(
                    "[AM Battle] Loaded config from {}: scale={}, offset={:?}, bullet_pattern='{}', battle_box_pattern='{}', damage={}",
                    config_path,
                    am_config.scale,
                    am_config.offset,
                    am_config.bullet_pattern,
                    am_config.battle_box_pattern,
                    am_config.bullet_damage
                );
            }
            Err(e) => {
                warn!(
                    "[AM Battle] Failed to parse {}: {}. Using defaults.",
                    config_path, e
                );
            }
        },
        Err(e) => {
            info!(
                "[AM Battle] Config file {} not found ({}). Using defaults: scale={}, offset={:?}",
                config_path, e, am_config.scale, am_config.offset
            );
        }
    }

    // Compile regex patterns
    let bullet_regex = match Regex::new(&am_config.bullet_pattern) {
        Ok(r) => {
            info!(
                "[AM Battle] Compiled bullet regex: '{}'",
                am_config.bullet_pattern
            );
            Some(r)
        }
        Err(e) => {
            warn!(
                "[AM Battle] Invalid bullet pattern '{}': {}",
                am_config.bullet_pattern, e
            );
            None
        }
    };

    let battle_box_regex = match Regex::new(&am_config.battle_box_pattern) {
        Ok(r) => {
            info!(
                "[AM Battle] Compiled battle_box regex: '{}'",
                am_config.battle_box_pattern
            );
            Some(r)
        }
        Err(e) => {
            warn!(
                "[AM Battle] Invalid battle_box pattern '{}': {}",
                am_config.battle_box_pattern, e
            );
            None
        }
    };

    let hidden_regex = if am_config.hidden_pattern.is_empty() {
        None
    } else {
        match Regex::new(&am_config.hidden_pattern) {
            Ok(r) => {
                info!(
                    "[AM Battle] Compiled hidden regex: '{}'",
                    am_config.hidden_pattern
                );
                Some(r)
            }
            Err(e) => {
                warn!(
                    "[AM Battle] Invalid hidden pattern '{}': {}",
                    am_config.hidden_pattern, e
                );
                None
            }
        }
    };

    commands.insert_resource(AmBattlePatterns {
        bullet_regex,
        battle_box_regex,
        hidden_regex,
    });
}

/// Observer function that handles AmEntitySpawned events.
/// Adds marker components based on layer naming conventions.
/// Collision components are added later by propagate_am_markers_system.
///
/// 处理 AmEntitySpawned 事件的观察者函数。
/// 根据图层命名约定添加标记组件。
/// 碰撞组件由 propagate_am_markers_system 稍后添加。
pub fn on_am_entity_spawned(
    trigger: Trigger<AmEntitySpawned>,
    mut commands: Commands,
    patterns: Option<Res<AmBattlePatterns>>,
) {
    let event = trigger.event();
    let layer_name = &event.layer_name;

    // info!(
    //     "[AM Battle] Entity spawned: '{}' (type={:?})",
    //     layer_name, event.element_type
    // );

    // Add AmBattleEntity marker to all AM entities
    commands.entity(event.entity).insert(AmBattleEntity);

    // Check regex patterns for bullet/battle_box/hidden markers
    if let Some(patterns) = patterns {
        // Check bullet pattern
        if let Some(ref regex) = patterns.bullet_regex
            && regex.is_match(layer_name)
        {
            commands.entity(event.entity).insert(AmBulletMarker);
            // info!(
            //     "  → Matched bullet pattern, added AmBulletMarker to '{}'",
            //     layer_name
            // );
        }

        // Check battle_box pattern
        if let Some(ref regex) = patterns.battle_box_regex
            && regex.is_match(layer_name)
        {
            commands.entity(event.entity).insert(AmBattleBoxMarker);
            // info!(
            //     "  → Matched battle_box pattern, added AmBattleBoxMarker to '{}'",
            //     layer_name
            // );
        }

        // Check hidden pattern - mark layers matching this pattern for hiding
        if let Some(ref regex) = patterns.hidden_regex
            && regex.is_match(layer_name)
        {
            // Add both AmHiddenMarker (for propagation) and AmForceHidden (for AM library)
            commands.entity(event.entity).insert((
                AmHiddenMarker,
                AmForceHidden, // Tell bevy_alight_motion to keep this hidden
                Visibility::Hidden,
            ));
            // info!(
            //     "  → Matched hidden pattern, added AmHiddenMarker + AmForceHidden to '{}'",
            //     layer_name
            // );
        }
    }
}

/// System to propagate AM markers from parent groups to children.
///
/// 将 AM 标记从父编组传播到子元素。
#[allow(clippy::too_many_arguments)]
fn propagate_am_markers_system(
    mut commands: Commands,
    // All AM entities that might need marker inheritance
    am_entities: Query<
        (
            Entity,
            Option<&AmBulletMarker>,
            Option<&AmBattleBoxMarker>,
            Option<&AmHiddenMarker>,
        ),
        With<AmBattleEntity>,
    >,
    // Parent hierarchy for inheritance
    parent_query: Query<&ChildOf>,
) {
    // Propagate markers from parents to children
    for (entity, bullet_marker, battle_box_marker, hidden_marker) in am_entities.iter() {
        // Check if already has all markers - can skip
        let has_bullet = bullet_marker.is_some();
        let has_battle_box = battle_box_marker.is_some();
        let has_hidden = hidden_marker.is_some();

        // If already has all markers we care about, skip
        if has_bullet && has_battle_box && has_hidden {
            continue;
        }

        // Check parent chain for markers
        let mut current = entity;
        let mut inherited_bullet = false;
        let mut inherited_battle_box = false;
        let mut inherited_hidden = false;

        while let Ok(child_of) = parent_query.get(current) {
            let parent = child_of.parent();

            // Check if parent has markers
            if let Ok((_, parent_bullet, parent_battle_box, parent_hidden)) =
                am_entities.get(parent)
            {
                if !has_bullet && parent_bullet.is_some() {
                    inherited_bullet = true;
                }
                if !has_battle_box && parent_battle_box.is_some() {
                    inherited_battle_box = true;
                }
                if !has_hidden && parent_hidden.is_some() {
                    inherited_hidden = true;
                }
            }

            // If found all needed inheritance, stop
            if (has_bullet || inherited_bullet)
                && (has_battle_box || inherited_battle_box)
                && (has_hidden || inherited_hidden)
            {
                break;
            }

            current = parent;
        }

        // Apply inherited markers
        if inherited_bullet {
            commands.entity(entity).insert(AmBulletMarker);
            info!(
                "[AM Battle] Inherited AmBulletMarker to entity {:?}",
                entity
            );
        }
        if inherited_battle_box {
            commands.entity(entity).insert(AmBattleBoxMarker);
            info!(
                "[AM Battle] Inherited AmBattleBoxMarker to entity {:?}",
                entity
            );
        }
        if inherited_hidden {
            // Add both AmHiddenMarker (for tracking) and AmForceHidden (for AM library)
            commands.entity(entity).insert((
                AmHiddenMarker,
                AmForceHidden, // Tell bevy_alight_motion to keep this hidden
                Visibility::Hidden,
            ));
            info!(
                "[AM Battle] Inherited AmHiddenMarker + AmForceHidden to entity {:?}",
                entity
            );
        }
    }
}

/// System to add collision components to marked AM entities.
/// Runs after `propagate_am_markers_system` and `apply_deferred`.
///
/// 为标记的 AM 实体添加碰撞组件。
/// 在 `propagate_am_markers_system` 和 `apply_deferred` 之后运行。
#[allow(clippy::too_many_arguments)]
fn add_am_collision_system(
    mut commands: Commands,
    am_config: Res<AmBattleConfig>,
    am_state: Res<AmPerformanceState>,
    // Entities with bullet marker that need collision (newly added)
    bullet_marker_query: Query<Entity, (With<AmBulletMarker>, Without<Bullet>)>,
    // Entities with battle_box marker that need components (newly added)
    battle_box_marker_query: Query<Entity, (With<AmBattleBoxMarker>, Without<BattleBox>)>,
    // AmLayerSpec query for collision size (contains actual layer dimensions)
    layer_spec_query: Query<&AmLayerSpec>,
    // AmAnimated query for layer's animated scale
    animated_query: Query<&AmAnimated>,
    // Parent query to traverse hierarchy
    parent_query: Query<&ChildOf>,
    // Visibility query for hiding bullet layers
    mut visibility_query: Query<&mut Visibility>,
) {
    // Helper function to check if layer spec is a visual element that should have collision
    fn is_visual_element(spec: &AmLayerSpec) -> bool {
        matches!(
            spec,
            AmLayerSpec::SpriteShape { .. }
                | AmLayerSpec::SdfShape { .. }
                | AmLayerSpec::Image { .. }
                | AmLayerSpec::Text { .. }
        )
    }

    // Helper function to get size from AmLayerSpec (SDF shapes have actual dimensions)
    fn get_layer_size(spec: &AmLayerSpec) -> Option<(f32, f32)> {
        match spec {
            AmLayerSpec::SpriteShape { width, height, .. } => Some((*width, *height)),
            AmLayerSpec::SdfShape { width, height, .. } => Some((*width, *height)),
            AmLayerSpec::Image { width, height, .. } => Some((*width, *height)),
            AmLayerSpec::Text { .. } | AmLayerSpec::Null | AmLayerSpec::EmbedScene => None,
        }
    }

    // Helper function to get initial scale from AmAnimated.scale
    fn get_animated_scale(animated: &AmAnimated) -> Vec2 {
        // First try static value
        if let Some(val) = &animated.scale.value {
            return Vec2::new(val[0].abs(), val[1].abs());
        }
        // Then try first keyframe
        if let Some(kf) = animated.scale.keyframes.first() {
            // Parse "x,y" format
            let parts: Vec<&str> = kf.value.split(',').collect();
            if parts.len() == 2
                && let (Ok(x), Ok(y)) = (
                    parts[0].trim().parse::<f32>(),
                    parts[1].trim().parse::<f32>(),
                )
            {
                return Vec2::new(x.abs(), y.abs());
            }
        }
        // Default to 1.0
        Vec2::ONE
    }

    // Helper function to compute total scale by traversing parent hierarchy
    fn compute_total_scale(
        entity: Entity,
        animated_query: &Query<&AmAnimated>,
        parent_query: &Query<&ChildOf>,
        final_scale: f32,
    ) -> Vec2 {
        let mut total_scale = Vec2::splat(final_scale);
        let mut current = entity;

        // Traverse up the hierarchy
        loop {
            // Get this entity's own scale
            if let Ok(animated) = animated_query.get(current) {
                let scale = get_animated_scale(animated);
                total_scale *= scale;
            }

            // Move to parent
            if let Ok(child_of) = parent_query.get(current) {
                current = child_of.0;
            } else {
                break;
            }
        }

        total_scale
    }

    // Add collision components to bullet-marked entities
    // Only add collision to actual visual elements, not groups (Null/EmbedScene)
    // Now using SDF shape dimensions directly from AmLayerSpec
    for entity in bullet_marker_query.iter() {
        // Check if this is a visual element and get size from AmLayerSpec
        let (width, height) = if let Ok(spec) = layer_spec_query.get(entity) {
            if let Some((w, h)) = get_layer_size(spec) {
                info!(
                    "[AM Battle] Entity {:?} layer spec size: {}x{} (spec={:?})",
                    entity, w, h, spec
                );
                (w, h)
            } else {
                info!(
                    "[AM Battle] SKIPPING entity {:?} - not a visual element (spec={:?})",
                    entity, spec
                );
                continue; // Skip non-visual elements
            }
        } else {
            info!("[AM Battle] SKIPPING entity {:?} - no AmLayerSpec", entity);
            continue;
        };

        // Compute total scale by traversing parent hierarchy
        // This includes: layer's own scale + all parent scales + project root scale (final_scale)
        let total_scale =
            compute_total_scale(entity, &animated_query, &parent_query, am_state.final_scale);

        // Calculate final collision half_size (size * total_scale / 2)
        let half_size = Vec2::new(width * total_scale.x / 2.0, height * total_scale.y / 2.0);

        commands.entity(entity).insert((
            Bullet,
            TriggerCollider::Box { half_size },
            BulletDamage(am_config.bullet_damage),
            // AM bullets use no invincibility_duration since they're animated
            // and motion_state.elapsed doesn't track their real age
            BulletHitBehavior {
                despawn_on_hit: false,
                damage_on_player_moving: false,
                damage_on_player_stationary: false,
                invincibility_duration: 0.0, // Disable bullet i-frames for AM bullets
            },
            BulletLastHitTime::default(),
            BulletMotionState::new(Vec2::ZERO),
        ));

        // TODO: Temporarily disabled for debugging
        // Hide the bullet layer (set visibility to Hidden)
        // if let Ok(mut visibility) = visibility_query.get_mut(entity) {
        //     *visibility = Visibility::Hidden;
        //     info!(
        //         "[AM Battle] Hidden bullet entity {:?}",
        //         entity
        //     );
        // }

        info!(
            "[AM Battle] ADDED COLLISION to entity {:?} (half_size={:?}, size=({:.1}x{:.1}), total_scale={:?}, damage={})",
            entity, half_size, width, height, total_scale, am_config.bullet_damage
        );
    }

    // Add battle box components to battle_box-marked entities
    for entity in battle_box_marker_query.iter() {
        // Check if this is a visual element (skip groups)
        let (is_visual, _spec_debug) = if let Ok(spec) = layer_spec_query.get(entity) {
            (is_visual_element(spec), format!("{:?}", spec))
        } else {
            (false, "No AmLayerSpec".to_string())
        };

        if !is_visual {
            continue;
        }

        // Compute total scale by traversing parent hierarchy
        let total_scale =
            compute_total_scale(entity, &animated_query, &parent_query, am_state.final_scale);

        // Get size from AmLayerSpec with total_scale
        let (width, height) = if let Ok(spec) = layer_spec_query.get(entity) {
            if let Some((w, h)) = get_layer_size(spec) {
                (w.abs() * total_scale.x, h.abs() * total_scale.y)
            } else {
                (565.0, 140.0)
            }
        } else {
            (565.0, 140.0)
        };

        // Calculate center_offset from anchor_offset
        // anchor_offset moves entity from center to pivot point
        // So center_offset = -anchor_offset to go back to center
        // Also need to apply scale to the offset
        let center_offset = if let Ok(animated) = animated_query.get(entity) {
            -animated.anchor_offset * total_scale
        } else {
            Vec2::ZERO
        };

        commands.entity(entity).insert((
            BattleBox,
            AmBattleBoxBounds {
                width,
                height,
                center_offset,
            },
        ));

        info!(
            "[AM Battle] Added BattleBox to entity {:?} (size={}x{}, total_scale={:?}, center_offset={:?})",
            entity, width, height, total_scale, center_offset
        );
    }
}

/// System to handle PlayAmPerformanceEvent.
///
/// 处理 PlayAmPerformanceEvent 的系统。
fn handle_play_am_performance_event(
    mut commands: Commands,
    mut events: bevy::ecs::message::MessageReader<PlayAmPerformanceEvent>,
    mut am_state: ResMut<AmPerformanceState>,
    asset_server: Res<AssetServer>,
    am_config: Res<AmBattleConfig>,
) {
    for event in events.read() {
        info!("[AM Battle] Starting performance: {}", event.amproj_path);

        // Load the AM project
        let entity = load_am_project(&mut commands, &asset_server, &event.amproj_path);

        // Calculate scale to fit the AM project into the camera view
        // We use a fixed base scale of 0.25 to match the behavior at resolution_scale=4.
        // This ensures the AM project size remains constant relative to the game world
        // regardless of the actual window resolution_scale.
        let base_scale = 0.25;
        let final_scale = base_scale * am_config.scale;

        // Apply offset from config (scaled by base_scale to work in screen coordinates)
        let offset = Vec3::new(
            am_config.offset.0 * base_scale,
            am_config.offset.1 * base_scale,
            0.0,
        );

        // Mark as battle entity and apply scale and offset
        // IMPORTANT: We must update inv_fit_scale when we override the Transform.scale
        // to keep mask coordinate calculations consistent with the actual transform.
        commands.entity(entity).insert((
            BattleEntity,
            Transform {
                translation: offset,
                scale: Vec3::splat(final_scale),
                ..Default::default()
            },
        ));

        // Update AmPendingLayers.inv_fit_scale to match our custom scale
        // This ensures mask coordinate calculations use the correct scale factor
        commands
            .entity(entity)
            .queue(move |mut entity_world: bevy::ecs::world::EntityWorldMut| {
                // Update all descendant AmPendingLayers components
                if let Some(mut pending) = entity_world.get_mut::<AmPendingLayers>() {
                    let old_inv_fit_scale = pending.inv_fit_scale;
                    pending.inv_fit_scale = 1.0 / final_scale;
                    bevy::log::info!(
                        "[AM Battle] Updated inv_fit_scale: {} -> {} (final_scale={})",
                        old_inv_fit_scale,
                        pending.inv_fit_scale,
                        final_scale
                    );
                }
            });

        info!(
            "[AM Battle] Performance started, entity: {:?}, base_scale: {}, config_scale: {}, final_scale: {}, offset: {:?}",
            entity, base_scale, am_config.scale, final_scale, am_config.offset
        );

        // Register the observer for this project's spawned entities
        commands.add_observer(on_am_entity_spawned);

        // Update state
        am_state.is_playing = true;
        am_state.project_entity = Some(entity);
        am_state.final_scale = final_scale;
    }
}

/// System to check if AM performance has completed.
///
/// 检查 AM 演出是否完成的系统。
fn check_am_performance_completion(
    playback: Option<Res<AmPlayback>>,
    mut am_state: ResMut<AmPerformanceState>,
    am_roots: Query<(Entity, &Name, &AmProjectRoot, &GlobalTransform), With<AmProjectRoot>>,
) {
    // Debug: Log all AM project roots
    // for (entity, name, root, transform) in am_roots.iter() {
    //     info!(
    //         "[AM Battle Debug] Project root: {:?} '{}' spawned={} pos={:?}",
    //         entity,
    //         name,
    //         root.spawned,
    //         transform.translation()
    //     );
    // }

    if !am_state.is_playing {
        return;
    }

    // Check if playback exists and has finished
    if let Some(playback) = playback {
        let total_duration = playback.total_time_ms;
        am_state.total_duration_ms = total_duration;

        // Check if animation has finished
        if playback.current_time_ms >= total_duration {
            info!(
                "[AM Battle] Performance completed ({}ms / {}ms)",
                playback.current_time_ms, total_duration
            );
            am_state.is_playing = false;
        }
    }
}

/// System to cleanup AM entities when exiting battle.
///
/// 退出战斗时清理 AM 实体的系统。
fn cleanup_am_entities(
    mut commands: Commands,
    query: Query<Entity, With<AmBattleEntity>>,
    mut am_state: ResMut<AmPerformanceState>,
) {
    for entity in query.iter() {
        commands.entity(entity).despawn();
    }

    am_state.is_playing = false;
    am_state.project_entity = None;

    info!("[AM Battle] Cleaned up AM entities");
}

/// System to apply visibility hidden to entities with AmHiddenMarker.
/// Runs after propagate_am_markers_system and apply_deferred so all markers are propagated.
///
/// 将带有 AmHiddenMarker 的实体设置为隐藏。
/// 在 propagate_am_markers_system 和 apply_deferred 之后运行,确保所有标记都已传播。
fn apply_am_hidden_visibility(
    mut hidden_entities: Query<(Entity, &Name, &mut Visibility), With<AmHiddenMarker>>,
) {
    for (entity, name, mut visibility) in hidden_entities.iter_mut() {
        if *visibility != Visibility::Hidden {
            *visibility = Visibility::Hidden;
            // Only log when we actually change visibility
            info!(
                "[AM Battle] Applied Hidden visibility to entity {:?} '{}'",
                entity, name
            );
        }
    }
}

/// System to dynamically update battle box bounds based on current animation time.
/// This handles battle boxes with scale animations (e.g., shrinking/expanding).
///
/// 根据当前动画时间动态更新战斗框边界。
/// 处理带有缩放动画的战斗框(如收缩/扩展)。
fn update_am_battle_box_bounds_system(
    playback: Option<Res<AmPlayback>>,
    am_state: Res<AmPerformanceState>,
    mut battle_box_query: Query<(Entity, &AmAnimated, &AmLayerSpec, &mut AmBattleBoxBounds)>,
    parent_query: Query<&ChildOf>,
    animated_query: Query<&AmAnimated>,
) {
    let Some(playback) = playback else {
        return;
    };

    if !am_state.is_playing {
        return;
    }

    let current_time_ms = playback.current_time_ms;

    for (entity, animated, layer_spec, mut bounds) in battle_box_query.iter_mut() {
        // Get base size from layer spec
        let (base_width, base_height) = match layer_spec {
            AmLayerSpec::SdfShape { width, height, .. } => (width.abs(), height.abs()),
            AmLayerSpec::Image { width, height, .. } => (width.abs(), height.abs()),
            _ => continue,
        };

        // Calculate total scale by traversing parent hierarchy with current time interpolation
        let total_scale = compute_total_scale_at_time(
            entity,
            &animated_query,
            &parent_query,
            am_state.final_scale,
            current_time_ms,
        );

        // Get this entity's current scale at this time
        let local_time = animated.calc_local_time(current_time_ms);
        let local_scale = get_animated_scale_at_time(&animated.scale, local_time);

        // Final dimensions
        let new_width = base_width * total_scale.x * local_scale.x;
        let new_height = base_height * total_scale.y * local_scale.y;

        // Calculate center_offset with current scale
        // anchor_offset is static, but we need to scale it by current total scale
        let full_scale = total_scale * local_scale;
        let new_center_offset = -animated.anchor_offset * full_scale;

        // Only update if changed significantly (avoid noise)
        if (bounds.width - new_width).abs() > 0.1
            || (bounds.height - new_height).abs() > 0.1
            || (bounds.center_offset - new_center_offset).length() > 0.1
        {
            bounds.width = new_width;
            bounds.height = new_height;
            bounds.center_offset = new_center_offset;
        }
    }
}

/// Get animated scale at a specific local time using interpolation.
///
/// 使用插值获取特定本地时间的动画缩放。
fn get_animated_scale_at_time(scale_prop: &AmAnimatedVec2, local_time_ms: f32) -> Vec2 {
    // Use interpolate_vec2 from bevy_alight_motion
    if let Some([x, y]) = interpolate_vec2(scale_prop, local_time_ms) {
        Vec2::new(x.abs(), y.abs())
    } else {
        // Fall back to default
        Vec2::ONE
    }
}

/// Compute total scale from parent hierarchy at a specific time.
///
/// 计算特定时间下从父级层次结构累积的总缩放。
fn compute_total_scale_at_time(
    entity: Entity,
    animated_query: &Query<&AmAnimated>,
    parent_query: &Query<&ChildOf>,
    final_scale: f32,
    current_time_ms: f32,
) -> Vec2 {
    let mut total_scale = Vec2::splat(final_scale);
    let mut current = entity;

    // Traverse up the hierarchy (skip the entity itself, we handle it separately)
    if let Ok(child_of) = parent_query.get(current) {
        current = child_of.0;
    } else {
        return total_scale;
    }

    // Traverse parent chain
    loop {
        if let Ok(animated) = animated_query.get(current) {
            let local_time = animated.calc_local_time(current_time_ms);
            let scale = get_animated_scale_at_time(&animated.scale, local_time);
            total_scale *= scale;
        }

        if let Ok(child_of) = parent_query.get(current) {
            current = child_of.0;
        } else {
            break;
        }
    }

    total_scale
}

/// System to synchronize inv_fit_scale with the scale applied by souprune.
/// This ensures mask coordinates are correctly calculated when souprune applies
/// additional scaling to the AM project root entity.
///
/// 同步 inv_fit_scale 与 souprune 应用的缩放。
/// 确保当 souprune 对 AM 项目根实体应用额外缩放时,遮罩坐标能正确计算。
fn sync_am_fit_scale_system(
    am_state: Res<AmPerformanceState>,
    mut pending_layers_query: Query<&mut AmPendingLayers>,
) {
    if !am_state.is_playing {
        return;
    }

    // Update inv_fit_scale based on the scale applied by souprune
    // final_scale is the combined scale applied to the project root
    for mut pending_layers in pending_layers_query.iter_mut() {
        let expected_inv_fit_scale = 1.0 / am_state.final_scale;

        // Only update if different (avoid unnecessary mutation)
        if (pending_layers.inv_fit_scale - expected_inv_fit_scale).abs() > 0.0001 {
            info!(
                "[AM Battle] Updating inv_fit_scale from {} to {} (final_scale={})",
                pending_layers.inv_fit_scale, expected_inv_fit_scale, am_state.final_scale
            );
            pending_layers.inv_fit_scale = expected_inv_fit_scale;
        }
    }
}