1use std::collections::{HashMap, VecDeque};
157use std::sync::Arc;
158
159use anyhow::{Context, Result};
160use dotzuki_engine::battle::stack::{collect_handlers, run_event, BattleCtx, Event, RelayVar};
161use dotzuki_engine::battle::{BattleState, BattlerRef};
162use dotzuki_engine::menu::MenuConfig;
163use dotzuki_engine::render::{FrameBuffer, Rgba, TileRect, Ui};
164use dotzuki_renderer::embedded_font;
165use dotzuki_renderer::input::{GbButton, InputState};
166use dotzuki_ui::widgets::flex_menu::{draw_flex_menu, FlexMenuState};
167use dotzuki_ui::FrameBufferPainter;
168
169use crate::game::{draw_textbox, DIALOG_AREA, SCREEN_H, SCREEN_W};
170use crate::manifest::{BattleLevels, BattleStats, DEFAULT_RULES_FILE};
171use crate::project::LoadedProject;
172use crate::vfs::{join_path, ProjectFiles};
173
174use dotzuki_rules::RulesProvider;
175use hooks::{GenericProvider, HookState};
176
177pub mod hooks;
178
179pub const BASIC_ATTACK_NAME: &str = "Attack";
181pub const BASIC_ATTACK_POWER: u32 = 40;
183pub const BASIC_ATTACK_ACCURACY: u32 = 100;
185const MAX_STAGE: i8 = 4;
187
188pub trait BattleRng {
193 fn byte(&mut self) -> u8;
195}
196
197pub struct XorshiftRng(u64);
199
200impl XorshiftRng {
201 pub fn seed(seed: u64) -> Self {
203 Self(if seed == 0 {
204 0x9E37_79B9_7F4A_7C15
205 } else {
206 seed
207 })
208 }
209}
210
211impl BattleRng for XorshiftRng {
212 fn byte(&mut self) -> u8 {
213 let mut x = self.0;
214 x ^= x >> 12;
215 x ^= x << 25;
216 x ^= x >> 27;
217 self.0 = x;
218 (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as u8
219 }
220}
221
222pub struct ScriptedRng {
227 bytes: Vec<u8>,
228 idx: usize,
229}
230
231impl ScriptedRng {
232 pub fn new(bytes: Vec<u8>) -> Self {
234 Self { bytes, idx: 0 }
235 }
236}
237
238impl BattleRng for ScriptedRng {
239 fn byte(&mut self) -> u8 {
240 if self.bytes.is_empty() {
241 return 0;
242 }
243 let b = self.bytes[self.idx];
244 self.idx = (self.idx + 1) % self.bytes.len();
245 b
246 }
247}
248
249#[derive(Debug, Default, Clone)]
255pub struct TypeChart {
256 edges: HashMap<(String, String), (u32, u32)>,
257}
258
259impl TypeChart {
260 pub fn from_ruleset(ruleset: &dotzuki_rules::Ruleset) -> Self {
262 let mut edges = HashMap::new();
263 for entry in &ruleset.type_chart {
264 edges.insert(
265 (entry.atk.to_lowercase(), entry.def.to_lowercase()),
266 (entry.mult.num, entry.mult.den.max(1)),
267 );
268 }
269 Self { edges }
270 }
271
272 pub fn mult(&self, atk: Option<&str>, def: Option<&str>) -> (u32, u32) {
276 let (Some(atk), Some(def)) = (atk, def) else {
277 return (1, 1);
278 };
279 self.edges
280 .get(&(atk.to_lowercase(), def.to_lowercase()))
281 .copied()
282 .unwrap_or((1, 1))
283 }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum SkillCategory {
291 Damage,
293 Heal,
295 Buff,
297 Debuff,
299}
300
301#[derive(Debug, Clone)]
303pub struct Skill {
304 pub id: String,
306 pub name: String,
308 pub power: u32,
310 pub accuracy: u32,
312 pub element: Option<String>,
314 pub category: SkillCategory,
316 pub stat: String,
319 pub cost: u32,
321 pub ron: bool,
324}
325
326pub fn basic_attack() -> Skill {
328 Skill {
329 id: "basic".to_string(),
330 name: BASIC_ATTACK_NAME.to_string(),
331 power: BASIC_ATTACK_POWER,
332 accuracy: BASIC_ATTACK_ACCURACY,
333 element: None,
334 category: SkillCategory::Damage,
335 stat: "attack".to_string(),
336 cost: 0,
337 ron: false,
338 }
339}
340
341fn skill_from_record(id: &str, record: &serde_json::Value, setup: &BattleSetup) -> Skill {
347 let category = match get_str(record, &setup.category_field)
348 .unwrap_or("")
349 .to_lowercase()
350 .as_str()
351 {
352 "heal" => SkillCategory::Heal,
353 "buff" => SkillCategory::Buff,
354 "debuff" => SkillCategory::Debuff,
355 _ => SkillCategory::Damage,
357 };
358 let stat = normalize_stat_key(get_str(record, "stat").unwrap_or("attack"));
359 let mut skill = Skill {
360 id: id.to_string(),
361 name: get_str(record, "name").unwrap_or(id).to_string(),
362 power: get_num(record, "power").unwrap_or(0),
363 accuracy: get_num(record, "accuracy").unwrap_or(100),
364 element: get_str(record, "element").map(str::to_string),
365 category,
366 stat,
367 cost: get_num(record, &setup.cost_field).unwrap_or(0),
368 ron: false,
369 };
370 if let Some(ron_move) = setup.ron_move(id) {
371 skill.ron = true;
372 if let Some(power) = ron_move.power {
373 skill.power = power;
374 }
375 if let Some(accuracy) = ron_move.accuracy {
376 skill.accuracy = accuracy;
377 }
378 if let Some(mtype) = &ron_move.mtype {
379 skill.element = Some(mtype.clone());
380 }
381 if let Some(cost) = ron_move.cost {
382 skill.cost = cost;
383 }
384 }
385 skill
386}
387
388fn normalize_stat_key(stat: &str) -> String {
390 match stat.to_lowercase().as_str() {
391 "hp" => "hp",
392 "defense" | "def" => "defense",
393 "speed" | "spd" => "speed",
394 _ => "attack",
396 }
397 .to_string()
398}
399
400fn stat_label(stat: &str) -> &str {
402 match stat {
403 "hp" => "HP",
404 "defense" => "Defense",
405 "speed" => "Speed",
406 _ => "Attack",
407 }
408}
409
410#[derive(Debug, Clone, Default)]
415pub struct Stages {
416 pub hp: i8,
418 pub attack: i8,
420 pub defense: i8,
422 pub speed: i8,
424}
425
426impl Stages {
427 fn bump(&mut self, stat: &str, delta: i8) {
428 let stage = match stat {
429 "hp" => &mut self.hp,
430 "defense" => &mut self.defense,
431 "speed" => &mut self.speed,
432 _ => &mut self.attack,
433 };
434 *stage = (*stage + delta).clamp(-MAX_STAGE, MAX_STAGE);
435 }
436
437 pub fn get(&self, stat: &str) -> i8 {
439 match normalize_stat_key(stat).as_str() {
440 "hp" => self.hp,
441 "defense" => self.defense,
442 "speed" => self.speed,
443 _ => self.attack,
444 }
445 }
446
447 pub fn set(&mut self, stat: &str, value: i8) {
449 let value = value.clamp(-MAX_STAGE, MAX_STAGE);
450 match normalize_stat_key(stat).as_str() {
451 "hp" => self.hp = value,
452 "defense" => self.defense = value,
453 "speed" => self.speed = value,
454 _ => self.attack = value,
455 }
456 }
457}
458
459pub fn stage_multiplier(raw: u32, stage: i8) -> u32 {
462 match stage.clamp(-MAX_STAGE, MAX_STAGE) {
463 s if s >= 0 => raw * (4 + s as u32) / 4,
464 s => raw * 4 / (4 - s) as u32,
465 }
466}
467
468#[derive(Debug, Clone, Copy)]
472pub struct BaseStats {
473 pub max_hp: u32,
475 pub max_mp: u32,
477 pub attack: u32,
479 pub defense: u32,
481 pub speed: u32,
483}
484
485pub fn growth_stat(raw: u32, level: u8, growth: f64) -> u32 {
489 let mult = 1.0 + growth * f64::from(level.saturating_sub(1));
490 ((raw as f64 * mult) + 1e-9).floor().max(0.0) as u32
493}
494
495pub fn exp_to_next(base: u32, exponent: u32, level: u8) -> u32 {
498 u64::from(base)
499 .saturating_mul(u64::from(level).saturating_pow(exponent))
500 .min(u64::from(u32::MAX)) as u32
501}
502
503#[derive(Debug, Clone)]
508pub struct Combatant {
509 pub id: String,
511 pub name: String,
513 pub element: Option<String>,
515 pub hp: u32,
517 pub max_hp: u32,
519 pub attack: u32,
521 pub defense: u32,
523 pub speed: u32,
525 pub level: u8,
529 pub base: BaseStats,
531 pub exp: u32,
533 pub exp_reward: u32,
536 pub stages: Stages,
538 pub mp: u32,
540 pub max_mp: u32,
542 pub skills: Vec<Skill>,
544 pub status: Option<String>,
547 pub ability: Option<String>,
552 pub held_item: Option<String>,
557}
558
559impl Combatant {
560 pub fn eff_attack(&self) -> u32 {
562 stage_multiplier(self.attack, self.stages.attack)
563 }
564 pub fn eff_defense(&self) -> u32 {
566 stage_multiplier(self.defense, self.stages.defense)
567 }
568 pub fn eff_speed(&self) -> u32 {
570 stage_multiplier(self.speed, self.stages.speed)
571 }
572
573 pub fn recompute_stats(&mut self, levels: &LevelsSetup) {
577 let grown = |raw: u32| growth_stat(raw, self.level, levels.growth);
578 let (old_hp, old_mp) = (self.max_hp, self.max_mp);
579 self.max_hp = grown(self.base.max_hp);
580 self.max_mp = grown(self.base.max_mp);
581 self.attack = grown(self.base.attack);
582 self.defense = grown(self.base.defense);
583 self.speed = grown(self.base.speed);
584 self.hp = (self.hp + self.max_hp.saturating_sub(old_hp)).min(self.max_hp);
585 self.mp = (self.mp + self.max_mp.saturating_sub(old_mp)).min(self.max_mp);
586 }
587}
588
589#[derive(Debug, Clone)]
596pub struct PartyMemberState {
597 pub id: String,
599 pub hp: u32,
601 pub mp: u32,
603 pub status: Option<String>,
605 pub level: u8,
607 pub exp: u32,
609}
610
611#[derive(Debug, Clone)]
614pub struct BattleItem {
615 pub id: String,
617 pub name: String,
619 pub heal: u32,
621}
622
623pub struct BattleSetup {
630 files: Arc<dyn ProjectFiles>,
632 party_dir: String,
634 enemies_dir: String,
635 encounters_dir: Option<String>,
638 skills_dir: Option<String>,
639 skills_field: String,
640 category_field: String,
641 cost_field: String,
642 stats: BattleStats,
643 resource: Option<String>,
644 chart: TypeChart,
645 ron: Option<RonSetup>,
646 items: Option<ItemsSetup>,
649 levels: Option<LevelsSetup>,
652}
653
654#[derive(Debug, Clone)]
656pub struct LevelsSetup {
657 pub exp_field: String,
659 pub level_field: String,
661 pub curve_base: u32,
663 pub curve_exponent: u32,
665 pub growth: f64,
667 pub max_level: u8,
669}
670
671impl LevelsSetup {
672 fn from_manifest(levels: &BattleLevels) -> Self {
674 Self {
675 exp_field: levels.exp_field.clone(),
676 level_field: levels.level_field.clone(),
677 curve_base: levels.curve.base,
678 curve_exponent: levels.curve.exponent,
679 growth: levels.growth,
680 max_level: levels.max_level.clamp(1, 255) as u8,
681 }
682 }
683
684 pub fn exp_to_next(&self, level: u8) -> u32 {
686 exp_to_next(self.curve_base, self.curve_exponent, level)
687 }
688
689 pub fn growth(&self, raw: u32, level: u8) -> u32 {
691 growth_stat(raw, level, self.growth)
692 }
693}
694
695struct ItemsSetup {
697 dir: String,
699 heal_field: String,
701 starting: HashMap<String, u32>,
703}
704
705struct Encounter {
708 name: String,
710 enemy_ids: Vec<String>,
712 trainer: bool,
714 money: u32,
716}
717
718struct RonSetup {
722 compiled: dotzuki_rules::CompiledRuleset,
724 registry: Vec<&'static dotzuki_engine::battle::stack::Effect<GenericProvider>>,
727 move_records: HashMap<String, hooks::RonMove>,
729 status_names: Vec<String>,
731 stat_names: Vec<String>,
733 has_resource: bool,
735}
736
737impl BattleSetup {
738 pub fn from_project(project: &LoadedProject) -> Result<Self> {
746 let section = project
747 .manifest()
748 .battle
749 .as_ref()
750 .context("manifest has no battle section")?;
751
752 let table_dir =
753 |reference: Option<&crate::manifest::BattleTableRef>, what: &str| -> Result<String> {
754 let id = reference.map(|r| r.table.as_str()).with_context(|| {
755 format!("battle.{what}.table is required when a battle starts")
756 })?;
757 project.table_dir_rel(id).with_context(|| {
758 format!("battle.{what}.table '{id}' is not a declared data table")
759 })
760 };
761 let party_dir = table_dir(section.party.as_ref(), "party")?;
762 let enemies_dir = table_dir(section.enemies.as_ref(), "enemies")?;
763 let encounters_dir = match §ion.encounters {
764 Some(encounters) => {
765 Some(project.table_dir_rel(&encounters.table).with_context(|| {
766 format!(
767 "battle.encounters.table '{}' is not a declared data table",
768 encounters.table
769 )
770 })?)
771 }
772 None => None,
773 };
774 let skills_dir = match §ion.skills {
775 Some(skills) => Some(project.table_dir_rel(&skills.table).with_context(|| {
776 format!(
777 "battle.skills.table '{}' is not a declared data table",
778 skills.table
779 )
780 })?),
781 None => None,
782 };
783 let items = match §ion.items {
784 Some(items) => Some(ItemsSetup {
785 dir: project.table_dir_rel(&items.table).with_context(|| {
786 format!(
787 "battle.items.table '{}' is not a declared data table",
788 items.table
789 )
790 })?,
791 heal_field: items.heal_field.clone(),
792 starting: items.starting.clone(),
793 }),
794 None => None,
795 };
796
797 let (skills_field, category_field, cost_field) = match §ion.skills {
798 Some(s) => (
799 s.field.clone(),
800 s.category_field.clone(),
801 s.cost_field.clone(),
802 ),
803 None => (
804 crate::manifest::DEFAULT_SKILLS_FIELD.to_string(),
805 crate::manifest::DEFAULT_CATEGORY_FIELD.to_string(),
806 crate::manifest::DEFAULT_COST_FIELD.to_string(),
807 ),
808 };
809
810 let rules_rel = section.rules.as_deref().unwrap_or(DEFAULT_RULES_FILE);
815 let rules_rel = join_path("", rules_rel);
816 let mut ron = None;
817 let chart = match project.files().read(&rules_rel) {
818 Ok(bytes) => {
819 let text = String::from_utf8(bytes)
820 .with_context(|| format!("{rules_rel} is not UTF-8"))?;
821 let ruleset = dotzuki_rules::Ruleset::from_ron(&text)
822 .map_err(|e| anyhow::anyhow!("failed to parse {rules_rel}: {e}"))?;
823 let chart = TypeChart::from_ruleset(&ruleset);
824 if !ruleset.effects.is_empty() {
825 let compiled = hooks::compile_ruleset(&ruleset)
826 .map_err(|e| anyhow::anyhow!("failed to compile {rules_rel}: {e}"))?;
827 let registry = compiled.build_effects::<GenericProvider>();
828 let move_records = hooks::ron_moves(&ruleset, section.resource.as_deref());
829 ron = Some(RonSetup {
830 compiled,
831 registry,
832 move_records,
833 status_names: hooks::status_names(&ruleset),
834 stat_names: ruleset.stats.clone(),
835 has_resource: section.resource.is_some(),
836 });
837 }
838 chart
839 }
840 Err(e) => {
841 if section.rules.is_some() {
846 log::warn!(
847 "battle.rules '{rules_rel}' could not be read ({e:#}); battles run with an empty type chart"
848 );
849 }
850 TypeChart::default()
851 }
852 };
853
854 Ok(Self {
855 files: Arc::clone(project.files()),
856 party_dir,
857 enemies_dir,
858 encounters_dir,
859 skills_dir,
860 skills_field,
861 category_field,
862 cost_field,
863 stats: section.stats.clone().unwrap_or_default(),
864 resource: section.resource.clone(),
865 chart,
866 ron,
867 items,
868 levels: section.levels.as_ref().map(LevelsSetup::from_manifest),
869 })
870 }
871
872 pub fn start(&self, enemy_id: &str, rng: Box<dyn BattleRng>) -> Result<Battle> {
880 self.start_with(enemy_id, rng, None, None)
881 }
882
883 pub fn start_with(
902 &self,
903 enemy_id: &str,
904 rng: Box<dyn BattleRng>,
905 party_state: Option<&[PartyMemberState]>,
906 inventory: Option<&HashMap<String, u32>>,
907 ) -> Result<Battle> {
908 let ids = record_ids(self.files.as_ref(), &self.party_dir);
909 if ids.is_empty() {
910 anyhow::bail!("party table {} has no records", self.party_dir);
911 }
912 let mut party = Vec::with_capacity(ids.len());
913 for id in &ids {
914 let mut c = self.load_combatant(&self.party_dir, id)?;
915 if let Some(state) = party_state.and_then(|states| states.iter().find(|s| &s.id == id))
916 {
917 if let Some(levels) = &self.levels {
920 c.level = state.level.max(1);
921 c.exp = state.exp;
922 c.recompute_stats(levels);
923 }
924 c.hp = state.hp.min(c.max_hp);
925 c.mp = state.mp.min(c.max_mp);
926 c.status = state.status.clone();
927 }
928 party.push(c);
929 }
930 let active = party.iter().position(|c| c.hp > 0).unwrap_or(0);
931
932 let (enemy, rest, trainer, money) = match self.load_encounter(enemy_id)? {
935 Some(encounter) => {
936 let mut enemies = Vec::with_capacity(encounter.enemy_ids.len());
937 for id in &encounter.enemy_ids {
938 enemies.push(self.load_combatant(&self.enemies_dir, id)?);
939 }
940 let mut enemies = enemies.into_iter();
941 let first = enemies.next().expect("encounter enemies non-empty");
942 let money = if encounter.trainer {
943 encounter.money
944 } else {
945 0
946 };
947 log::info!(
948 "encounter '{}' ({}): {} enemies, trainer {}",
949 enemy_id,
950 encounter.name,
951 encounter.enemy_ids.len(),
952 encounter.trainer
953 );
954 (first, enemies.collect(), encounter.trainer, money)
955 }
956 None => {
957 let enemy_id = if self
958 .files
959 .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
960 {
961 enemy_id.to_string()
962 } else {
963 let fallback = first_record_id(self.files.as_ref(), &self.enemies_dir)
964 .with_context(|| {
965 format!("enemies table {} has no records", self.enemies_dir)
966 })?;
967 log::warn!(
968 "unknown enemy id '{enemy_id}' — using first enemy record '{fallback}'"
969 );
970 fallback
971 };
972 (
973 self.load_combatant(&self.enemies_dir, &enemy_id)?,
974 Vec::new(),
975 false,
976 0,
977 )
978 }
979 };
980
981 let items = self.load_items();
982 let inventory = inventory.cloned().unwrap_or_else(|| {
983 self.items
984 .as_ref()
985 .map(|i| i.starting.clone())
986 .unwrap_or_default()
987 });
988
989 log::info!(
990 "battle: {} (hp {}, party of {}) vs {} (hp {})",
991 party[active].name,
992 party[active].max_hp,
993 party.len(),
994 enemy.name,
995 enemy.max_hp
996 );
997 let hook_state = self.ron.as_ref().map(|ron| {
1001 hooks::install_compiled(ron.compiled.clone());
1002 HookState {
1003 state: BattleState::new(
1004 vec![hooks::mirror_of(
1005 &party[active],
1006 &ron.stat_names,
1007 &ron.status_names,
1008 ron.has_resource,
1009 )],
1010 vec![hooks::mirror_of(
1011 &enemy,
1012 &ron.stat_names,
1013 &ron.status_names,
1014 ron.has_resource,
1015 )],
1016 ),
1017 effects: Vec::new(),
1018 mv: dotzuki_engine::battle::stack::MoveContext::default(),
1019 registry: ron.registry.clone(),
1020 move_records: ron.move_records.clone(),
1021 status_names: ron.status_names.clone(),
1022 stat_names: ron.stat_names.clone(),
1023 has_resource: ron.has_resource,
1024 }
1025 });
1026 let mut battle = Battle::full(
1027 party,
1028 active,
1029 enemy,
1030 items,
1031 inventory,
1032 self.chart.clone(),
1033 rng,
1034 hook_state,
1035 );
1036 battle.set_levels(self.levels.clone());
1037 battle.set_enemy_party(rest, trainer, money);
1038 if battle.party.iter().all(|c| c.hp == 0) {
1039 battle.arm_loss();
1040 }
1041 Ok(battle)
1042 }
1043
1044 fn load_encounter(&self, id: &str) -> Result<Option<Encounter>> {
1049 let Some(dir) = &self.encounters_dir else {
1050 return Ok(None);
1051 };
1052 if !self.files.exists(&format!("{dir}/{id}.json")) {
1053 return Ok(None);
1054 }
1055 let record = read_record(self.files.as_ref(), dir, id)?;
1056 let enemy_ids: Vec<String> = record
1057 .get("enemies")
1058 .and_then(|v| v.as_array())
1059 .map(|ids| {
1060 ids.iter()
1061 .filter_map(|v| v.as_str().map(str::to_string))
1062 .collect()
1063 })
1064 .unwrap_or_default();
1065 if enemy_ids.is_empty() {
1066 anyhow::bail!("encounter '{id}' has no 'enemies' list (or it is empty)");
1067 }
1068 for enemy_id in &enemy_ids {
1069 if !self
1070 .files
1071 .exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
1072 {
1073 anyhow::bail!(
1074 "encounter '{id}' references unknown enemy id '{enemy_id}' \
1075 (no record in the enemies table)"
1076 );
1077 }
1078 }
1079 Ok(Some(Encounter {
1080 name: get_str(&record, "name").unwrap_or(id).to_string(),
1081 enemy_ids,
1082 trainer: record
1083 .get("trainer")
1084 .and_then(|v| v.as_bool())
1085 .unwrap_or(false),
1086 money: get_num(&record, "money").unwrap_or(0),
1087 }))
1088 }
1089
1090 fn load_items(&self) -> Vec<BattleItem> {
1094 let Some(items) = &self.items else {
1095 return Vec::new();
1096 };
1097 let mut out = Vec::new();
1098 for id in record_ids(self.files.as_ref(), &items.dir) {
1099 let record = match read_record(self.files.as_ref(), &items.dir, &id) {
1100 Ok(record) => record,
1101 Err(e) => {
1102 log::warn!("item record '{id}' skipped: {e:#}");
1103 continue;
1104 }
1105 };
1106 let heal = get_num(&record, &items.heal_field).unwrap_or(0);
1107 if heal > 0 {
1108 out.push(BattleItem {
1109 name: get_str(&record, "name").unwrap_or(&id).to_string(),
1110 id,
1111 heal,
1112 });
1113 }
1114 }
1115 out
1116 }
1117
1118 fn ron_move(&self, id: &str) -> Option<&hooks::RonMove> {
1120 self.ron.as_ref()?.move_records.get(id)
1121 }
1122
1123 fn load_combatant(&self, dir: &str, id: &str) -> Result<Combatant> {
1128 let record = read_record(self.files.as_ref(), dir, id)?;
1129 let level_field = self
1130 .levels
1131 .as_ref()
1132 .map(|l| l.level_field.as_str())
1133 .unwrap_or("level");
1134 let level = get_num(&record, level_field).unwrap_or(1).min(255) as u8;
1135 let stat = |field: &str| get_num(&record, field).unwrap_or(1);
1136 let base = BaseStats {
1137 max_hp: stat(&self.stats.hp),
1138 max_mp: self
1139 .resource
1140 .as_deref()
1141 .map(|f| get_num(&record, f).unwrap_or(0))
1142 .unwrap_or(0),
1143 attack: stat(&self.stats.attack),
1144 defense: stat(&self.stats.defense),
1145 speed: stat(&self.stats.speed),
1146 };
1147 let grown = |raw: u32| match &self.levels {
1148 Some(levels) => levels.growth(raw, level),
1149 None => raw,
1150 };
1151 let (max_hp, mp) = (grown(base.max_hp), grown(base.max_mp));
1152 let exp_reward = self
1153 .levels
1154 .as_ref()
1155 .and_then(|l| get_num(&record, &l.exp_field))
1156 .unwrap_or(0);
1157 Ok(Combatant {
1158 id: id.to_string(),
1159 name: get_str(&record, "name").unwrap_or(id).to_string(),
1160 element: get_str(&record, "element").map(str::to_string),
1161 hp: max_hp,
1162 max_hp,
1163 attack: grown(base.attack),
1164 defense: grown(base.defense),
1165 speed: grown(base.speed),
1166 level,
1167 base,
1168 exp: 0,
1169 exp_reward,
1170 stages: Stages::default(),
1171 mp,
1172 max_mp: mp,
1173 skills: self.load_skills(&record),
1174 status: None,
1175 ability: get_str(&record, "ability").map(str::to_string),
1176 held_item: get_str(&record, "heldItem").map(str::to_string),
1177 })
1178 }
1179
1180 fn load_skills(&self, record: &serde_json::Value) -> Vec<Skill> {
1185 let Some(skills_dir) = &self.skills_dir else {
1186 return vec![basic_attack()];
1187 };
1188 let mut skills = Vec::new();
1189 if let Some(ids) = record.get(&self.skills_field).and_then(|v| v.as_array()) {
1190 for id in ids {
1191 let Some(id) = id.as_str() else {
1192 continue;
1193 };
1194 match read_record(self.files.as_ref(), skills_dir, id) {
1195 Ok(rec) => skills.push(skill_from_record(id, &rec, self)),
1196 Err(e) => log::warn!("unknown skill id '{id}' skipped: {e:#}"),
1197 }
1198 }
1199 }
1200 if skills.is_empty() {
1201 skills.push(basic_attack());
1202 }
1203 skills
1204 }
1205}
1206
1207pub(crate) fn record_ids(files: &dyn ProjectFiles, dir: &str) -> Vec<String> {
1210 let prefix = format!("{dir}/");
1211 let mut ids: Vec<String> = files
1212 .list(dir)
1213 .into_iter()
1214 .filter_map(|p| {
1215 let rest = p.strip_prefix(&prefix)?;
1217 if rest.contains('/') {
1218 return None;
1219 }
1220 rest.strip_suffix(".json").map(str::to_string)
1221 })
1222 .collect();
1223 ids.sort();
1224 ids.dedup();
1225 ids
1226}
1227
1228fn first_record_id(files: &dyn ProjectFiles, dir: &str) -> Option<String> {
1230 record_ids(files, dir).into_iter().next()
1231}
1232
1233pub(crate) fn read_record(
1235 files: &dyn ProjectFiles,
1236 dir: &str,
1237 id: &str,
1238) -> Result<serde_json::Value> {
1239 let rel = format!("{dir}/{id}.json");
1240 let bytes = files
1241 .read(&rel)
1242 .with_context(|| format!("failed to read {rel}"))?;
1243 serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {rel}"))
1244}
1245
1246pub(crate) fn get_num(record: &serde_json::Value, field: &str) -> Option<u32> {
1249 match record.get(field)? {
1250 serde_json::Value::Number(n) => n
1251 .as_u64()
1252 .or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
1253 .map(|v| v.min(u32::MAX as u64) as u32),
1254 _ => None,
1255 }
1256}
1257
1258pub(crate) fn get_str<'a>(record: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1260 record.get(field).and_then(|v| v.as_str())
1261}
1262
1263pub fn accuracy_roll(accuracy: u32, rng: &mut dyn BattleRng) -> bool {
1267 u32::from(rng.byte() % 100) < accuracy
1268}
1269
1270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1272pub struct DamageRoll {
1273 pub damage: u32,
1275 pub crit: bool,
1277 pub mult_num: u32,
1279 pub mult_den: u32,
1281}
1282
1283pub fn damage_roll(
1288 power: u32,
1289 eff_atk: u32,
1290 eff_def: u32,
1291 mult: (u32, u32),
1292 rng: &mut dyn BattleRng,
1293) -> DamageRoll {
1294 let base = power as u64 * eff_atk as u64 / eff_def.max(1) as u64;
1295 let varied = base * (85 + (rng.byte() % 16) as u64) / 100;
1296 let crit = rng.byte().is_multiple_of(16);
1297 let after_crit = if crit { varied * 3 / 2 } else { varied };
1298 let damage = (after_crit * mult.0 as u64 / mult.1.max(1) as u64)
1299 .max(1)
1300 .min(u32::MAX as u64) as u32;
1301 DamageRoll {
1302 damage,
1303 crit,
1304 mult_num: mult.0,
1305 mult_den: mult.1,
1306 }
1307}
1308
1309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1313pub enum BattleOutcome {
1314 Win,
1316 Lose,
1318 Run,
1322}
1323
1324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1326pub enum Side {
1327 Player,
1328 Enemy,
1329}
1330
1331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1333enum After {
1334 Menu,
1336 ForcedSwitch,
1340 End(BattleOutcome),
1341}
1342
1343#[derive(Debug)]
1345enum Phase {
1346 Root,
1348 Skills,
1350 Party,
1353 Items,
1355 ForcedSwitch,
1357 Narrate {
1359 lines: VecDeque<String>,
1360 after: After,
1361 },
1362}
1363
1364enum FaintFlow {
1366 Continue,
1368 SentOut,
1371 Switch,
1374 End(BattleOutcome),
1376}
1377
1378pub struct Battle {
1384 party: Vec<Combatant>,
1386 active: usize,
1388 enemy: Combatant,
1389 enemies: VecDeque<Combatant>,
1392 trainer: bool,
1394 trainer_money: u32,
1396 exp_pool: u32,
1399 currency: String,
1402 items: Vec<BattleItem>,
1405 inventory: HashMap<String, u32>,
1407 chart: TypeChart,
1408 rng: Box<dyn BattleRng>,
1409 hooks: Option<HookState>,
1413 levels: Option<LevelsSetup>,
1416 lang: String,
1418 phase: Phase,
1419 cursor: usize,
1420 outcome: Option<BattleOutcome>,
1421 weather: Option<String>,
1426 pending_enemy: Option<Skill>,
1428 log: Vec<String>,
1430}
1431
1432impl Battle {
1433 pub fn new(
1436 player: Combatant,
1437 enemy: Combatant,
1438 chart: TypeChart,
1439 rng: Box<dyn BattleRng>,
1440 ) -> Self {
1441 Self::with_hooks(player, enemy, chart, rng, None)
1442 }
1443
1444 pub fn with_hooks(
1447 player: Combatant,
1448 enemy: Combatant,
1449 chart: TypeChart,
1450 rng: Box<dyn BattleRng>,
1451 hooks: Option<HookState>,
1452 ) -> Self {
1453 Self::full(
1454 vec![player],
1455 0,
1456 enemy,
1457 Vec::new(),
1458 HashMap::new(),
1459 chart,
1460 rng,
1461 hooks,
1462 )
1463 }
1464
1465 #[allow(clippy::too_many_arguments)]
1468 pub fn full(
1469 party: Vec<Combatant>,
1470 active: usize,
1471 enemy: Combatant,
1472 items: Vec<BattleItem>,
1473 inventory: HashMap<String, u32>,
1474 chart: TypeChart,
1475 rng: Box<dyn BattleRng>,
1476 hooks: Option<HookState>,
1477 ) -> Self {
1478 Self {
1479 party,
1480 active,
1481 enemy,
1482 enemies: VecDeque::new(),
1483 trainer: false,
1484 trainer_money: 0,
1485 exp_pool: 0,
1486 currency: "G".to_string(),
1487 items,
1488 inventory,
1489 chart,
1490 rng,
1491 hooks,
1492 levels: None,
1493 lang: "en".to_string(),
1494 weather: None,
1495 phase: Phase::Root,
1496 cursor: 0,
1497 outcome: None,
1498 pending_enemy: None,
1499 log: Vec::new(),
1500 }
1501 }
1502
1503 pub(crate) fn arm_loss(&mut self) {
1505 self.outcome = Some(BattleOutcome::Lose);
1506 }
1507
1508 pub fn set_levels(&mut self, levels: Option<LevelsSetup>) {
1510 self.levels = levels;
1511 }
1512
1513 pub fn set_lang(&mut self, lang: &str) {
1516 self.lang = lang.to_string();
1517 }
1518
1519 pub fn set_currency(&mut self, currency: &str) {
1522 self.currency = currency.to_string();
1523 }
1524
1525 pub fn set_enemy_party(&mut self, rest: Vec<Combatant>, trainer: bool, money: u32) {
1530 self.enemies = rest.into();
1531 self.trainer = trainer;
1532 self.trainer_money = money;
1533 }
1534
1535 pub fn set_weather(&mut self, weather: Option<String>) {
1540 self.weather = weather;
1541 }
1542
1543 pub fn weather(&self) -> Option<&str> {
1545 self.weather.as_deref()
1546 }
1547
1548 pub fn begin(&mut self) {
1556 if self.outcome.is_some() || self.hooks.is_none() {
1557 return;
1558 }
1559 let mut lines = VecDeque::new();
1560 if let Some(weather) = self.weather.clone() {
1561 if self.record_has_hooks(&weather) {
1562 narrate(
1563 &mut self.log,
1564 &mut lines,
1565 weather_start_line(&self.lang, &weather),
1566 );
1567 } else {
1568 log::warn!("weather '{weather}' names no rules.ron record — ignored");
1569 self.weather = None;
1570 }
1571 }
1572 for side in [Side::Player, Side::Enemy] {
1573 self.fire_switch_in(side, &mut lines);
1574 }
1575 if !lines.is_empty() {
1576 self.phase = Phase::Narrate {
1577 lines,
1578 after: After::Menu,
1579 };
1580 }
1581 }
1582
1583 fn record_has_hooks(&self, id: &str) -> bool {
1585 GenericProvider::rules_host()
1586 .is_some_and(|host| host.compiled.hooks.values().any(|h| h.source_id == id))
1587 }
1588
1589 fn fire_switch_in(&mut self, side: Side, lines: &mut VecDeque<String>) {
1595 if self.hooks.is_none() {
1596 return;
1597 }
1598 let combatant = match side {
1599 Side::Player => &self.party[self.active],
1600 Side::Enemy => &self.enemy,
1601 };
1602 let Some(ability) = combatant.ability.clone() else {
1603 return;
1604 };
1605 if !self.subscribes(&ability, Event::SwitchIn) {
1606 return;
1607 }
1608 let name = combatant.name.clone();
1609 narrate(
1610 &mut self.log,
1611 lines,
1612 ability_intro_line(&self.lang, &name, &ability),
1613 );
1614 self.sync_to_mirrors();
1615 let who = HookState::battler_ref(side);
1616 let before = snap_mirrors(self.hooks.as_ref().unwrap());
1617 self.fire(Event::SwitchIn, &[&ability], who, who, RelayVar::Unit);
1618 self.narrate_diffs(&before, lines, false);
1619 self.sync_from_mirrors();
1620 }
1621
1622 pub fn player(&self) -> &Combatant {
1626 &self.party[self.active]
1627 }
1628 pub fn party(&self) -> &[Combatant] {
1630 &self.party
1631 }
1632 pub fn active_index(&self) -> usize {
1634 self.active
1635 }
1636 pub fn enemy(&self) -> &Combatant {
1638 &self.enemy
1639 }
1640 pub fn enemies_remaining(&self) -> usize {
1642 self.enemies.len()
1643 }
1644 pub fn is_trainer(&self) -> bool {
1646 self.trainer
1647 }
1648 pub fn trainer_money(&self) -> u32 {
1650 self.trainer_money
1651 }
1652 pub fn outcome(&self) -> Option<BattleOutcome> {
1655 self.outcome
1656 }
1657 pub fn log(&self) -> &[String] {
1659 &self.log
1660 }
1661 pub fn hooks(&self) -> Option<&HookState> {
1663 self.hooks.as_ref()
1664 }
1665 pub fn inventory(&self) -> &HashMap<String, u32> {
1667 &self.inventory
1668 }
1669 pub fn party_state(&self) -> Vec<PartyMemberState> {
1673 self.party
1674 .iter()
1675 .map(|c| PartyMemberState {
1676 id: c.id.clone(),
1677 hp: c.hp,
1678 mp: c.mp,
1679 status: c.status.clone(),
1680 level: c.level,
1681 exp: c.exp,
1682 })
1683 .collect()
1684 }
1685 pub fn in_menu(&self) -> bool {
1687 !matches!(self.phase, Phase::Narrate { .. }) && self.outcome.is_none()
1688 }
1689 pub fn current_line(&self) -> Option<&str> {
1691 match &self.phase {
1692 Phase::Narrate { lines, .. } => lines.front().map(String::as_str),
1693 _ => None,
1694 }
1695 }
1696 pub fn menu_items(&self) -> Vec<String> {
1698 match &self.phase {
1699 Phase::Root => self.root_items(),
1700 Phase::Skills => self.skill_items(),
1701 Phase::Party | Phase::ForcedSwitch => self.party_items(),
1702 Phase::Items => self.item_items(),
1703 Phase::Narrate { .. } => Vec::new(),
1704 }
1705 }
1706
1707 fn root_items(&self) -> Vec<String> {
1710 let mut items = vec!["Fight".to_string(), "Party".to_string()];
1711 if !self.items.is_empty() {
1712 items.push("Item".to_string());
1713 }
1714 items.push("Run".to_string());
1715 items
1716 }
1717
1718 fn skill_items(&self) -> Vec<String> {
1720 self.player()
1721 .skills
1722 .iter()
1723 .map(|s| {
1724 let label = if s.cost > 0 {
1725 format!("{} {}MP", s.name, s.cost)
1726 } else {
1727 s.name.clone()
1728 };
1729 if s.cost > self.player().mp {
1730 format!("× {label}")
1731 } else {
1732 label
1733 }
1734 })
1735 .collect()
1736 }
1737
1738 fn party_items(&self) -> Vec<String> {
1741 self.party
1742 .iter()
1743 .enumerate()
1744 .map(|(i, c)| {
1745 let mut label = format!("{} {}/{}", c.name, c.hp, c.max_hp);
1746 if let Some(status) = &c.status {
1747 label.push_str(&format!(" ({status})"));
1748 }
1749 if i == self.active || c.hp == 0 {
1750 format!("× {label}")
1751 } else {
1752 label
1753 }
1754 })
1755 .collect()
1756 }
1757
1758 fn item_items(&self) -> Vec<String> {
1760 self.usable_items()
1761 .iter()
1762 .map(|&i| {
1763 let item = &self.items[i];
1764 let count = self.inventory.get(&item.id).copied().unwrap_or(0);
1765 format!("{} ×{count}", item.name)
1766 })
1767 .collect()
1768 }
1769
1770 fn usable_items(&self) -> Vec<usize> {
1772 self.items
1773 .iter()
1774 .enumerate()
1775 .filter(|(_, item)| self.inventory.get(&item.id).copied().unwrap_or(0) > 0)
1776 .map(|(i, _)| i)
1777 .collect()
1778 }
1779
1780 fn first_switchable(&self) -> usize {
1782 self.party
1783 .iter()
1784 .enumerate()
1785 .position(|(i, c)| i != self.active && c.hp > 0)
1786 .unwrap_or(0)
1787 }
1788
1789 fn move_cursor(&mut self, input: &InputState, n: usize) {
1791 let n = n.max(1);
1792 if input.is_just_pressed(GbButton::Up) {
1793 self.cursor = (self.cursor + n - 1) % n;
1794 } else if input.is_just_pressed(GbButton::Down) {
1795 self.cursor = (self.cursor + 1) % n;
1796 }
1797 }
1798
1799 pub fn update(&mut self, input: &InputState) {
1805 match std::mem::replace(&mut self.phase, Phase::Root) {
1806 Phase::Root => {
1807 let n = self.root_items().len();
1808 self.move_cursor(input, n);
1809 if input.is_just_pressed(GbButton::A) {
1810 if self.cursor == n - 1 {
1811 self.try_run();
1813 return;
1814 }
1815 self.phase = match self.cursor {
1816 0 => Phase::Skills,
1817 1 => {
1818 self.cursor = self.first_switchable();
1819 Phase::Party
1820 }
1821 _ => Phase::Items,
1822 };
1823 if !matches!(self.phase, Phase::Party) {
1824 self.cursor = 0;
1825 }
1826 }
1827 }
1828 Phase::Skills => {
1829 if input.is_just_pressed(GbButton::B) {
1830 self.phase = Phase::Root;
1831 self.cursor = 0;
1832 return;
1833 }
1834 let n = self.player().skills.len();
1835 self.move_cursor(input, n);
1836 if input.is_just_pressed(GbButton::A) {
1837 if self.player().skills[self.cursor].cost <= self.player().mp {
1839 let pick = self.cursor;
1840 self.execute_round(pick);
1841 return;
1842 }
1843 }
1844 self.phase = Phase::Skills;
1845 }
1846 Phase::Party => {
1847 if input.is_just_pressed(GbButton::B) {
1848 self.phase = Phase::Root;
1849 self.cursor = 0;
1850 return;
1851 }
1852 let n = self.party.len();
1853 self.move_cursor(input, n);
1854 if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1855 let pick = self.cursor;
1856 self.execute_switch_round(pick);
1857 return;
1858 }
1859 self.phase = Phase::Party;
1860 }
1861 Phase::Items => {
1862 if input.is_just_pressed(GbButton::B) {
1863 self.phase = Phase::Root;
1864 self.cursor = 0;
1865 return;
1866 }
1867 let n = self.usable_items().len();
1868 self.move_cursor(input, n);
1869 if input.is_just_pressed(GbButton::A) && n > 0 {
1870 let pick = self.cursor;
1871 self.execute_item_round(pick);
1872 return;
1873 }
1874 self.phase = Phase::Items;
1875 }
1876 Phase::ForcedSwitch => {
1877 let n = self.party.len();
1878 self.move_cursor(input, n);
1879 if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
1880 let pick = self.cursor;
1881 self.forced_switch_to(pick);
1882 return;
1883 }
1884 self.phase = Phase::ForcedSwitch;
1885 }
1886 Phase::Narrate { mut lines, after } => {
1887 if input.is_just_pressed(GbButton::A) {
1888 lines.pop_front();
1889 }
1890 if lines.is_empty() && input.is_just_pressed(GbButton::A) {
1891 match after {
1892 After::Menu => {
1893 self.phase = Phase::Root;
1894 self.cursor = 0;
1895 }
1896 After::ForcedSwitch => {
1897 self.cursor = self.first_switchable();
1898 self.phase = Phase::ForcedSwitch;
1899 }
1900 After::End(o) => self.outcome = Some(o),
1901 }
1902 } else {
1903 self.phase = Phase::Narrate { lines, after };
1904 }
1905 }
1906 }
1907 }
1908
1909 fn switch_legal(&self, idx: usize) -> bool {
1912 idx != self.active && self.party.get(idx).is_some_and(|c| c.hp > 0)
1913 }
1914
1915 fn try_run(&mut self) {
1920 let mut lines = VecDeque::new();
1921 if self.trainer {
1922 narrate(&mut self.log, &mut lines, run_blocked_line(&self.lang));
1923 self.phase = Phase::Narrate {
1924 lines,
1925 after: After::Menu,
1926 };
1927 } else {
1928 narrate(&mut self.log, &mut lines, run_safe_line(&self.lang));
1929 self.phase = Phase::Narrate {
1930 lines,
1931 after: After::End(BattleOutcome::Run),
1932 };
1933 }
1934 }
1935
1936 fn execute_round(&mut self, player_pick: usize) {
1941 let player_skill = self.player().skills[player_pick].clone();
1942 let enemy_skill = ai_pick(&self.enemy);
1943 let player_first = self.player().eff_speed() >= self.enemy.eff_speed();
1944 let order = if player_first {
1945 [Side::Player, Side::Enemy]
1946 } else {
1947 [Side::Enemy, Side::Player]
1948 };
1949
1950 let mut lines = VecDeque::new();
1951 let mut after = After::Menu;
1952 for (pos, side) in order.iter().enumerate() {
1953 if self.player().hp == 0 || self.enemy.hp == 0 {
1954 break; }
1956 let skill = match side {
1957 Side::Player => player_skill.clone(),
1958 Side::Enemy => enemy_skill.clone(),
1959 };
1960 self.perform(*side, &skill, &mut lines);
1961 match self.faint_flow(&mut lines) {
1962 FaintFlow::Continue => {}
1963 FaintFlow::SentOut => {
1964 after = After::Menu;
1966 break;
1967 }
1968 FaintFlow::Switch => {
1969 if order.get(pos + 1) == Some(&Side::Enemy) {
1972 self.pending_enemy = Some(enemy_skill);
1973 }
1974 after = After::ForcedSwitch;
1975 break;
1976 }
1977 FaintFlow::End(o) => {
1978 after = After::End(o);
1979 break;
1980 }
1981 }
1982 self.residual(*side, &mut lines);
1983 match self.faint_flow(&mut lines) {
1984 FaintFlow::Continue => {}
1985 FaintFlow::SentOut => {
1986 after = After::Menu;
1987 break;
1988 }
1989 FaintFlow::Switch => {
1990 if order.get(pos + 1) == Some(&Side::Enemy) {
1991 self.pending_enemy = Some(enemy_skill);
1992 }
1993 after = After::ForcedSwitch;
1994 break;
1995 }
1996 FaintFlow::End(o) => {
1997 after = After::End(o);
1998 break;
1999 }
2000 }
2001 }
2002 self.phase = Phase::Narrate { lines, after };
2003 }
2004
2005 fn faint_flow(&mut self, lines: &mut VecDeque<String>) -> FaintFlow {
2010 if self.enemy.hp == 0 {
2011 narrate(
2012 &mut self.log,
2013 lines,
2014 format!("{} fainted!", self.enemy.name),
2015 );
2016 self.exp_pool = self.exp_pool.saturating_add(self.enemy.exp_reward);
2019 if let Some(next) = self.enemies.pop_front() {
2020 self.send_out(next, lines);
2021 return FaintFlow::SentOut;
2022 }
2023 narrate(&mut self.log, lines, "You won the battle!".to_string());
2024 self.award_exp(lines);
2025 self.award_trainer_money(lines);
2026 FaintFlow::End(BattleOutcome::Win)
2027 } else if self.player().hp == 0 {
2028 let name = self.player().name.clone();
2029 narrate(&mut self.log, lines, format!("{name} fainted!"));
2030 if self.party.iter().any(|c| c.hp > 0) {
2031 FaintFlow::Switch
2032 } else {
2033 narrate(&mut self.log, lines, "You lost the battle…".to_string());
2034 FaintFlow::End(BattleOutcome::Lose)
2035 }
2036 } else {
2037 FaintFlow::Continue
2038 }
2039 }
2040
2041 fn send_out(&mut self, next: Combatant, lines: &mut VecDeque<String>) {
2046 self.enemy = next;
2047 if let Some(hooks) = &mut self.hooks {
2048 hooks.state.opponent_battlers[0] = hooks::mirror_of(
2049 &self.enemy,
2050 &hooks.stat_names,
2051 &hooks.status_names,
2052 hooks.has_resource,
2053 );
2054 let enemy_ref = HookState::battler_ref(Side::Enemy);
2055 hooks.effects.retain(|e| e.host != enemy_ref);
2056 }
2057 let name = self.enemy.name.clone();
2058 narrate(&mut self.log, lines, sent_out_line(&self.lang, &name));
2059 self.fire_switch_in(Side::Enemy, lines);
2061 }
2062
2063 fn award_exp(&mut self, lines: &mut VecDeque<String>) {
2071 let Some(levels) = self.levels.clone() else {
2072 return;
2073 };
2074 let reward = self.exp_pool;
2075 let lang = self.lang.clone();
2076 for i in 0..self.party.len() {
2077 if self.party[i].hp == 0 {
2078 continue; }
2080 let name = self.party[i].name.clone();
2081 narrate(&mut self.log, lines, gained_exp_line(&lang, &name, reward));
2082 let c = &mut self.party[i];
2083 c.exp = c.exp.saturating_add(reward);
2084 loop {
2085 let need = levels.exp_to_next(c.level);
2086 if c.exp < need || c.level >= levels.max_level {
2087 break;
2088 }
2089 c.exp -= need;
2090 c.level += 1;
2091 c.recompute_stats(&levels);
2092 narrate(&mut self.log, lines, level_up_line(&lang, &name, c.level));
2093 }
2094 }
2095 }
2096
2097 fn award_trainer_money(&mut self, lines: &mut VecDeque<String>) {
2101 if self.trainer_money > 0 {
2102 let line = trainer_money_line(&self.lang, self.trainer_money, &self.currency);
2103 narrate(&mut self.log, lines, line);
2104 }
2105 }
2106
2107 fn execute_switch_round(&mut self, idx: usize) {
2110 let mut lines = VecDeque::new();
2111 let old_name = self.player().name.clone();
2112 narrate(&mut self.log, &mut lines, format!("Come back, {old_name}!"));
2113 self.switch_to(idx, &mut lines);
2114 let after = self.enemy_turn(&mut lines);
2115 self.phase = Phase::Narrate { lines, after };
2116 }
2117
2118 fn execute_item_round(&mut self, pick: usize) {
2121 let usable = self.usable_items();
2122 let Some(&item_idx) = usable.get(pick) else {
2123 return;
2124 };
2125 let item = self.items[item_idx].clone();
2126 let mut lines = VecDeque::new();
2127 let before = self.player().hp;
2128 let healed = (before + item.heal).min(self.player().max_hp);
2129 self.party[self.active].hp = healed;
2130 if let Some(count) = self.inventory.get_mut(&item.id) {
2131 *count = count.saturating_sub(1);
2132 if *count == 0 {
2133 self.inventory.remove(&item.id);
2134 }
2135 }
2136 let name = self.player().name.clone();
2137 narrate(
2138 &mut self.log,
2139 &mut lines,
2140 format!("{name} used {}!", item.name),
2141 );
2142 narrate(
2143 &mut self.log,
2144 &mut lines,
2145 format!("{name} recovered {} HP!", healed - before),
2146 );
2147 let after = self.enemy_turn(&mut lines);
2148 self.phase = Phase::Narrate { lines, after };
2149 }
2150
2151 fn enemy_turn(&mut self, lines: &mut VecDeque<String>) -> After {
2154 let skill = ai_pick(&self.enemy);
2155 self.perform(Side::Enemy, &skill, lines);
2156 match self.faint_flow(lines) {
2157 FaintFlow::SentOut => After::Menu,
2158 FaintFlow::Switch => After::ForcedSwitch,
2159 FaintFlow::End(o) => After::End(o),
2160 FaintFlow::Continue => {
2161 self.residual(Side::Enemy, lines);
2162 match self.faint_flow(lines) {
2163 FaintFlow::SentOut => After::Menu,
2164 FaintFlow::Switch => After::ForcedSwitch,
2165 FaintFlow::End(o) => After::End(o),
2166 FaintFlow::Continue => After::Menu,
2167 }
2168 }
2169 }
2170 }
2171
2172 fn forced_switch_to(&mut self, idx: usize) {
2175 let mut lines = VecDeque::new();
2176 self.switch_to(idx, &mut lines);
2177 let mut after = After::Menu;
2178 if let Some(skill) = self.pending_enemy.take() {
2179 self.perform(Side::Enemy, &skill, &mut lines);
2180 match self.faint_flow(&mut lines) {
2181 FaintFlow::SentOut => after = After::Menu,
2182 FaintFlow::Switch => after = After::ForcedSwitch,
2183 FaintFlow::End(o) => after = After::End(o),
2184 FaintFlow::Continue => {
2185 self.residual(Side::Enemy, &mut lines);
2186 after = match self.faint_flow(&mut lines) {
2187 FaintFlow::SentOut => After::Menu,
2188 FaintFlow::Switch => After::ForcedSwitch,
2189 FaintFlow::End(o) => After::End(o),
2190 FaintFlow::Continue => After::Menu,
2191 };
2192 }
2193 }
2194 }
2195 self.phase = Phase::Narrate { lines, after };
2196 }
2197
2198 fn switch_to(&mut self, idx: usize, lines: &mut VecDeque<String>) {
2202 self.active = idx;
2203 self.party[idx].stages = Stages::default();
2204 if let Some(hooks) = &mut self.hooks {
2205 hooks.state.player_battlers[0] = hooks::mirror_of(
2206 &self.party[idx],
2207 &hooks.stat_names,
2208 &hooks.status_names,
2209 hooks.has_resource,
2210 );
2211 let player_ref = HookState::battler_ref(Side::Player);
2212 hooks.effects.retain(|e| e.host != player_ref);
2213 }
2214 let name = self.party[idx].name.clone();
2215 narrate(&mut self.log, lines, format!("Go, {name}!"));
2216 self.fire_switch_in(Side::Player, lines);
2218 }
2219
2220 fn perform(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2225 if skill.ron && self.hooks.is_some() {
2226 self.perform_ron(side, skill, lines);
2227 return;
2228 }
2229 let (attacker, defender) = match side {
2230 Side::Player => (&mut self.party[self.active], &mut self.enemy),
2231 Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2232 };
2233
2234 if skill.cost > attacker.mp {
2236 narrate(
2237 &mut self.log,
2238 lines,
2239 format!("{} tried to use {}!", attacker.name, skill.name),
2240 );
2241 narrate(
2242 &mut self.log,
2243 lines,
2244 "But there wasn't enough MP!".to_string(),
2245 );
2246 return;
2247 }
2248 attacker.mp -= skill.cost;
2249 narrate(
2250 &mut self.log,
2251 lines,
2252 format!("{} used {}!", attacker.name, skill.name),
2253 );
2254
2255 if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2256 narrate(&mut self.log, lines, "But it missed!".to_string());
2257 return;
2258 }
2259
2260 match skill.category {
2261 SkillCategory::Damage => {
2262 let mult = self
2263 .chart
2264 .mult(skill.element.as_deref(), defender.element.as_deref());
2265 let roll = damage_roll(
2266 skill.power,
2267 attacker.eff_attack(),
2268 defender.eff_defense(),
2269 mult,
2270 self.rng.as_mut(),
2271 );
2272 defender.hp = defender.hp.saturating_sub(roll.damage);
2273 if roll.crit {
2274 narrate(&mut self.log, lines, "Critical hit!".to_string());
2275 }
2276 if roll.mult_num > roll.mult_den {
2277 narrate(&mut self.log, lines, "It's super effective!".to_string());
2278 } else if roll.mult_num < roll.mult_den {
2279 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2280 }
2281 narrate(&mut self.log, lines, format!("{} damage!", roll.damage));
2282 }
2283 SkillCategory::Heal => {
2284 let before = attacker.hp;
2285 attacker.hp = (attacker.hp + skill.power).min(attacker.max_hp);
2286 narrate(
2287 &mut self.log,
2288 lines,
2289 format!("{} recovered {} HP!", attacker.name, attacker.hp - before),
2290 );
2291 }
2292 SkillCategory::Buff => {
2293 attacker.stages.bump(&skill.stat, 1);
2294 narrate(
2295 &mut self.log,
2296 lines,
2297 format!("{}'s {} rose!", attacker.name, stat_label(&skill.stat)),
2298 );
2299 }
2300 SkillCategory::Debuff => {
2301 defender.stages.bump(&skill.stat, -1);
2302 narrate(
2303 &mut self.log,
2304 lines,
2305 format!("{}'s {} fell!", defender.name, stat_label(&skill.stat)),
2306 );
2307 }
2308 }
2309 }
2310
2311 fn fire(
2319 &mut self,
2320 event: Event,
2321 source_ids: &[&str],
2322 target: BattlerRef,
2323 source: BattlerRef,
2324 relay: RelayVar,
2325 ) -> RelayVar {
2326 let hooks = self.hooks.as_mut().expect("fire requires hook state");
2327 let host = GenericProvider::rules_host().expect("rules host installed");
2328 let provider = GenericProvider;
2329 let mut adapter = hooks::RngAdapter(self.rng.as_mut());
2330 let mut ctx = BattleCtx {
2331 state: &mut hooks.state,
2332 effects: &mut hooks.effects,
2333 mv: &mut hooks.mv,
2334 rng: &mut adapter,
2335 };
2336 let mut hs = Vec::new();
2337 for eff in &hooks.registry {
2338 let matches = host
2339 .compiled
2340 .hook(eff.id)
2341 .map(|h| source_ids.contains(&h.source_id.as_str()))
2342 .unwrap_or(false);
2343 if matches {
2344 collect_handlers(&ctx, &provider, Some(eff), event, target, source, &mut hs);
2345 }
2346 }
2347 run_event(&mut ctx, hs, relay, false)
2348 }
2349
2350 fn sync_to_mirrors(&mut self) {
2352 let Some(hooks) = &mut self.hooks else { return };
2353 let (stat_names, status_names, has_resource) =
2354 (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2355 hooks::sync_to_mirror(
2356 &self.party[self.active],
2357 &mut hooks.state.player_battlers[0],
2358 stat_names,
2359 status_names,
2360 has_resource,
2361 );
2362 hooks::sync_to_mirror(
2363 &self.enemy,
2364 &mut hooks.state.opponent_battlers[0],
2365 stat_names,
2366 status_names,
2367 has_resource,
2368 );
2369 }
2370
2371 fn sync_from_mirrors(&mut self) {
2373 let Some(hooks) = &mut self.hooks else { return };
2374 let (stat_names, status_names, has_resource) =
2375 (&hooks.stat_names, &hooks.status_names, hooks.has_resource);
2376 hooks::sync_from_mirror(
2377 &hooks.state.player_battlers[0],
2378 &mut self.party[self.active],
2379 stat_names,
2380 status_names,
2381 has_resource,
2382 );
2383 hooks::sync_from_mirror(
2384 &hooks.state.opponent_battlers[0],
2385 &mut self.enemy,
2386 stat_names,
2387 status_names,
2388 has_resource,
2389 );
2390 }
2391
2392 fn narrate_diffs(
2396 &mut self,
2397 before: &[MirrorSnap; 2],
2398 lines: &mut VecDeque<String>,
2399 residual: bool,
2400 ) {
2401 let names = [self.player().name.clone(), self.enemy.name.clone()];
2402 let Some(hooks) = &self.hooks else { return };
2403 let produced = diff_lines(hooks, before, [&names[0], &names[1]], residual);
2404 for line in produced {
2405 narrate(&mut self.log, lines, line);
2406 }
2407 }
2408
2409 fn perform_ron(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
2415 let (attacker, defender) = match side {
2416 Side::Player => (&mut self.party[self.active], &mut self.enemy),
2417 Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
2418 };
2419
2420 if skill.cost > attacker.mp {
2423 narrate(
2424 &mut self.log,
2425 lines,
2426 format!("{} tried to use {}!", attacker.name, skill.name),
2427 );
2428 narrate(
2429 &mut self.log,
2430 lines,
2431 "But there wasn't enough MP!".to_string(),
2432 );
2433 return;
2434 }
2435 attacker.mp -= skill.cost;
2436 narrate(
2437 &mut self.log,
2438 lines,
2439 format!("{} used {}!", attacker.name, skill.name),
2440 );
2441
2442 if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
2443 narrate(&mut self.log, lines, "But it missed!".to_string());
2444 return;
2445 }
2446
2447 let eff_atk = attacker.eff_attack();
2448 let eff_def = defender.eff_defense();
2449 let def_element = defender.element.clone();
2450 let ability = attacker.ability.clone();
2454 let held_item = attacker.held_item.clone();
2455 let mut ids: Vec<&str> = vec![skill.id.as_str()];
2456 if let Some(a) = &ability {
2457 ids.push(a);
2458 }
2459 if let Some(i) = &held_item {
2460 ids.push(i);
2461 }
2462 let source = HookState::battler_ref(side);
2463 let target = HookState::battler_ref(match side {
2464 Side::Player => Side::Enemy,
2465 Side::Enemy => Side::Player,
2466 });
2467
2468 self.sync_to_mirrors();
2469
2470 if self.subscribes_any(&ids, Event::BeforeMove) {
2474 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2475 let out = self.fire(
2476 Event::BeforeMove,
2477 &ids,
2478 target,
2479 source,
2480 RelayVar::Bool(true),
2481 );
2482 self.narrate_diffs(&before, lines, false);
2483 match out {
2484 RelayVar::Bool(false) => {
2485 narrate(&mut self.log, lines, "But it failed!".to_string());
2486 self.sync_from_mirrors();
2487 return;
2488 }
2489 RelayVar::Unit => {
2490 self.sync_from_mirrors();
2491 return;
2492 }
2493 _ => {}
2494 }
2495 }
2496
2497 let has_effectiveness_hooks = self.subscribes_any(&ids, Event::Effectiveness);
2501
2502 if skill.power > 0 {
2503 let mult = if has_effectiveness_hooks {
2504 (1, 1)
2505 } else {
2506 self.chart
2507 .mult(skill.element.as_deref(), def_element.as_deref())
2508 };
2509 let roll = damage_roll(skill.power, eff_atk, eff_def, mult, self.rng.as_mut());
2510 self.hooks.as_mut().unwrap().mv.damage = roll.damage.min(u32::from(u16::MAX)) as u16;
2511 if roll.crit {
2512 narrate(&mut self.log, lines, "Critical hit!".to_string());
2513 }
2514 if !has_effectiveness_hooks {
2515 if roll.mult_num > roll.mult_den {
2516 narrate(&mut self.log, lines, "It's super effective!".to_string());
2517 } else if roll.mult_num < roll.mult_den {
2518 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2519 }
2520 }
2521
2522 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2524 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2525 let out = self.fire(
2526 Event::ModifyDamage,
2527 &ids,
2528 target,
2529 source,
2530 RelayVar::Damage(in_damage),
2531 );
2532 self.narrate_diffs(&before, lines, false);
2533 match out {
2534 RelayVar::Damage(d) => self.hooks.as_mut().unwrap().mv.damage = d,
2535 RelayVar::Bool(false) => {
2536 narrate(&mut self.log, lines, "But it failed!".to_string());
2537 self.sync_from_mirrors();
2538 return;
2539 }
2540 RelayVar::Unit => {
2541 self.sync_from_mirrors();
2542 return;
2543 }
2544 _ => {}
2545 }
2546
2547 if has_effectiveness_hooks {
2550 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2551 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2552 let out = self.fire(
2553 Event::Effectiveness,
2554 &ids,
2555 target,
2556 source,
2557 RelayVar::Damage(in_damage),
2558 );
2559 self.narrate_diffs(&before, lines, false);
2560 match out {
2561 RelayVar::Damage(d) => {
2562 self.hooks.as_mut().unwrap().mv.damage = d;
2563 if d > in_damage {
2564 narrate(&mut self.log, lines, "It's super effective!".to_string());
2565 } else if d < in_damage {
2566 narrate(&mut self.log, lines, "It's not very effective…".to_string());
2567 }
2568 }
2569 RelayVar::Bool(false) => {
2570 narrate(&mut self.log, lines, "But it failed!".to_string());
2571 self.sync_from_mirrors();
2572 return;
2573 }
2574 RelayVar::Unit => {
2575 self.sync_from_mirrors();
2576 return;
2577 }
2578 _ => {}
2579 }
2580 }
2581
2582 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2584 let in_damage = self.hooks.as_ref().unwrap().mv.damage;
2585 let out = self.fire(
2586 Event::Damage,
2587 &ids,
2588 target,
2589 source,
2590 RelayVar::Damage(in_damage),
2591 );
2592 self.narrate_diffs(&before, lines, false);
2593 let final_damage = match out {
2594 RelayVar::Damage(d) => d,
2595 RelayVar::Bool(false) => {
2596 narrate(&mut self.log, lines, "But it failed!".to_string());
2597 self.sync_from_mirrors();
2598 return;
2599 }
2600 RelayVar::Unit => {
2601 self.sync_from_mirrors();
2602 return;
2603 }
2604 _ => in_damage,
2605 };
2606 {
2607 let hooks = self.hooks.as_mut().unwrap();
2608 let b = if target.side == 0 {
2609 &mut hooks.state.player_battlers[target.slot as usize]
2610 } else {
2611 &mut hooks.state.opponent_battlers[target.slot as usize]
2612 };
2613 b.take_damage(final_damage);
2614 hooks.mv.last_damage = final_damage;
2615 }
2616 narrate(&mut self.log, lines, format!("{final_damage} damage!"));
2617 self.sync_from_mirrors();
2619 }
2620
2621 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2625 let last_damage = self.hooks.as_ref().unwrap().mv.last_damage;
2626 self.fire(
2627 Event::DamagingHit,
2628 &ids,
2629 target,
2630 source,
2631 RelayVar::Damage(last_damage),
2632 );
2633 self.narrate_diffs(&before, lines, false);
2634
2635 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2637 self.fire(Event::AfterMove, &ids, target, source, RelayVar::Unit);
2638 self.narrate_diffs(&before, lines, false);
2639
2640 self.sync_from_mirrors();
2641 }
2642
2643 fn subscribes(&self, skill_id: &str, event: Event) -> bool {
2645 self.hooks
2646 .as_ref()
2647 .is_some_and(|h| h.subscribes(skill_id, event))
2648 }
2649
2650 fn subscribes_any(&self, ids: &[&str], event: Event) -> bool {
2653 ids.iter().any(|id| self.subscribes(id, event))
2654 }
2655
2656 fn residual(&mut self, side: Side, lines: &mut VecDeque<String>) {
2662 if self.hooks.is_none() {
2663 return;
2664 }
2665 self.sync_to_mirrors();
2668 let who = HookState::battler_ref(side);
2669
2670 let status_id = {
2672 let hooks = self.hooks.as_ref().unwrap();
2673 hooks
2674 .battler(side)
2675 .status
2676 .clone()
2677 .and_then(|hooks::StatusId(idx)| hooks.status_names.get(idx as usize).cloned())
2678 };
2679 if let Some(source_id) = status_id {
2680 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2681 self.fire(Event::Residual, &[&source_id], who, who, RelayVar::Unit);
2682 self.narrate_diffs(&before, lines, true);
2683 }
2684
2685 let held_item = match side {
2687 Side::Player => self.party[self.active].held_item.clone(),
2688 Side::Enemy => self.enemy.held_item.clone(),
2689 };
2690 if let Some(item_id) = held_item {
2691 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2692 self.fire(Event::Residual, &[&item_id], who, who, RelayVar::Unit);
2693 self.narrate_diffs(&before, lines, false);
2694 }
2695
2696 if let Some(weather) = self.weather.clone() {
2698 let before = snap_mirrors(self.hooks.as_ref().unwrap());
2699 self.fire(Event::FieldResidual, &[&weather], who, who, RelayVar::Unit);
2700 self.narrate_diffs(&before, lines, false);
2701 }
2702
2703 self.sync_from_mirrors();
2704 }
2705
2706 pub fn draw(&self, fb: &mut FrameBuffer) {
2713 fb.fill_rect(
2715 0,
2716 0,
2717 SCREEN_W as u32,
2718 SCREEN_H as u32,
2719 Rgba::rgb(0x18, 0x18, 0x28),
2720 );
2721 fb.fill_rect(
2722 0,
2723 130,
2724 SCREEN_W as u32,
2725 SCREEN_H as u32 - 130,
2726 Rgba::rgb(0x20, 0x2A, 0x20),
2727 );
2728
2729 draw_blob(fb, 228, 28, 56, blob_color(&self.enemy.id));
2731 draw_blob(fb, 52, 108, 64, blob_color(&self.player().id));
2732
2733 draw_panel(fb, 8, 8, 184, 46);
2735 text(fb, &self.enemy.name, 16, 14, Rgba::rgb(0xF0, 0xF0, 0xF0));
2736 if let Some(element) = &self.enemy.element {
2737 text(fb, element, 16, 26, Rgba::rgb(0x90, 0xA8, 0xC8));
2738 }
2739 draw_bar(fb, 80, 28, 104, 6, self.enemy.hp, self.enemy.max_hp);
2740
2741 draw_panel(fb, 132, 136, 180, 46);
2743 text(
2744 fb,
2745 &self.player().name,
2746 140,
2747 142,
2748 Rgba::rgb(0xF0, 0xF0, 0xF0),
2749 );
2750 draw_bar(fb, 140, 156, 104, 6, self.player().hp, self.player().max_hp);
2751 text(
2752 fb,
2753 &format!("{}/{}", self.player().hp, self.player().max_hp),
2754 250,
2755 154,
2756 Rgba::rgb(0xC8, 0xC8, 0xC8),
2757 );
2758 text(
2759 fb,
2760 &format!("MP {}/{}", self.player().mp, self.player().max_mp),
2761 140,
2762 168,
2763 Rgba::rgb(0x90, 0xA8, 0xC8),
2764 );
2765
2766 match &self.phase {
2768 Phase::Root => {
2769 draw_textbox(fb, "What will you do?");
2770 self.draw_menu(fb);
2771 }
2772 Phase::Skills => {
2773 draw_textbox(fb, "Choose a skill!");
2774 self.draw_menu(fb);
2775 }
2776 Phase::Party => {
2777 draw_textbox(fb, "Switch to whom?");
2778 self.draw_menu(fb);
2779 }
2780 Phase::Items => {
2781 draw_textbox(fb, "Use which item?");
2782 self.draw_menu(fb);
2783 }
2784 Phase::ForcedSwitch => {
2785 draw_textbox(fb, "Choose your next fighter!");
2786 self.draw_menu(fb);
2787 }
2788 Phase::Narrate { lines, .. } => {
2789 if let Some(line) = lines.front() {
2790 draw_textbox(fb, line);
2791 }
2792 }
2793 }
2794 }
2795
2796 fn draw_menu(&self, fb: &mut FrameBuffer) {
2799 let items = self.menu_items();
2800 let n = items.len() as u32;
2801 if n == 0 {
2802 return;
2803 }
2804 let max_len = items.iter().map(|o| o.chars().count()).max().unwrap_or(1) as u32;
2805 let w = (max_len + 4).clamp(10, 24);
2807 let h = n + 2;
2808 let tx = (40 - w) as i32;
2809 let ty = DIALOG_AREA.ty as i32 - h as i32;
2810 let config = MenuConfig::new(
2811 TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
2812 None,
2813 TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
2814 Default::default(),
2815 );
2816 let state = FlexMenuState {
2817 cursor: self.cursor,
2818 scroll_offset: 0,
2819 };
2820 let mut painter = FrameBufferPainter::new(fb);
2821 let mut ui = Ui::new(&mut painter);
2822 draw_flex_menu(&items, &[config], &state, items.len(), &mut ui);
2823 }
2824}
2825
2826fn ai_pick(combatant: &Combatant) -> Skill {
2829 combatant
2830 .skills
2831 .iter()
2832 .filter(|s| s.cost <= combatant.mp)
2833 .max_by_key(|s| s.power)
2834 .cloned()
2835 .unwrap_or_else(basic_attack)
2836}
2837
2838fn narrate(log: &mut Vec<String>, lines: &mut VecDeque<String>, line: String) {
2840 log.push(line.clone());
2841 lines.push_back(line);
2842}
2843
2844fn gained_exp_line(lang: &str, name: &str, n: u32) -> String {
2846 if lang == "zh" {
2847 format!("{name} 获得了 {n} 点经验!")
2848 } else {
2849 format!("{name} gained {n} EXP!")
2850 }
2851}
2852
2853fn level_up_line(lang: &str, name: &str, level: u8) -> String {
2855 if lang == "zh" {
2856 format!("{name} 升到了 {level} 级!")
2857 } else {
2858 format!("{name} grew to level {level}!")
2859 }
2860}
2861
2862fn sent_out_line(lang: &str, name: &str) -> String {
2864 if lang == "zh" {
2865 format!("对方派出了 {name}!")
2866 } else {
2867 format!("Foe sent out {name}!")
2868 }
2869}
2870
2871fn run_safe_line(lang: &str) -> String {
2873 if lang == "zh" {
2874 "顺利逃走了!".to_string()
2875 } else {
2876 "Got away safely!".to_string()
2877 }
2878}
2879
2880fn run_blocked_line(lang: &str) -> String {
2882 if lang == "zh" {
2883 "无法从训练家的战斗中逃走!".to_string()
2884 } else {
2885 "Can't escape from a trainer battle!".to_string()
2886 }
2887}
2888
2889fn trainer_money_line(lang: &str, money: u32, currency: &str) -> String {
2891 if lang == "zh" {
2892 format!("赢得了 {money} {currency}!")
2893 } else {
2894 format!("Got {money} {currency} for winning!")
2895 }
2896}
2897
2898fn ability_intro_line(lang: &str, name: &str, ability: &str) -> String {
2902 let label = prettify_id(ability);
2903 if lang == "zh" {
2904 format!("{name} 的 {label}!")
2905 } else {
2906 format!("{name}'s {label}!")
2907 }
2908}
2909
2910fn weather_start_line(lang: &str, weather: &str) -> String {
2913 if lang == "zh" {
2914 format!("{weather} 开始了!")
2915 } else {
2916 format!("A {weather} rages!")
2917 }
2918}
2919
2920fn prettify_id(id: &str) -> String {
2922 id.split(['-', '_'])
2923 .filter(|w| !w.is_empty())
2924 .map(|w| {
2925 let mut chars = w.chars();
2926 match chars.next() {
2927 Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
2928 None => String::new(),
2929 }
2930 })
2931 .collect::<Vec<_>>()
2932 .join(" ")
2933}
2934
2935#[derive(Clone)]
2940struct MirrorSnap {
2941 hp: u16,
2942 status: Option<hooks::StatusId>,
2943 stages: Vec<i8>,
2944}
2945
2946fn snap_mirrors(hooks: &HookState) -> [MirrorSnap; 2] {
2948 [Side::Player, Side::Enemy].map(|side| {
2949 let b = hooks.battler(side);
2950 MirrorSnap {
2951 hp: b.hp,
2952 status: b.status.clone(),
2953 stages: (0..hooks.stat_names.len())
2954 .map(|i| {
2955 b.stat_stages
2956 .get(hooks::StatId(i as u16))
2957 .copied()
2958 .unwrap_or(0)
2959 })
2960 .collect(),
2961 }
2962 })
2963}
2964
2965fn diff_lines(
2969 hooks: &HookState,
2970 before: &[MirrorSnap; 2],
2971 names: [&str; 2],
2972 residual: bool,
2973) -> Vec<String> {
2974 let after = snap_mirrors(hooks);
2975 let mut out = Vec::new();
2976 for i in 0..2 {
2977 let (b, a, name) = (&before[i], &after[i], names[i]);
2978 let status_name = |id: &hooks::StatusId| {
2979 hooks
2980 .status_names
2981 .get(id.0 as usize)
2982 .map(String::as_str)
2983 .unwrap_or("?")
2984 };
2985 match (&b.status, &a.status) {
2986 (None, Some(id)) => out.push(format!("{name} was afflicted with {}!", status_name(id))),
2987 (Some(id), None) => out.push(format!("{name} is no longer {}!", status_name(id))),
2988 _ => {}
2989 }
2990 for (s, stat) in hooks.stat_names.iter().enumerate() {
2991 let (bv, av) = (b.stages[s], a.stages[s]);
2992 if av > bv {
2993 out.push(format!(
2994 "{name}'s {} rose!",
2995 stat_label(&normalize_stat_key(stat))
2996 ));
2997 } else if av < bv {
2998 out.push(format!(
2999 "{name}'s {} fell!",
3000 stat_label(&normalize_stat_key(stat))
3001 ));
3002 }
3003 }
3004 if a.hp < b.hp {
3005 match (&a.status, &b.status, residual) {
3006 (Some(id), _, true) | (_, Some(id), true) => {
3007 out.push(format!("{name} is hurt by {}!", status_name(id)));
3008 }
3009 _ => out.push(format!("{name} lost {} HP!", b.hp - a.hp)),
3010 }
3011 } else if a.hp > b.hp {
3012 out.push(format!("{name} recovered {} HP!", a.hp - b.hp));
3013 }
3014 }
3015 out
3016}
3017
3018pub(crate) fn text(fb: &mut FrameBuffer, s: &str, x: u32, y: u32, color: Rgba) {
3022 embedded_font::draw_text(s, x, y, color, fb);
3023}
3024
3025pub(crate) fn draw_panel(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32) {
3027 fb.fill_rect(x, y, w, h, Rgba::rgb(0xC8, 0xC8, 0xD0));
3028 fb.fill_rect(x + 1, y + 1, w - 2, h - 2, Rgba::rgb(0x28, 0x28, 0x38));
3029}
3030
3031fn draw_bar(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32, hp: u32, max_hp: u32) {
3033 let frac = if max_hp == 0 {
3034 0.0
3035 } else {
3036 hp as f32 / max_hp as f32
3037 };
3038 fb.fill_rect(x, y, w, h, Rgba::rgb(0x10, 0x10, 0x18));
3039 let fill = if frac < 0.25 {
3040 Rgba::rgb(0xD0, 0x40, 0x38)
3041 } else {
3042 Rgba::rgb(0x40, 0xC0, 0x58)
3043 };
3044 let inner = ((w - 2) as f32 * frac.clamp(0.0, 1.0)) as u32;
3045 if inner > 0 {
3046 fb.fill_rect(x + 1, y + 1, inner, h - 2, fill);
3047 }
3048}
3049
3050fn blob_color(id: &str) -> Rgba {
3052 let hash = id.bytes().fold(0x811C_9DC5_u32, |h, b| {
3053 h.wrapping_mul(16_777_619) ^ b as u32
3054 });
3055 let hue = (hash >> 8) as u8;
3056 Rgba::rgb(
3057 0x60 + hue % 0x70,
3058 0x60 + (hue / 3) % 0x70,
3059 0x60 + (hue / 7) % 0x70,
3060 )
3061}
3062
3063fn draw_blob(fb: &mut FrameBuffer, x: i32, y: i32, size: i32, color: Rgba) {
3065 let r = (size / 7).max(2);
3066 for dy in 0..size {
3067 for dx in 0..size {
3068 let (px, py) = (x + dx, y + dy);
3069 if px < 0 || py < 0 || px >= SCREEN_W || py >= SCREEN_H {
3070 continue;
3071 }
3072 if (dx < r || dx >= size - r) && (dy < r || dy >= size - r) {
3073 continue;
3074 }
3075 fb.set_pixel(px as u32, py as u32, color);
3076 }
3077 }
3078}
3079
3080#[cfg(test)]
3081mod tests;