use crate::{
MortarAsset, MortarAudioSettings, MortarEvent, MortarEventTracker, MortarRegistry,
MortarRuntime, MortarVariableState, audio::auto_play_sound_events, evaluate_if_condition,
process_interpolated_text,
};
use bevy::asset::Assets;
use bevy::ecs::schedule::SystemSet;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use std::collections::HashSet;
mod condition_cache;
mod run_execution;
mod text_events;
pub use condition_cache::{CachedCondition, evaluate_condition_cached};
use text_events::collect_text_events;
pub struct MortarDialoguePlugin;
#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
pub enum MortarDialogueSystemSet {
UpdateText,
TriggerEvents,
}
impl Plugin for MortarDialoguePlugin {
fn build(&self, app: &mut App) {
app.configure_sets(
Update,
(
MortarDialogueSystemSet::UpdateText,
MortarDialogueSystemSet::TriggerEvents,
)
.chain(),
)
.init_resource::<MortarAudioSettings>()
.init_resource::<MortarDialogueVariables>()
.init_resource::<MortarRunsExecuting>()
.init_resource::<LoggedConstants>()
.add_message::<MortarGameEvent>()
.add_systems(
Update,
(
log_public_constants_once,
run_execution::process_run_statements_after_text,
update_mortar_text_targets.in_set(MortarDialogueSystemSet::UpdateText),
run_execution::trigger_bound_events.in_set(MortarDialogueSystemSet::TriggerEvents),
run_execution::process_pending_run_executions,
auto_play_sound_events
.after(MortarDialogueSystemSet::TriggerEvents)
.after(run_execution::process_pending_run_executions),
),
)
.add_systems(PostUpdate, run_execution::clear_runs_executing_flag);
}
}
#[derive(Component)]
pub struct MortarTextTarget;
#[derive(Component, Debug, Clone, Default)]
pub struct MortarDialogueText {
pub header: String,
pub body: String,
}
impl MortarDialogueText {
pub fn full_text(&self) -> String {
format!("{}{}", self.header, self.body)
}
}
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct MortarEventBinding {
pub current_index: f32,
}
#[derive(Message, Debug, Clone)]
pub struct MortarGameEvent {
pub source: Option<Entity>,
pub name: String,
pub args: Vec<String>,
}
#[derive(Resource, Default)]
pub struct MortarDialogueVariables {
pub state: Option<MortarVariableState>,
active_path: Option<String>,
}
impl MortarDialogueVariables {
fn reset(&mut self) {
self.state = None;
self.active_path = None;
}
fn ensure_for(
&mut self,
path: &str,
asset: &mortar_compiler::MortaredData,
) -> &mut MortarVariableState {
if self.active_path.as_deref() != Some(path) {
self.state = Some(MortarVariableState::from_variables(
&asset.variables,
&asset.constants,
&asset.enums,
));
self.active_path = Some(path.to_string());
} else if self.state.is_none() {
self.state = Some(MortarVariableState::from_variables(
&asset.variables,
&asset.constants,
&asset.enums,
));
}
self.state.as_mut().expect("variable state initialized")
}
}
#[derive(Resource, Default)]
pub struct MortarRunsExecuting {
pub executing: bool,
}
#[derive(Resource, Default)]
pub struct LoggedConstants {
seen_paths: HashSet<String>,
}
#[derive(SystemParam)]
struct TextUpdateParams<'w, 's> {
commands: Commands<'w, 's>,
runtime: Res<'w, MortarRuntime>,
registry: Res<'w, MortarRegistry>,
assets: Res<'w, Assets<MortarAsset>>,
texts: Query<'w, 's, (Entity, &'static mut Text), With<MortarTextTarget>>,
variable_cache: ResMut<'w, MortarDialogueVariables>,
runs_executing: Res<'w, MortarRunsExecuting>,
events: MessageWriter<'w, MortarEvent>,
}
fn log_public_constants_once(
runtime: Res<MortarRuntime>,
registry: Res<MortarRegistry>,
assets: Res<Assets<MortarAsset>>,
mut logged: ResMut<LoggedConstants>,
) {
let Some(state) = runtime.primary_dialogue_state() else {
return;
};
if logged.seen_paths.contains(&state.mortar_path) {
return;
}
let Some(handle) = registry.get(&state.mortar_path) else {
return;
};
let Some(asset) = assets.get(handle) else {
return;
};
let public_consts: Vec<_> = asset.data.constants.iter().filter(|c| c.public).collect();
if public_consts.is_empty() {
logged.seen_paths.insert(state.mortar_path.clone());
return;
}
info!("Mortar public constants exposed by {}:", state.mortar_path);
for constant in public_consts {
let value_repr = match &constant.value {
serde_json::Value::String(s) => s.clone(),
_ => constant.value.to_string(),
};
info!(
" {} ({}): {}",
constant.name, constant.const_type, value_repr
);
}
logged.seen_paths.insert(state.mortar_path.clone());
}
fn process_line_group(
group: &[crate::TextData],
functions: &crate::MortarFunctionRegistry,
func_decls: &[mortar_compiler::Function],
variable_state: &mut MortarVariableState,
) -> Option<String> {
let mut result_lines = Vec::new();
for line_data in group {
if let Some(condition) = &line_data.condition
&& !evaluate_if_condition(condition, functions, variable_state)
{
continue;
}
for stmt in &line_data.pre_statements {
if stmt.stmt_type == "assignment"
&& let (Some(var_name), Some(value)) = (&stmt.var_name, &stmt.value)
{
variable_state.execute_assignment(var_name, value);
}
}
let line_text = process_interpolated_text(line_data, functions, func_decls, variable_state);
if !line_text.is_empty() {
result_lines.push(line_text);
}
}
if result_lines.is_empty() {
return None;
}
Some(result_lines.join("\n"))
}
fn update_mortar_text_targets(
mut asset_events: MessageReader<AssetEvent<MortarAsset>>,
params: TextUpdateParams,
mut last_key: Local<Option<(String, String, usize)>>,
mut skip_next_conditional: Local<bool>,
mut cached_condition: Local<Option<CachedCondition>>,
) {
let TextUpdateParams {
mut commands,
runtime,
registry,
assets,
mut texts,
mut variable_cache,
runs_executing,
mut events,
} = params;
for event in asset_events.read() {
if let AssetEvent::Modified { id: _ } = event {
info!("Mortar asset modified, reloading variables...");
variable_cache.reset();
*last_key = None; *cached_condition = None;
}
}
if runs_executing.executing {
return;
}
if !runtime.has_active_dialogues() {
variable_cache.reset();
for (_, mut text) in &mut texts {
**text = "等待加载对话...".to_string();
}
*last_key = None;
*cached_condition = None;
return;
}
if !runtime.is_changed() {
return;
}
for (entity, mut text) in &mut texts {
let Some(state) = runtime.primary_dialogue_state() else {
**text = "等待加载对话...".to_string();
*last_key = None;
continue;
};
let asset_data = registry
.get(&state.mortar_path)
.and_then(|handle| assets.get(handle))
.map(|asset| &asset.data);
let current_key = (
state.mortar_path.clone(),
state.current_node.clone(),
state.text_index,
);
if last_key.as_ref() == Some(¤t_key) {
continue;
}
let Some(text_data) = state.current_text_data() else {
continue;
};
let variable_state = if let Some(asset_data) = asset_data {
variable_cache.ensure_for(&state.mortar_path, asset_data)
} else {
variable_cache
.state
.get_or_insert_with(MortarVariableState::new)
};
let func_decls = asset_data
.map(|data| data.functions.as_slice())
.unwrap_or(&[]);
if text_data.is_line {
let group = state.current_line_group().unwrap_or(&[]);
let Some(processed_text) =
process_line_group(group, &runtime.functions, func_decls, variable_state)
else {
*last_key = Some(current_key);
events.write(MortarEvent::next_text());
continue;
};
*skip_next_conditional = false;
commands.entity(entity).remove::<MortarEventTracker>();
commands.entity(entity).remove::<MortarEventBinding>();
*last_key = Some(current_key);
let header = format!("[{} / {}]\n\n", state.mortar_path, state.current_node);
let final_text = format!("{}{}", header, processed_text);
**text = final_text.clone();
commands.entity(entity).insert(MortarDialogueText {
header,
body: processed_text,
});
continue;
}
if *skip_next_conditional && text_data.condition.is_some() {
*skip_next_conditional = false;
*last_key = Some(current_key);
events.write(MortarEvent::next_text());
continue;
}
if let Some(condition) = &text_data.condition {
let result = evaluate_condition_cached(
condition,
&runtime.functions,
variable_state,
&mut cached_condition,
);
if !result {
*last_key = Some(current_key.clone());
events.write(MortarEvent::next_text());
continue;
}
}
let mut executed_statements = false;
for stmt in &text_data.pre_statements {
if stmt.stmt_type == "assignment"
&& let (Some(var_name), Some(value)) = (&stmt.var_name, &stmt.value)
{
variable_state.execute_assignment(var_name, value);
executed_statements = true;
}
}
let processed_text =
process_interpolated_text(text_data, &runtime.functions, func_decls, variable_state);
if processed_text.is_empty() {
if executed_statements && text_data.condition.is_some() {
*skip_next_conditional = true;
}
*last_key = Some(current_key);
events.write(MortarEvent::next_text());
continue;
}
*skip_next_conditional = false;
commands.entity(entity).remove::<MortarEventTracker>();
commands.entity(entity).remove::<MortarEventBinding>();
let all_events = collect_text_events(
text_data,
variable_state,
asset_data,
state.current_text_content_index(),
state.node_data(),
);
if !all_events.is_empty() {
commands
.entity(entity)
.insert(MortarEventTracker::new(all_events))
.insert(MortarEventBinding::default());
}
*last_key = Some(current_key);
let header = format!("[{} / {}]\n\n", state.mortar_path, state.current_node);
let final_text = format!("{}{}", header, processed_text);
**text = final_text.clone();
commands.entity(entity).insert(MortarDialogueText {
header,
body: processed_text,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{TextData, binder::MortarFunctionRegistry};
fn make_line(value: &str) -> TextData {
TextData {
value: value.to_string(),
interpolated_parts: None,
condition: None,
pre_statements: vec![],
events: None,
is_line: true,
}
}
fn make_conditional_line(value: &str, cond: mortar_compiler::IfCondition) -> TextData {
TextData {
value: value.to_string(),
interpolated_parts: None,
condition: Some(cond),
pre_statements: vec![],
events: None,
is_line: true,
}
}
fn true_condition() -> mortar_compiler::IfCondition {
mortar_compiler::IfCondition {
cond_type: "identifier".to_string(),
operator: None,
left: None,
right: None,
operand: None,
value: Some("truthy_var".to_string()),
}
}
fn false_condition() -> mortar_compiler::IfCondition {
mortar_compiler::IfCondition {
cond_type: "identifier".to_string(),
operator: None,
left: None,
right: None,
operand: None,
value: Some("unset_var".to_string()),
}
}
#[test]
fn test_process_line_group_basic() {
let group = vec![make_line("Line A"), make_line("Line B")];
let functions = MortarFunctionRegistry::new();
let func_decls = vec![];
let mut vs = MortarVariableState::default();
let result = process_line_group(&group, &functions, &func_decls, &mut vs);
assert_eq!(result, Some("Line A\nLine B".to_string()));
}
#[test]
fn test_process_line_group_single_line() {
let group = vec![make_line("Only line")];
let functions = MortarFunctionRegistry::new();
let func_decls = vec![];
let mut vs = MortarVariableState::default();
let result = process_line_group(&group, &functions, &func_decls, &mut vs);
assert_eq!(result, Some("Only line".to_string()));
}
#[test]
fn test_process_line_group_all_conditions_false() {
let group = vec![
make_conditional_line("Line A", false_condition()),
make_conditional_line("Line B", false_condition()),
];
let functions = MortarFunctionRegistry::new();
let func_decls = vec![];
let mut vs = MortarVariableState::default();
let result = process_line_group(&group, &functions, &func_decls, &mut vs);
assert_eq!(result, None, "All conditions false → None");
}
#[test]
fn test_process_line_group_mixed_conditions() {
let group = vec![
make_line("Always shown"),
make_conditional_line("True line", true_condition()),
make_conditional_line("False line", false_condition()),
];
let functions = MortarFunctionRegistry::new();
let func_decls = vec![];
let mut vs = MortarVariableState::default();
vs.set("truthy_var", crate::MortarVariableValue::Boolean(true));
let result = process_line_group(&group, &functions, &func_decls, &mut vs);
assert_eq!(
result,
Some("Always shown\nTrue line".to_string()),
"False line should be excluded"
);
}
#[test]
fn test_process_line_group_empty_lines_skipped() {
let group = vec![make_line(""), make_line("Non-empty")];
let functions = MortarFunctionRegistry::new();
let func_decls = vec![];
let mut vs = MortarVariableState::default();
let result = process_line_group(&group, &functions, &func_decls, &mut vs);
assert_eq!(
result,
Some("Non-empty".to_string()),
"Empty lines should be excluded from join"
);
}
}