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,
};
#[derive(Component, Debug, Clone, Default)]
pub struct AmBattleEntity;
#[derive(Component, Debug, Clone, Default)]
pub struct AmBulletMarker;
#[derive(Component, Debug, Clone, Default)]
pub struct AmBattleBoxMarker;
#[derive(Component, Debug, Clone, Default)]
pub struct AmHiddenMarker;
#[derive(Resource, Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct AmBattleConfig {
#[serde(default = "default_scale")]
pub scale: f32,
#[serde(default = "default_offset")]
pub offset: (f32, f32),
#[serde(default = "default_bullet_pattern")]
pub bullet_pattern: String,
#[serde(default = "default_battle_box_pattern")]
pub battle_box_pattern: String,
#[serde(default = "default_hidden_pattern")]
pub hidden_pattern: String,
#[serde(default = "default_bullet_damage")]
pub bullet_damage: f32,
#[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() }
fn default_bullet_damage() -> f32 {
1.0
}
fn default_collision_scale() -> f32 {
0.05 }
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(),
}
}
}
#[derive(Resource)]
pub struct AmBattlePatterns {
pub bullet_regex: Option<Regex>,
pub battle_box_regex: Option<Regex>,
pub hidden_regex: Option<Regex>,
}
#[derive(Resource, Default)]
pub struct AmPerformanceState {
pub is_playing: bool,
pub total_duration_ms: f32,
pub project_entity: Option<Entity>,
pub final_scale: f32,
}
#[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,
}
}
}
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_am_fit_scale_system,
ApplyDeferred,
propagate_am_markers_system,
ApplyDeferred,
add_am_collision_system,
apply_am_hidden_visibility,
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,
);
}
}
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
);
}
}
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,
});
}
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;
commands.entity(event.entity).insert(AmBattleEntity);
if let Some(patterns) = patterns {
if let Some(ref regex) = patterns.bullet_regex
&& regex.is_match(layer_name)
{
commands.entity(event.entity).insert(AmBulletMarker);
}
if let Some(ref regex) = patterns.battle_box_regex
&& regex.is_match(layer_name)
{
commands.entity(event.entity).insert(AmBattleBoxMarker);
}
if let Some(ref regex) = patterns.hidden_regex
&& regex.is_match(layer_name)
{
commands.entity(event.entity).insert((
AmHiddenMarker,
AmForceHidden, Visibility::Hidden,
));
}
}
}
#[allow(clippy::too_many_arguments)]
fn propagate_am_markers_system(
mut commands: Commands,
am_entities: Query<
(
Entity,
Option<&AmBulletMarker>,
Option<&AmBattleBoxMarker>,
Option<&AmHiddenMarker>,
),
With<AmBattleEntity>,
>,
parent_query: Query<&ChildOf>,
) {
for (entity, bullet_marker, battle_box_marker, hidden_marker) in am_entities.iter() {
let has_bullet = bullet_marker.is_some();
let has_battle_box = battle_box_marker.is_some();
let has_hidden = hidden_marker.is_some();
if has_bullet && has_battle_box && has_hidden {
continue;
}
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();
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 (has_bullet || inherited_bullet)
&& (has_battle_box || inherited_battle_box)
&& (has_hidden || inherited_hidden)
{
break;
}
current = parent;
}
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 {
commands.entity(entity).insert((
AmHiddenMarker,
AmForceHidden, Visibility::Hidden,
));
info!(
"[AM Battle] Inherited AmHiddenMarker + AmForceHidden to entity {:?}",
entity
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn add_am_collision_system(
mut commands: Commands,
am_config: Res<AmBattleConfig>,
am_state: Res<AmPerformanceState>,
bullet_marker_query: Query<Entity, (With<AmBulletMarker>, Without<Bullet>)>,
battle_box_marker_query: Query<Entity, (With<AmBattleBoxMarker>, Without<BattleBox>)>,
layer_spec_query: Query<&AmLayerSpec>,
animated_query: Query<&AmAnimated>,
parent_query: Query<&ChildOf>,
mut visibility_query: Query<&mut Visibility>,
) {
fn is_visual_element(spec: &AmLayerSpec) -> bool {
matches!(
spec,
AmLayerSpec::SpriteShape { .. }
| AmLayerSpec::SdfShape { .. }
| AmLayerSpec::Image { .. }
| AmLayerSpec::Text { .. }
)
}
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,
}
}
fn get_animated_scale(animated: &AmAnimated) -> Vec2 {
if let Some(val) = &animated.scale.value {
return Vec2::new(val[0].abs(), val[1].abs());
}
if let Some(kf) = animated.scale.keyframes.first() {
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());
}
}
Vec2::ONE
}
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;
loop {
if let Ok(animated) = animated_query.get(current) {
let scale = get_animated_scale(animated);
total_scale *= scale;
}
if let Ok(child_of) = parent_query.get(current) {
current = child_of.0;
} else {
break;
}
}
total_scale
}
for entity in bullet_marker_query.iter() {
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; }
} else {
info!("[AM Battle] SKIPPING entity {:?} - no AmLayerSpec", entity);
continue;
};
let total_scale =
compute_total_scale(entity, &animated_query, &parent_query, am_state.final_scale);
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),
BulletHitBehavior {
despawn_on_hit: false,
damage_on_player_moving: false,
damage_on_player_stationary: false,
invincibility_duration: 0.0, },
BulletLastHitTime::default(),
BulletMotionState::new(Vec2::ZERO),
));
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
);
}
for entity in battle_box_marker_query.iter() {
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;
}
let total_scale =
compute_total_scale(entity, &animated_query, &parent_query, am_state.final_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)
};
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
);
}
}
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);
let entity = load_am_project(&mut commands, &asset_server, &event.amproj_path);
let base_scale = 0.25;
let final_scale = base_scale * am_config.scale;
let offset = Vec3::new(
am_config.offset.0 * base_scale,
am_config.offset.1 * base_scale,
0.0,
);
commands.entity(entity).insert((
BattleEntity,
Transform {
translation: offset,
scale: Vec3::splat(final_scale),
..Default::default()
},
));
commands
.entity(entity)
.queue(move |mut entity_world: bevy::ecs::world::EntityWorldMut| {
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
);
commands.add_observer(on_am_entity_spawned);
am_state.is_playing = true;
am_state.project_entity = Some(entity);
am_state.final_scale = final_scale;
}
}
fn check_am_performance_completion(
playback: Option<Res<AmPlayback>>,
mut am_state: ResMut<AmPerformanceState>,
am_roots: Query<(Entity, &Name, &AmProjectRoot, &GlobalTransform), With<AmProjectRoot>>,
) {
if !am_state.is_playing {
return;
}
if let Some(playback) = playback {
let total_duration = playback.total_time_ms;
am_state.total_duration_ms = total_duration;
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;
}
}
}
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");
}
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;
info!(
"[AM Battle] Applied Hidden visibility to entity {:?} '{}'",
entity, name
);
}
}
}
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() {
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,
};
let total_scale = compute_total_scale_at_time(
entity,
&animated_query,
&parent_query,
am_state.final_scale,
current_time_ms,
);
let local_time = animated.calc_local_time(current_time_ms);
let local_scale = get_animated_scale_at_time(&animated.scale, local_time);
let new_width = base_width * total_scale.x * local_scale.x;
let new_height = base_height * total_scale.y * local_scale.y;
let full_scale = total_scale * local_scale;
let new_center_offset = -animated.anchor_offset * full_scale;
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;
}
}
}
fn get_animated_scale_at_time(scale_prop: &AmAnimatedVec2, local_time_ms: f32) -> Vec2 {
if let Some([x, y]) = interpolate_vec2(scale_prop, local_time_ms) {
Vec2::new(x.abs(), y.abs())
} else {
Vec2::ONE
}
}
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;
if let Ok(child_of) = parent_query.get(current) {
current = child_of.0;
} else {
return total_scale;
}
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
}
fn sync_am_fit_scale_system(
am_state: Res<AmPerformanceState>,
mut pending_layers_query: Query<&mut AmPendingLayers>,
) {
if !am_state.is_playing {
return;
}
for mut pending_layers in pending_layers_query.iter_mut() {
let expected_inv_fit_scale = 1.0 / am_state.final_scale;
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;
}
}
}