use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use anyhow::{Context, Result};
use dotzuki_engine::battle::stack::{collect_handlers, run_event, BattleCtx, Event, RelayVar};
use dotzuki_engine::battle::{BattleState, BattlerRef};
use dotzuki_engine::menu::MenuConfig;
use dotzuki_engine::render::{FrameBuffer, Rgba, TileRect, Ui};
use dotzuki_renderer::embedded_font;
use dotzuki_renderer::input::{GbButton, InputState};
use dotzuki_ui::widgets::flex_menu::{draw_flex_menu, FlexMenuState};
use dotzuki_ui::FrameBufferPainter;
use crate::game::{draw_textbox, DIALOG_AREA, SCREEN_H, SCREEN_W};
use crate::manifest::{BattleLevels, BattleStats, DEFAULT_RULES_FILE};
use crate::project::LoadedProject;
use crate::vfs::{join_path, ProjectFiles};
use hooks::{GenericProvider, HookState};
use dotzuki_rules::RulesProvider;
pub mod hooks;
pub const BASIC_ATTACK_NAME: &str = "Attack";
pub const BASIC_ATTACK_POWER: u32 = 40;
pub const BASIC_ATTACK_ACCURACY: u32 = 100;
const MAX_STAGE: i8 = 4;
pub trait BattleRng {
fn byte(&mut self) -> u8;
}
pub struct XorshiftRng(u64);
impl XorshiftRng {
pub fn seed(seed: u64) -> Self {
Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
}
}
impl BattleRng for XorshiftRng {
fn byte(&mut self) -> u8 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
(x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as u8
}
}
pub struct ScriptedRng {
bytes: Vec<u8>,
idx: usize,
}
impl ScriptedRng {
pub fn new(bytes: Vec<u8>) -> Self {
Self { bytes, idx: 0 }
}
}
impl BattleRng for ScriptedRng {
fn byte(&mut self) -> u8 {
if self.bytes.is_empty() {
return 0;
}
let b = self.bytes[self.idx];
self.idx = (self.idx + 1) % self.bytes.len();
b
}
}
#[derive(Debug, Default, Clone)]
pub struct TypeChart {
edges: HashMap<(String, String), (u32, u32)>,
}
impl TypeChart {
pub fn from_ruleset(ruleset: &dotzuki_rules::Ruleset) -> Self {
let mut edges = HashMap::new();
for entry in &ruleset.type_chart {
edges.insert(
(entry.atk.to_lowercase(), entry.def.to_lowercase()),
(entry.mult.num, entry.mult.den.max(1)),
);
}
Self { edges }
}
pub fn mult(&self, atk: Option<&str>, def: Option<&str>) -> (u32, u32) {
let (Some(atk), Some(def)) = (atk, def) else {
return (1, 1);
};
self.edges
.get(&(atk.to_lowercase(), def.to_lowercase()))
.copied()
.unwrap_or((1, 1))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillCategory {
Damage,
Heal,
Buff,
Debuff,
}
#[derive(Debug, Clone)]
pub struct Skill {
pub id: String,
pub name: String,
pub power: u32,
pub accuracy: u32,
pub element: Option<String>,
pub category: SkillCategory,
pub stat: String,
pub cost: u32,
pub ron: bool,
}
pub fn basic_attack() -> Skill {
Skill {
id: "basic".to_string(),
name: BASIC_ATTACK_NAME.to_string(),
power: BASIC_ATTACK_POWER,
accuracy: BASIC_ATTACK_ACCURACY,
element: None,
category: SkillCategory::Damage,
stat: "attack".to_string(),
cost: 0,
ron: false,
}
}
fn skill_from_record(id: &str, record: &serde_json::Value, setup: &BattleSetup) -> Skill {
let category = match get_str(record, &setup.category_field)
.unwrap_or("")
.to_lowercase()
.as_str()
{
"heal" => SkillCategory::Heal,
"buff" => SkillCategory::Buff,
"debuff" => SkillCategory::Debuff,
_ => SkillCategory::Damage,
};
let stat = normalize_stat_key(get_str(record, "stat").unwrap_or("attack"));
let mut skill = Skill {
id: id.to_string(),
name: get_str(record, "name").unwrap_or(id).to_string(),
power: get_num(record, "power").unwrap_or(0),
accuracy: get_num(record, "accuracy").unwrap_or(100),
element: get_str(record, "element").map(str::to_string),
category,
stat,
cost: get_num(record, &setup.cost_field).unwrap_or(0),
ron: false,
};
if let Some(ron_move) = setup.ron_move(id) {
skill.ron = true;
if let Some(power) = ron_move.power {
skill.power = power;
}
if let Some(accuracy) = ron_move.accuracy {
skill.accuracy = accuracy;
}
if let Some(mtype) = &ron_move.mtype {
skill.element = Some(mtype.clone());
}
if let Some(cost) = ron_move.cost {
skill.cost = cost;
}
}
skill
}
fn normalize_stat_key(stat: &str) -> String {
match stat.to_lowercase().as_str() {
"hp" => "hp",
"defense" | "def" => "defense",
"speed" | "spd" => "speed",
_ => "attack",
}
.to_string()
}
fn stat_label(stat: &str) -> &str {
match stat {
"hp" => "HP",
"defense" => "Defense",
"speed" => "Speed",
_ => "Attack",
}
}
#[derive(Debug, Clone, Default)]
pub struct Stages {
pub hp: i8,
pub attack: i8,
pub defense: i8,
pub speed: i8,
}
impl Stages {
fn bump(&mut self, stat: &str, delta: i8) {
let stage = match stat {
"hp" => &mut self.hp,
"defense" => &mut self.defense,
"speed" => &mut self.speed,
_ => &mut self.attack,
};
*stage = (*stage + delta).clamp(-MAX_STAGE, MAX_STAGE);
}
pub fn get(&self, stat: &str) -> i8 {
match normalize_stat_key(stat).as_str() {
"hp" => self.hp,
"defense" => self.defense,
"speed" => self.speed,
_ => self.attack,
}
}
pub fn set(&mut self, stat: &str, value: i8) {
let value = value.clamp(-MAX_STAGE, MAX_STAGE);
match normalize_stat_key(stat).as_str() {
"hp" => self.hp = value,
"defense" => self.defense = value,
"speed" => self.speed = value,
_ => self.attack = value,
}
}
}
pub fn stage_multiplier(raw: u32, stage: i8) -> u32 {
match stage.clamp(-MAX_STAGE, MAX_STAGE) {
s if s >= 0 => raw * (4 + s as u32) / 4,
s => raw * 4 / (4 - s) as u32,
}
}
#[derive(Debug, Clone, Copy)]
pub struct BaseStats {
pub max_hp: u32,
pub max_mp: u32,
pub attack: u32,
pub defense: u32,
pub speed: u32,
}
pub fn growth_stat(raw: u32, level: u8, growth: f64) -> u32 {
let mult = 1.0 + growth * f64::from(level.saturating_sub(1));
((raw as f64 * mult) + 1e-9).floor().max(0.0) as u32
}
pub fn exp_to_next(base: u32, exponent: u32, level: u8) -> u32 {
u64::from(base)
.saturating_mul(u64::from(level).saturating_pow(exponent))
.min(u64::from(u32::MAX)) as u32
}
#[derive(Debug, Clone)]
pub struct Combatant {
pub id: String,
pub name: String,
pub element: Option<String>,
pub hp: u32,
pub max_hp: u32,
pub attack: u32,
pub defense: u32,
pub speed: u32,
pub level: u8,
pub base: BaseStats,
pub exp: u32,
pub exp_reward: u32,
pub stages: Stages,
pub mp: u32,
pub max_mp: u32,
pub skills: Vec<Skill>,
pub status: Option<String>,
pub ability: Option<String>,
pub held_item: Option<String>,
}
impl Combatant {
pub fn eff_attack(&self) -> u32 {
stage_multiplier(self.attack, self.stages.attack)
}
pub fn eff_defense(&self) -> u32 {
stage_multiplier(self.defense, self.stages.defense)
}
pub fn eff_speed(&self) -> u32 {
stage_multiplier(self.speed, self.stages.speed)
}
pub fn recompute_stats(&mut self, levels: &LevelsSetup) {
let grown = |raw: u32| growth_stat(raw, self.level, levels.growth);
let (old_hp, old_mp) = (self.max_hp, self.max_mp);
self.max_hp = grown(self.base.max_hp);
self.max_mp = grown(self.base.max_mp);
self.attack = grown(self.base.attack);
self.defense = grown(self.base.defense);
self.speed = grown(self.base.speed);
self.hp = (self.hp + self.max_hp.saturating_sub(old_hp)).min(self.max_hp);
self.mp = (self.mp + self.max_mp.saturating_sub(old_mp)).min(self.max_mp);
}
}
#[derive(Debug, Clone)]
pub struct PartyMemberState {
pub id: String,
pub hp: u32,
pub mp: u32,
pub status: Option<String>,
pub level: u8,
pub exp: u32,
}
#[derive(Debug, Clone)]
pub struct BattleItem {
pub id: String,
pub name: String,
pub heal: u32,
}
pub struct BattleSetup {
files: Arc<dyn ProjectFiles>,
party_dir: String,
enemies_dir: String,
encounters_dir: Option<String>,
skills_dir: Option<String>,
skills_field: String,
category_field: String,
cost_field: String,
stats: BattleStats,
resource: Option<String>,
chart: TypeChart,
ron: Option<RonSetup>,
items: Option<ItemsSetup>,
levels: Option<LevelsSetup>,
}
#[derive(Debug, Clone)]
pub struct LevelsSetup {
pub exp_field: String,
pub level_field: String,
pub curve_base: u32,
pub curve_exponent: u32,
pub growth: f64,
pub max_level: u8,
}
impl LevelsSetup {
fn from_manifest(levels: &BattleLevels) -> Self {
Self {
exp_field: levels.exp_field.clone(),
level_field: levels.level_field.clone(),
curve_base: levels.curve.base,
curve_exponent: levels.curve.exponent,
growth: levels.growth,
max_level: levels.max_level.clamp(1, 255) as u8,
}
}
pub fn exp_to_next(&self, level: u8) -> u32 {
exp_to_next(self.curve_base, self.curve_exponent, level)
}
pub fn growth(&self, raw: u32, level: u8) -> u32 {
growth_stat(raw, level, self.growth)
}
}
struct ItemsSetup {
dir: String,
heal_field: String,
starting: HashMap<String, u32>,
}
struct Encounter {
name: String,
enemy_ids: Vec<String>,
trainer: bool,
money: u32,
}
struct RonSetup {
compiled: dotzuki_rules::CompiledRuleset,
registry: Vec<&'static dotzuki_engine::battle::stack::Effect<GenericProvider>>,
move_records: HashMap<String, hooks::RonMove>,
status_names: Vec<String>,
stat_names: Vec<String>,
has_resource: bool,
}
impl BattleSetup {
pub fn from_project(project: &LoadedProject) -> Result<Self> {
let section = project
.manifest()
.battle
.as_ref()
.context("manifest has no battle section")?;
let table_dir = |reference: Option<&crate::manifest::BattleTableRef>,
what: &str|
-> Result<String> {
let id = reference
.map(|r| r.table.as_str())
.with_context(|| format!("battle.{what}.table is required when a battle starts"))?;
project
.table_dir_rel(id)
.with_context(|| format!("battle.{what}.table '{id}' is not a declared data table"))
};
let party_dir = table_dir(section.party.as_ref(), "party")?;
let enemies_dir = table_dir(section.enemies.as_ref(), "enemies")?;
let encounters_dir = match §ion.encounters {
Some(encounters) => Some(project.table_dir_rel(&encounters.table).with_context(|| {
format!(
"battle.encounters.table '{}' is not a declared data table",
encounters.table
)
})?),
None => None,
};
let skills_dir = match §ion.skills {
Some(skills) => Some(project.table_dir_rel(&skills.table).with_context(|| {
format!("battle.skills.table '{}' is not a declared data table", skills.table)
})?),
None => None,
};
let items = match §ion.items {
Some(items) => Some(ItemsSetup {
dir: project.table_dir_rel(&items.table).with_context(|| {
format!("battle.items.table '{}' is not a declared data table", items.table)
})?,
heal_field: items.heal_field.clone(),
starting: items.starting.clone(),
}),
None => None,
};
let (skills_field, category_field, cost_field) = match §ion.skills {
Some(s) => (
s.field.clone(),
s.category_field.clone(),
s.cost_field.clone(),
),
None => (
crate::manifest::DEFAULT_SKILLS_FIELD.to_string(),
crate::manifest::DEFAULT_CATEGORY_FIELD.to_string(),
crate::manifest::DEFAULT_COST_FIELD.to_string(),
),
};
let rules_rel = section.rules.as_deref().unwrap_or(DEFAULT_RULES_FILE);
let rules_rel = join_path("", rules_rel);
let mut ron = None;
let chart = match project.files().read(&rules_rel) {
Ok(bytes) => {
let text = String::from_utf8(bytes)
.with_context(|| format!("{rules_rel} is not UTF-8"))?;
let ruleset = dotzuki_rules::Ruleset::from_ron(&text)
.map_err(|e| anyhow::anyhow!("failed to parse {rules_rel}: {e}"))?;
let chart = TypeChart::from_ruleset(&ruleset);
if !ruleset.effects.is_empty() {
let compiled = hooks::compile_ruleset(&ruleset)
.map_err(|e| anyhow::anyhow!("failed to compile {rules_rel}: {e}"))?;
let registry = compiled.build_effects::<GenericProvider>();
let move_records = hooks::ron_moves(&ruleset, section.resource.as_deref());
ron = Some(RonSetup {
compiled,
registry,
move_records,
status_names: hooks::status_names(&ruleset),
stat_names: ruleset.stats.clone(),
has_resource: section.resource.is_some(),
});
}
chart
}
Err(e) => {
if section.rules.is_some() {
log::warn!(
"battle.rules '{rules_rel}' could not be read ({e:#}); battles run with an empty type chart"
);
}
TypeChart::default()
}
};
Ok(Self {
files: Arc::clone(project.files()),
party_dir,
enemies_dir,
encounters_dir,
skills_dir,
skills_field,
category_field,
cost_field,
stats: section.stats.clone().unwrap_or_default(),
resource: section.resource.clone(),
chart,
ron,
items,
levels: section.levels.as_ref().map(LevelsSetup::from_manifest),
})
}
pub fn start(&self, enemy_id: &str, rng: Box<dyn BattleRng>) -> Result<Battle> {
self.start_with(enemy_id, rng, None, None)
}
pub fn start_with(
&self,
enemy_id: &str,
rng: Box<dyn BattleRng>,
party_state: Option<&[PartyMemberState]>,
inventory: Option<&HashMap<String, u32>>,
) -> Result<Battle> {
let ids = record_ids(self.files.as_ref(), &self.party_dir);
if ids.is_empty() {
anyhow::bail!("party table {} has no records", self.party_dir);
}
let mut party = Vec::with_capacity(ids.len());
for id in &ids {
let mut c = self.load_combatant(&self.party_dir, id)?;
if let Some(state) = party_state.and_then(|states| states.iter().find(|s| &s.id == id))
{
if let Some(levels) = &self.levels {
c.level = state.level.max(1);
c.exp = state.exp;
c.recompute_stats(levels);
}
c.hp = state.hp.min(c.max_hp);
c.mp = state.mp.min(c.max_mp);
c.status = state.status.clone();
}
party.push(c);
}
let active = party.iter().position(|c| c.hp > 0).unwrap_or(0);
let (enemy, rest, trainer, money) = match self.load_encounter(enemy_id)? {
Some(encounter) => {
let mut enemies = Vec::with_capacity(encounter.enemy_ids.len());
for id in &encounter.enemy_ids {
enemies.push(self.load_combatant(&self.enemies_dir, id)?);
}
let mut enemies = enemies.into_iter();
let first = enemies.next().expect("encounter enemies non-empty");
let money = if encounter.trainer { encounter.money } else { 0 };
log::info!(
"encounter '{}' ({}): {} enemies, trainer {}",
enemy_id,
encounter.name,
encounter.enemy_ids.len(),
encounter.trainer
);
(first, enemies.collect(), encounter.trainer, money)
}
None => {
let enemy_id = if self
.files
.exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
{
enemy_id.to_string()
} else {
let fallback =
first_record_id(self.files.as_ref(), &self.enemies_dir).with_context(|| {
format!("enemies table {} has no records", self.enemies_dir)
})?;
log::warn!(
"unknown enemy id '{enemy_id}' — using first enemy record '{fallback}'"
);
fallback
};
(self.load_combatant(&self.enemies_dir, &enemy_id)?, Vec::new(), false, 0)
}
};
let items = self.load_items();
let inventory = inventory.cloned().unwrap_or_else(|| {
self.items
.as_ref()
.map(|i| i.starting.clone())
.unwrap_or_default()
});
log::info!(
"battle: {} (hp {}, party of {}) vs {} (hp {})",
party[active].name,
party[active].max_hp,
party.len(),
enemy.name,
enemy.max_hp
);
let hook_state = self.ron.as_ref().map(|ron| {
hooks::install_compiled(ron.compiled.clone());
HookState {
state: BattleState::new(
vec![hooks::mirror_of(
&party[active],
&ron.stat_names,
&ron.status_names,
ron.has_resource,
)],
vec![hooks::mirror_of(
&enemy,
&ron.stat_names,
&ron.status_names,
ron.has_resource,
)],
),
effects: Vec::new(),
mv: dotzuki_engine::battle::stack::MoveContext::default(),
registry: ron.registry.clone(),
move_records: ron.move_records.clone(),
status_names: ron.status_names.clone(),
stat_names: ron.stat_names.clone(),
has_resource: ron.has_resource,
}
});
let mut battle = Battle::full(
party, active, enemy, items, inventory, self.chart.clone(), rng, hook_state,
);
battle.set_levels(self.levels.clone());
battle.set_enemy_party(rest, trainer, money);
if battle.party.iter().all(|c| c.hp == 0) {
battle.arm_loss();
}
Ok(battle)
}
fn load_encounter(&self, id: &str) -> Result<Option<Encounter>> {
let Some(dir) = &self.encounters_dir else {
return Ok(None);
};
if !self.files.exists(&format!("{dir}/{id}.json")) {
return Ok(None);
}
let record = read_record(self.files.as_ref(), dir, id)?;
let enemy_ids: Vec<String> = record
.get("enemies")
.and_then(|v| v.as_array())
.map(|ids| {
ids.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
if enemy_ids.is_empty() {
anyhow::bail!("encounter '{id}' has no 'enemies' list (or it is empty)");
}
for enemy_id in &enemy_ids {
if !self
.files
.exists(&format!("{}/{enemy_id}.json", self.enemies_dir))
{
anyhow::bail!(
"encounter '{id}' references unknown enemy id '{enemy_id}' \
(no record in the enemies table)"
);
}
}
Ok(Some(Encounter {
name: get_str(&record, "name").unwrap_or(id).to_string(),
enemy_ids,
trainer: record
.get("trainer")
.and_then(|v| v.as_bool())
.unwrap_or(false),
money: get_num(&record, "money").unwrap_or(0),
}))
}
fn load_items(&self) -> Vec<BattleItem> {
let Some(items) = &self.items else {
return Vec::new();
};
let mut out = Vec::new();
for id in record_ids(self.files.as_ref(), &items.dir) {
let record = match read_record(self.files.as_ref(), &items.dir, &id) {
Ok(record) => record,
Err(e) => {
log::warn!("item record '{id}' skipped: {e:#}");
continue;
}
};
let heal = get_num(&record, &items.heal_field).unwrap_or(0);
if heal > 0 {
out.push(BattleItem {
name: get_str(&record, "name").unwrap_or(&id).to_string(),
id,
heal,
});
}
}
out
}
fn ron_move(&self, id: &str) -> Option<&hooks::RonMove> {
self.ron.as_ref()?.move_records.get(id)
}
fn load_combatant(&self, dir: &str, id: &str) -> Result<Combatant> {
let record = read_record(self.files.as_ref(), dir, id)?;
let level_field = self
.levels
.as_ref()
.map(|l| l.level_field.as_str())
.unwrap_or("level");
let level = get_num(&record, level_field).unwrap_or(1).min(255) as u8;
let stat = |field: &str| get_num(&record, field).unwrap_or(1);
let base = BaseStats {
max_hp: stat(&self.stats.hp),
max_mp: self
.resource
.as_deref()
.map(|f| get_num(&record, f).unwrap_or(0))
.unwrap_or(0),
attack: stat(&self.stats.attack),
defense: stat(&self.stats.defense),
speed: stat(&self.stats.speed),
};
let grown = |raw: u32| match &self.levels {
Some(levels) => levels.growth(raw, level),
None => raw,
};
let (max_hp, mp) = (grown(base.max_hp), grown(base.max_mp));
let exp_reward = self
.levels
.as_ref()
.and_then(|l| get_num(&record, &l.exp_field))
.unwrap_or(0);
Ok(Combatant {
id: id.to_string(),
name: get_str(&record, "name").unwrap_or(id).to_string(),
element: get_str(&record, "element").map(str::to_string),
hp: max_hp,
max_hp,
attack: grown(base.attack),
defense: grown(base.defense),
speed: grown(base.speed),
level,
base,
exp: 0,
exp_reward,
stages: Stages::default(),
mp,
max_mp: mp,
skills: self.load_skills(&record),
status: None,
ability: get_str(&record, "ability").map(str::to_string),
held_item: get_str(&record, "heldItem").map(str::to_string),
})
}
fn load_skills(&self, record: &serde_json::Value) -> Vec<Skill> {
let Some(skills_dir) = &self.skills_dir else {
return vec![basic_attack()];
};
let mut skills = Vec::new();
if let Some(ids) = record.get(&self.skills_field).and_then(|v| v.as_array()) {
for id in ids {
let Some(id) = id.as_str() else {
continue;
};
match read_record(self.files.as_ref(), skills_dir, id) {
Ok(rec) => skills.push(skill_from_record(id, &rec, self)),
Err(e) => log::warn!("unknown skill id '{id}' skipped: {e:#}"),
}
}
}
if skills.is_empty() {
skills.push(basic_attack());
}
skills
}
}
pub(crate) fn record_ids(files: &dyn ProjectFiles, dir: &str) -> Vec<String> {
let prefix = format!("{dir}/");
let mut ids: Vec<String> = files
.list(dir)
.into_iter()
.filter_map(|p| {
let rest = p.strip_prefix(&prefix)?;
if rest.contains('/') {
return None;
}
rest.strip_suffix(".json").map(str::to_string)
})
.collect();
ids.sort();
ids.dedup();
ids
}
fn first_record_id(files: &dyn ProjectFiles, dir: &str) -> Option<String> {
record_ids(files, dir).into_iter().next()
}
pub(crate) fn read_record(
files: &dyn ProjectFiles,
dir: &str,
id: &str,
) -> Result<serde_json::Value> {
let rel = format!("{dir}/{id}.json");
let bytes = files
.read(&rel)
.with_context(|| format!("failed to read {rel}"))?;
serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {rel}"))
}
pub(crate) fn get_num(record: &serde_json::Value, field: &str) -> Option<u32> {
match record.get(field)? {
serde_json::Value::Number(n) => n
.as_u64()
.or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
.map(|v| v.min(u32::MAX as u64) as u32),
_ => None,
}
}
pub(crate) fn get_str<'a>(record: &'a serde_json::Value, field: &str) -> Option<&'a str> {
record.get(field).and_then(|v| v.as_str())
}
pub fn accuracy_roll(accuracy: u32, rng: &mut dyn BattleRng) -> bool {
u32::from(rng.byte() % 100) < accuracy
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DamageRoll {
pub damage: u32,
pub crit: bool,
pub mult_num: u32,
pub mult_den: u32,
}
pub fn damage_roll(
power: u32,
eff_atk: u32,
eff_def: u32,
mult: (u32, u32),
rng: &mut dyn BattleRng,
) -> DamageRoll {
let base = power as u64 * eff_atk as u64 / eff_def.max(1) as u64;
let varied = base * (85 + (rng.byte() % 16) as u64) / 100;
let crit = rng.byte().is_multiple_of(16);
let after_crit = if crit { varied * 3 / 2 } else { varied };
let damage = (after_crit * mult.0 as u64 / mult.1.max(1) as u64)
.max(1)
.min(u32::MAX as u64) as u32;
DamageRoll {
damage,
crit,
mult_num: mult.0,
mult_den: mult.1,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BattleOutcome {
Win,
Lose,
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Player,
Enemy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum After {
Menu,
ForcedSwitch,
End(BattleOutcome),
}
#[derive(Debug)]
enum Phase {
Root,
Skills,
Party,
Items,
ForcedSwitch,
Narrate { lines: VecDeque<String>, after: After },
}
enum FaintFlow {
Continue,
SentOut,
Switch,
End(BattleOutcome),
}
pub struct Battle {
party: Vec<Combatant>,
active: usize,
enemy: Combatant,
enemies: VecDeque<Combatant>,
trainer: bool,
trainer_money: u32,
exp_pool: u32,
currency: String,
items: Vec<BattleItem>,
inventory: HashMap<String, u32>,
chart: TypeChart,
rng: Box<dyn BattleRng>,
hooks: Option<HookState>,
levels: Option<LevelsSetup>,
lang: String,
phase: Phase,
cursor: usize,
outcome: Option<BattleOutcome>,
weather: Option<String>,
pending_enemy: Option<Skill>,
log: Vec<String>,
}
impl Battle {
pub fn new(
player: Combatant,
enemy: Combatant,
chart: TypeChart,
rng: Box<dyn BattleRng>,
) -> Self {
Self::with_hooks(player, enemy, chart, rng, None)
}
pub fn with_hooks(
player: Combatant,
enemy: Combatant,
chart: TypeChart,
rng: Box<dyn BattleRng>,
hooks: Option<HookState>,
) -> Self {
Self::full(
vec![player],
0,
enemy,
Vec::new(),
HashMap::new(),
chart,
rng,
hooks,
)
}
#[allow(clippy::too_many_arguments)]
pub fn full(
party: Vec<Combatant>,
active: usize,
enemy: Combatant,
items: Vec<BattleItem>,
inventory: HashMap<String, u32>,
chart: TypeChart,
rng: Box<dyn BattleRng>,
hooks: Option<HookState>,
) -> Self {
Self {
party,
active,
enemy,
enemies: VecDeque::new(),
trainer: false,
trainer_money: 0,
exp_pool: 0,
currency: "G".to_string(),
items,
inventory,
chart,
rng,
hooks,
levels: None,
lang: "en".to_string(),
weather: None,
phase: Phase::Root,
cursor: 0,
outcome: None,
pending_enemy: None,
log: Vec::new(),
}
}
pub(crate) fn arm_loss(&mut self) {
self.outcome = Some(BattleOutcome::Lose);
}
pub fn set_levels(&mut self, levels: Option<LevelsSetup>) {
self.levels = levels;
}
pub fn set_lang(&mut self, lang: &str) {
self.lang = lang.to_string();
}
pub fn set_currency(&mut self, currency: &str) {
self.currency = currency.to_string();
}
pub fn set_enemy_party(&mut self, rest: Vec<Combatant>, trainer: bool, money: u32) {
self.enemies = rest.into();
self.trainer = trainer;
self.trainer_money = money;
}
pub fn set_weather(&mut self, weather: Option<String>) {
self.weather = weather;
}
pub fn weather(&self) -> Option<&str> {
self.weather.as_deref()
}
pub fn begin(&mut self) {
if self.outcome.is_some() || self.hooks.is_none() {
return;
}
let mut lines = VecDeque::new();
if let Some(weather) = self.weather.clone() {
if self.record_has_hooks(&weather) {
narrate(&mut self.log, &mut lines, weather_start_line(&self.lang, &weather));
} else {
log::warn!("weather '{weather}' names no rules.ron record — ignored");
self.weather = None;
}
}
for side in [Side::Player, Side::Enemy] {
self.fire_switch_in(side, &mut lines);
}
if !lines.is_empty() {
self.phase = Phase::Narrate {
lines,
after: After::Menu,
};
}
}
fn record_has_hooks(&self, id: &str) -> bool {
GenericProvider::rules_host()
.is_some_and(|host| host.compiled.hooks.values().any(|h| h.source_id == id))
}
fn fire_switch_in(&mut self, side: Side, lines: &mut VecDeque<String>) {
if self.hooks.is_none() {
return;
}
let combatant = match side {
Side::Player => &self.party[self.active],
Side::Enemy => &self.enemy,
};
let Some(ability) = combatant.ability.clone() else {
return;
};
if !self.subscribes(&ability, Event::SwitchIn) {
return;
}
let name = combatant.name.clone();
narrate(
&mut self.log,
lines,
ability_intro_line(&self.lang, &name, &ability),
);
self.sync_to_mirrors();
let who = HookState::battler_ref(side);
let before = snap_mirrors(self.hooks.as_ref().unwrap());
self.fire(Event::SwitchIn, &[&ability], who, who, RelayVar::Unit);
self.narrate_diffs(&before, lines, false);
self.sync_from_mirrors();
}
pub fn player(&self) -> &Combatant {
&self.party[self.active]
}
pub fn party(&self) -> &[Combatant] {
&self.party
}
pub fn active_index(&self) -> usize {
self.active
}
pub fn enemy(&self) -> &Combatant {
&self.enemy
}
pub fn enemies_remaining(&self) -> usize {
self.enemies.len()
}
pub fn is_trainer(&self) -> bool {
self.trainer
}
pub fn trainer_money(&self) -> u32 {
self.trainer_money
}
pub fn outcome(&self) -> Option<BattleOutcome> {
self.outcome
}
pub fn log(&self) -> &[String] {
&self.log
}
pub fn hooks(&self) -> Option<&HookState> {
self.hooks.as_ref()
}
pub fn inventory(&self) -> &HashMap<String, u32> {
&self.inventory
}
pub fn party_state(&self) -> Vec<PartyMemberState> {
self.party
.iter()
.map(|c| PartyMemberState {
id: c.id.clone(),
hp: c.hp,
mp: c.mp,
status: c.status.clone(),
level: c.level,
exp: c.exp,
})
.collect()
}
pub fn in_menu(&self) -> bool {
!matches!(self.phase, Phase::Narrate { .. }) && self.outcome.is_none()
}
pub fn current_line(&self) -> Option<&str> {
match &self.phase {
Phase::Narrate { lines, .. } => lines.front().map(String::as_str),
_ => None,
}
}
pub fn menu_items(&self) -> Vec<String> {
match &self.phase {
Phase::Root => self.root_items(),
Phase::Skills => self.skill_items(),
Phase::Party | Phase::ForcedSwitch => self.party_items(),
Phase::Items => self.item_items(),
Phase::Narrate { .. } => Vec::new(),
}
}
fn root_items(&self) -> Vec<String> {
let mut items = vec!["Fight".to_string(), "Party".to_string()];
if !self.items.is_empty() {
items.push("Item".to_string());
}
items.push("Run".to_string());
items
}
fn skill_items(&self) -> Vec<String> {
self.player()
.skills
.iter()
.map(|s| {
let label = if s.cost > 0 {
format!("{} {}MP", s.name, s.cost)
} else {
s.name.clone()
};
if s.cost > self.player().mp {
format!("× {label}")
} else {
label
}
})
.collect()
}
fn party_items(&self) -> Vec<String> {
self.party
.iter()
.enumerate()
.map(|(i, c)| {
let mut label = format!("{} {}/{}", c.name, c.hp, c.max_hp);
if let Some(status) = &c.status {
label.push_str(&format!(" ({status})"));
}
if i == self.active || c.hp == 0 {
format!("× {label}")
} else {
label
}
})
.collect()
}
fn item_items(&self) -> Vec<String> {
self.usable_items()
.iter()
.map(|&i| {
let item = &self.items[i];
let count = self.inventory.get(&item.id).copied().unwrap_or(0);
format!("{} ×{count}", item.name)
})
.collect()
}
fn usable_items(&self) -> Vec<usize> {
self.items
.iter()
.enumerate()
.filter(|(_, item)| self.inventory.get(&item.id).copied().unwrap_or(0) > 0)
.map(|(i, _)| i)
.collect()
}
fn first_switchable(&self) -> usize {
self.party
.iter()
.enumerate()
.position(|(i, c)| i != self.active && c.hp > 0)
.unwrap_or(0)
}
fn move_cursor(&mut self, input: &InputState, n: usize) {
let n = n.max(1);
if input.is_just_pressed(GbButton::Up) {
self.cursor = (self.cursor + n - 1) % n;
} else if input.is_just_pressed(GbButton::Down) {
self.cursor = (self.cursor + 1) % n;
}
}
pub fn update(&mut self, input: &InputState) {
match std::mem::replace(&mut self.phase, Phase::Root) {
Phase::Root => {
let n = self.root_items().len();
self.move_cursor(input, n);
if input.is_just_pressed(GbButton::A) {
if self.cursor == n - 1 {
self.try_run();
return;
}
self.phase = match self.cursor {
0 => Phase::Skills,
1 => {
self.cursor = self.first_switchable();
Phase::Party
}
_ => Phase::Items,
};
if !matches!(self.phase, Phase::Party) {
self.cursor = 0;
}
}
}
Phase::Skills => {
if input.is_just_pressed(GbButton::B) {
self.phase = Phase::Root;
self.cursor = 0;
return;
}
let n = self.player().skills.len();
self.move_cursor(input, n);
if input.is_just_pressed(GbButton::A) {
if self.player().skills[self.cursor].cost <= self.player().mp {
let pick = self.cursor;
self.execute_round(pick);
return;
}
}
self.phase = Phase::Skills;
}
Phase::Party => {
if input.is_just_pressed(GbButton::B) {
self.phase = Phase::Root;
self.cursor = 0;
return;
}
let n = self.party.len();
self.move_cursor(input, n);
if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
let pick = self.cursor;
self.execute_switch_round(pick);
return;
}
self.phase = Phase::Party;
}
Phase::Items => {
if input.is_just_pressed(GbButton::B) {
self.phase = Phase::Root;
self.cursor = 0;
return;
}
let n = self.usable_items().len();
self.move_cursor(input, n);
if input.is_just_pressed(GbButton::A) && n > 0 {
let pick = self.cursor;
self.execute_item_round(pick);
return;
}
self.phase = Phase::Items;
}
Phase::ForcedSwitch => {
let n = self.party.len();
self.move_cursor(input, n);
if input.is_just_pressed(GbButton::A) && self.switch_legal(self.cursor) {
let pick = self.cursor;
self.forced_switch_to(pick);
return;
}
self.phase = Phase::ForcedSwitch;
}
Phase::Narrate { mut lines, after } => {
if input.is_just_pressed(GbButton::A) {
lines.pop_front();
}
if lines.is_empty() && input.is_just_pressed(GbButton::A) {
match after {
After::Menu => {
self.phase = Phase::Root;
self.cursor = 0;
}
After::ForcedSwitch => {
self.cursor = self.first_switchable();
self.phase = Phase::ForcedSwitch;
}
After::End(o) => self.outcome = Some(o),
}
} else {
self.phase = Phase::Narrate { lines, after };
}
}
}
}
fn switch_legal(&self, idx: usize) -> bool {
idx != self.active && self.party.get(idx).is_some_and(|c| c.hp > 0)
}
fn try_run(&mut self) {
let mut lines = VecDeque::new();
if self.trainer {
narrate(&mut self.log, &mut lines, run_blocked_line(&self.lang));
self.phase = Phase::Narrate {
lines,
after: After::Menu,
};
} else {
narrate(&mut self.log, &mut lines, run_safe_line(&self.lang));
self.phase = Phase::Narrate {
lines,
after: After::End(BattleOutcome::Run),
};
}
}
fn execute_round(&mut self, player_pick: usize) {
let player_skill = self.player().skills[player_pick].clone();
let enemy_skill = ai_pick(&self.enemy);
let player_first = self.player().eff_speed() >= self.enemy.eff_speed();
let order = if player_first {
[Side::Player, Side::Enemy]
} else {
[Side::Enemy, Side::Player]
};
let mut lines = VecDeque::new();
let mut after = After::Menu;
for (pos, side) in order.iter().enumerate() {
if self.player().hp == 0 || self.enemy.hp == 0 {
break; }
let skill = match side {
Side::Player => player_skill.clone(),
Side::Enemy => enemy_skill.clone(),
};
self.perform(*side, &skill, &mut lines);
match self.faint_flow(&mut lines) {
FaintFlow::Continue => {}
FaintFlow::SentOut => {
after = After::Menu;
break;
}
FaintFlow::Switch => {
if order.get(pos + 1) == Some(&Side::Enemy) {
self.pending_enemy = Some(enemy_skill);
}
after = After::ForcedSwitch;
break;
}
FaintFlow::End(o) => {
after = After::End(o);
break;
}
}
self.residual(*side, &mut lines);
match self.faint_flow(&mut lines) {
FaintFlow::Continue => {}
FaintFlow::SentOut => {
after = After::Menu;
break;
}
FaintFlow::Switch => {
if order.get(pos + 1) == Some(&Side::Enemy) {
self.pending_enemy = Some(enemy_skill);
}
after = After::ForcedSwitch;
break;
}
FaintFlow::End(o) => {
after = After::End(o);
break;
}
}
}
self.phase = Phase::Narrate { lines, after };
}
fn faint_flow(&mut self, lines: &mut VecDeque<String>) -> FaintFlow {
if self.enemy.hp == 0 {
narrate(&mut self.log, lines, format!("{} fainted!", self.enemy.name));
self.exp_pool = self.exp_pool.saturating_add(self.enemy.exp_reward);
if let Some(next) = self.enemies.pop_front() {
self.send_out(next, lines);
return FaintFlow::SentOut;
}
narrate(&mut self.log, lines, "You won the battle!".to_string());
self.award_exp(lines);
self.award_trainer_money(lines);
FaintFlow::End(BattleOutcome::Win)
} else if self.player().hp == 0 {
let name = self.player().name.clone();
narrate(&mut self.log, lines, format!("{name} fainted!"));
if self.party.iter().any(|c| c.hp > 0) {
FaintFlow::Switch
} else {
narrate(&mut self.log, lines, "You lost the battle…".to_string());
FaintFlow::End(BattleOutcome::Lose)
}
} else {
FaintFlow::Continue
}
}
fn send_out(&mut self, next: Combatant, lines: &mut VecDeque<String>) {
self.enemy = next;
if let Some(hooks) = &mut self.hooks {
hooks.state.opponent_battlers[0] = hooks::mirror_of(
&self.enemy,
&hooks.stat_names,
&hooks.status_names,
hooks.has_resource,
);
let enemy_ref = HookState::battler_ref(Side::Enemy);
hooks.effects.retain(|e| e.host != enemy_ref);
}
let name = self.enemy.name.clone();
narrate(&mut self.log, lines, sent_out_line(&self.lang, &name));
self.fire_switch_in(Side::Enemy, lines);
}
fn award_exp(&mut self, lines: &mut VecDeque<String>) {
let Some(levels) = self.levels.clone() else {
return;
};
let reward = self.exp_pool;
let lang = self.lang.clone();
for i in 0..self.party.len() {
if self.party[i].hp == 0 {
continue; }
let name = self.party[i].name.clone();
narrate(&mut self.log, lines, gained_exp_line(&lang, &name, reward));
let c = &mut self.party[i];
c.exp = c.exp.saturating_add(reward);
loop {
let need = levels.exp_to_next(c.level);
if c.exp < need || c.level >= levels.max_level {
break;
}
c.exp -= need;
c.level += 1;
c.recompute_stats(&levels);
narrate(&mut self.log, lines, level_up_line(&lang, &name, c.level));
}
}
}
fn award_trainer_money(&mut self, lines: &mut VecDeque<String>) {
if self.trainer_money > 0 {
let line = trainer_money_line(&self.lang, self.trainer_money, &self.currency);
narrate(&mut self.log, lines, line);
}
}
fn execute_switch_round(&mut self, idx: usize) {
let mut lines = VecDeque::new();
let old_name = self.player().name.clone();
narrate(&mut self.log, &mut lines, format!("Come back, {old_name}!"));
self.switch_to(idx, &mut lines);
let after = self.enemy_turn(&mut lines);
self.phase = Phase::Narrate { lines, after };
}
fn execute_item_round(&mut self, pick: usize) {
let usable = self.usable_items();
let Some(&item_idx) = usable.get(pick) else {
return;
};
let item = self.items[item_idx].clone();
let mut lines = VecDeque::new();
let before = self.player().hp;
let healed = (before + item.heal).min(self.player().max_hp);
self.party[self.active].hp = healed;
if let Some(count) = self.inventory.get_mut(&item.id) {
*count = count.saturating_sub(1);
if *count == 0 {
self.inventory.remove(&item.id);
}
}
let name = self.player().name.clone();
narrate(&mut self.log, &mut lines, format!("{name} used {}!", item.name));
narrate(
&mut self.log,
&mut lines,
format!("{name} recovered {} HP!", healed - before),
);
let after = self.enemy_turn(&mut lines);
self.phase = Phase::Narrate { lines, after };
}
fn enemy_turn(&mut self, lines: &mut VecDeque<String>) -> After {
let skill = ai_pick(&self.enemy);
self.perform(Side::Enemy, &skill, lines);
match self.faint_flow(lines) {
FaintFlow::SentOut => After::Menu,
FaintFlow::Switch => After::ForcedSwitch,
FaintFlow::End(o) => After::End(o),
FaintFlow::Continue => {
self.residual(Side::Enemy, lines);
match self.faint_flow(lines) {
FaintFlow::SentOut => After::Menu,
FaintFlow::Switch => After::ForcedSwitch,
FaintFlow::End(o) => After::End(o),
FaintFlow::Continue => After::Menu,
}
}
}
}
fn forced_switch_to(&mut self, idx: usize) {
let mut lines = VecDeque::new();
self.switch_to(idx, &mut lines);
let mut after = After::Menu;
if let Some(skill) = self.pending_enemy.take() {
self.perform(Side::Enemy, &skill, &mut lines);
match self.faint_flow(&mut lines) {
FaintFlow::SentOut => after = After::Menu,
FaintFlow::Switch => after = After::ForcedSwitch,
FaintFlow::End(o) => after = After::End(o),
FaintFlow::Continue => {
self.residual(Side::Enemy, &mut lines);
after = match self.faint_flow(&mut lines) {
FaintFlow::SentOut => After::Menu,
FaintFlow::Switch => After::ForcedSwitch,
FaintFlow::End(o) => After::End(o),
FaintFlow::Continue => After::Menu,
};
}
}
}
self.phase = Phase::Narrate { lines, after };
}
fn switch_to(&mut self, idx: usize, lines: &mut VecDeque<String>) {
self.active = idx;
self.party[idx].stages = Stages::default();
if let Some(hooks) = &mut self.hooks {
hooks.state.player_battlers[0] = hooks::mirror_of(
&self.party[idx],
&hooks.stat_names,
&hooks.status_names,
hooks.has_resource,
);
let player_ref = HookState::battler_ref(Side::Player);
hooks.effects.retain(|e| e.host != player_ref);
}
let name = self.party[idx].name.clone();
narrate(&mut self.log, lines, format!("Go, {name}!"));
self.fire_switch_in(Side::Player, lines);
}
fn perform(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
if skill.ron && self.hooks.is_some() {
self.perform_ron(side, skill, lines);
return;
}
let (attacker, defender) = match side {
Side::Player => (&mut self.party[self.active], &mut self.enemy),
Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
};
if skill.cost > attacker.mp {
narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
return;
}
attacker.mp -= skill.cost;
narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
narrate(&mut self.log, lines, "But it missed!".to_string());
return;
}
match skill.category {
SkillCategory::Damage => {
let mult = self
.chart
.mult(skill.element.as_deref(), defender.element.as_deref());
let roll = damage_roll(
skill.power,
attacker.eff_attack(),
defender.eff_defense(),
mult,
self.rng.as_mut(),
);
defender.hp = defender.hp.saturating_sub(roll.damage);
if roll.crit {
narrate(&mut self.log, lines, "Critical hit!".to_string());
}
if roll.mult_num > roll.mult_den {
narrate(&mut self.log, lines, "It's super effective!".to_string());
} else if roll.mult_num < roll.mult_den {
narrate(&mut self.log, lines, "It's not very effective…".to_string());
}
narrate(&mut self.log, lines, format!("{} damage!", roll.damage));
}
SkillCategory::Heal => {
let before = attacker.hp;
attacker.hp = (attacker.hp + skill.power).min(attacker.max_hp);
narrate(&mut self.log, lines,
format!("{} recovered {} HP!", attacker.name, attacker.hp - before),
);
}
SkillCategory::Buff => {
attacker.stages.bump(&skill.stat, 1);
narrate(&mut self.log, lines,
format!("{}'s {} rose!", attacker.name, stat_label(&skill.stat)),
);
}
SkillCategory::Debuff => {
defender.stages.bump(&skill.stat, -1);
narrate(&mut self.log, lines,
format!("{}'s {} fell!", defender.name, stat_label(&skill.stat)),
);
}
}
}
fn fire(
&mut self,
event: Event,
source_ids: &[&str],
target: BattlerRef,
source: BattlerRef,
relay: RelayVar,
) -> RelayVar {
let hooks = self.hooks.as_mut().expect("fire requires hook state");
let host = GenericProvider::rules_host().expect("rules host installed");
let provider = GenericProvider;
let mut adapter = hooks::RngAdapter(self.rng.as_mut());
let mut ctx = BattleCtx {
state: &mut hooks.state,
effects: &mut hooks.effects,
mv: &mut hooks.mv,
rng: &mut adapter,
};
let mut hs = Vec::new();
for eff in &hooks.registry {
let matches = host
.compiled
.hook(eff.id)
.map(|h| source_ids.contains(&h.source_id.as_str()))
.unwrap_or(false);
if matches {
collect_handlers(&ctx, &provider, Some(eff), event, target, source, &mut hs);
}
}
run_event(&mut ctx, hs, relay, false)
}
fn sync_to_mirrors(&mut self) {
let Some(hooks) = &mut self.hooks else { return };
let (stat_names, status_names, has_resource) =
(&hooks.stat_names, &hooks.status_names, hooks.has_resource);
hooks::sync_to_mirror(
&self.party[self.active],
&mut hooks.state.player_battlers[0],
stat_names,
status_names,
has_resource,
);
hooks::sync_to_mirror(
&self.enemy,
&mut hooks.state.opponent_battlers[0],
stat_names,
status_names,
has_resource,
);
}
fn sync_from_mirrors(&mut self) {
let Some(hooks) = &mut self.hooks else { return };
let (stat_names, status_names, has_resource) =
(&hooks.stat_names, &hooks.status_names, hooks.has_resource);
hooks::sync_from_mirror(
&hooks.state.player_battlers[0],
&mut self.party[self.active],
stat_names,
status_names,
has_resource,
);
hooks::sync_from_mirror(
&hooks.state.opponent_battlers[0],
&mut self.enemy,
stat_names,
status_names,
has_resource,
);
}
fn narrate_diffs(&mut self, before: &[MirrorSnap; 2], lines: &mut VecDeque<String>, residual: bool) {
let names = [self.player().name.clone(), self.enemy.name.clone()];
let Some(hooks) = &self.hooks else { return };
let produced = diff_lines(hooks, before, [&names[0], &names[1]], residual);
for line in produced {
narrate(&mut self.log, lines, line);
}
}
fn perform_ron(&mut self, side: Side, skill: &Skill, lines: &mut VecDeque<String>) {
let (attacker, defender) = match side {
Side::Player => (&mut self.party[self.active], &mut self.enemy),
Side::Enemy => (&mut self.enemy, &mut self.party[self.active]),
};
if skill.cost > attacker.mp {
narrate(&mut self.log, lines, format!("{} tried to use {}!", attacker.name, skill.name));
narrate(&mut self.log, lines, "But there wasn't enough MP!".to_string());
return;
}
attacker.mp -= skill.cost;
narrate(&mut self.log, lines, format!("{} used {}!", attacker.name, skill.name));
if !accuracy_roll(skill.accuracy, self.rng.as_mut()) {
narrate(&mut self.log, lines, "But it missed!".to_string());
return;
}
let eff_atk = attacker.eff_attack();
let eff_def = defender.eff_defense();
let def_element = defender.element.clone();
let ability = attacker.ability.clone();
let held_item = attacker.held_item.clone();
let mut ids: Vec<&str> = vec![skill.id.as_str()];
if let Some(a) = &ability {
ids.push(a);
}
if let Some(i) = &held_item {
ids.push(i);
}
let source = HookState::battler_ref(side);
let target = HookState::battler_ref(match side {
Side::Player => Side::Enemy,
Side::Enemy => Side::Player,
});
self.sync_to_mirrors();
if self.subscribes_any(&ids, Event::BeforeMove) {
let before = snap_mirrors(self.hooks.as_ref().unwrap());
let out = self.fire(Event::BeforeMove, &ids, target, source, RelayVar::Bool(true));
self.narrate_diffs(&before, lines, false);
match out {
RelayVar::Bool(false) => {
narrate(&mut self.log, lines, "But it failed!".to_string());
self.sync_from_mirrors();
return;
}
RelayVar::Unit => {
self.sync_from_mirrors();
return;
}
_ => {}
}
}
let has_effectiveness_hooks = self.subscribes_any(&ids, Event::Effectiveness);
if skill.power > 0 {
let mult = if has_effectiveness_hooks {
(1, 1)
} else {
self.chart
.mult(skill.element.as_deref(), def_element.as_deref())
};
let roll = damage_roll(skill.power, eff_atk, eff_def, mult, self.rng.as_mut());
self.hooks.as_mut().unwrap().mv.damage =
roll.damage.min(u32::from(u16::MAX)) as u16;
if roll.crit {
narrate(&mut self.log, lines, "Critical hit!".to_string());
}
if !has_effectiveness_hooks {
if roll.mult_num > roll.mult_den {
narrate(&mut self.log, lines, "It's super effective!".to_string());
} else if roll.mult_num < roll.mult_den {
narrate(&mut self.log, lines, "It's not very effective…".to_string());
}
}
let before = snap_mirrors(self.hooks.as_ref().unwrap());
let in_damage = self.hooks.as_ref().unwrap().mv.damage;
let out = self.fire(
Event::ModifyDamage,
&ids,
target,
source,
RelayVar::Damage(in_damage),
);
self.narrate_diffs(&before, lines, false);
match out {
RelayVar::Damage(d) => self.hooks.as_mut().unwrap().mv.damage = d,
RelayVar::Bool(false) => {
narrate(&mut self.log, lines, "But it failed!".to_string());
self.sync_from_mirrors();
return;
}
RelayVar::Unit => {
self.sync_from_mirrors();
return;
}
_ => {}
}
if has_effectiveness_hooks {
let before = snap_mirrors(self.hooks.as_ref().unwrap());
let in_damage = self.hooks.as_ref().unwrap().mv.damage;
let out = self.fire(
Event::Effectiveness,
&ids,
target,
source,
RelayVar::Damage(in_damage),
);
self.narrate_diffs(&before, lines, false);
match out {
RelayVar::Damage(d) => {
self.hooks.as_mut().unwrap().mv.damage = d;
if d > in_damage {
narrate(&mut self.log, lines, "It's super effective!".to_string());
} else if d < in_damage {
narrate(&mut self.log, lines, "It's not very effective…".to_string());
}
}
RelayVar::Bool(false) => {
narrate(&mut self.log, lines, "But it failed!".to_string());
self.sync_from_mirrors();
return;
}
RelayVar::Unit => {
self.sync_from_mirrors();
return;
}
_ => {}
}
}
let before = snap_mirrors(self.hooks.as_ref().unwrap());
let in_damage = self.hooks.as_ref().unwrap().mv.damage;
let out = self.fire(
Event::Damage,
&ids,
target,
source,
RelayVar::Damage(in_damage),
);
self.narrate_diffs(&before, lines, false);
let final_damage = match out {
RelayVar::Damage(d) => d,
RelayVar::Bool(false) => {
narrate(&mut self.log, lines, "But it failed!".to_string());
self.sync_from_mirrors();
return;
}
RelayVar::Unit => {
self.sync_from_mirrors();
return;
}
_ => in_damage,
};
{
let hooks = self.hooks.as_mut().unwrap();
let b = if target.side == 0 {
&mut hooks.state.player_battlers[target.slot as usize]
} else {
&mut hooks.state.opponent_battlers[target.slot as usize]
};
b.take_damage(final_damage);
hooks.mv.last_damage = final_damage;
}
narrate(&mut self.log, lines, format!("{final_damage} damage!"));
self.sync_from_mirrors();
}
let before = snap_mirrors(self.hooks.as_ref().unwrap());
let last_damage = self.hooks.as_ref().unwrap().mv.last_damage;
self.fire(
Event::DamagingHit,
&ids,
target,
source,
RelayVar::Damage(last_damage),
);
self.narrate_diffs(&before, lines, false);
let before = snap_mirrors(self.hooks.as_ref().unwrap());
self.fire(Event::AfterMove, &ids, target, source, RelayVar::Unit);
self.narrate_diffs(&before, lines, false);
self.sync_from_mirrors();
}
fn subscribes(&self, skill_id: &str, event: Event) -> bool {
self.hooks
.as_ref()
.is_some_and(|h| h.subscribes(skill_id, event))
}
fn subscribes_any(&self, ids: &[&str], event: Event) -> bool {
ids.iter().any(|id| self.subscribes(id, event))
}
fn residual(&mut self, side: Side, lines: &mut VecDeque<String>) {
if self.hooks.is_none() {
return;
}
self.sync_to_mirrors();
let who = HookState::battler_ref(side);
let status_id = {
let hooks = self.hooks.as_ref().unwrap();
hooks
.battler(side)
.status
.clone()
.and_then(|hooks::StatusId(idx)| hooks.status_names.get(idx as usize).cloned())
};
if let Some(source_id) = status_id {
let before = snap_mirrors(self.hooks.as_ref().unwrap());
self.fire(Event::Residual, &[&source_id], who, who, RelayVar::Unit);
self.narrate_diffs(&before, lines, true);
}
let held_item = match side {
Side::Player => self.party[self.active].held_item.clone(),
Side::Enemy => self.enemy.held_item.clone(),
};
if let Some(item_id) = held_item {
let before = snap_mirrors(self.hooks.as_ref().unwrap());
self.fire(Event::Residual, &[&item_id], who, who, RelayVar::Unit);
self.narrate_diffs(&before, lines, false);
}
if let Some(weather) = self.weather.clone() {
let before = snap_mirrors(self.hooks.as_ref().unwrap());
self.fire(Event::FieldResidual, &[&weather], who, who, RelayVar::Unit);
self.narrate_diffs(&before, lines, false);
}
self.sync_from_mirrors();
}
pub fn draw(&self, fb: &mut FrameBuffer) {
fb.fill_rect(0, 0, SCREEN_W as u32, SCREEN_H as u32, Rgba::rgb(0x18, 0x18, 0x28));
fb.fill_rect(
0,
130,
SCREEN_W as u32,
SCREEN_H as u32 - 130,
Rgba::rgb(0x20, 0x2A, 0x20),
);
draw_blob(fb, 228, 28, 56, blob_color(&self.enemy.id));
draw_blob(fb, 52, 108, 64, blob_color(&self.player().id));
draw_panel(fb, 8, 8, 184, 46);
text(fb, &self.enemy.name, 16, 14, Rgba::rgb(0xF0, 0xF0, 0xF0));
if let Some(element) = &self.enemy.element {
text(fb, element, 16, 26, Rgba::rgb(0x90, 0xA8, 0xC8));
}
draw_bar(fb, 80, 28, 104, 6, self.enemy.hp, self.enemy.max_hp);
draw_panel(fb, 132, 136, 180, 46);
text(fb, &self.player().name, 140, 142, Rgba::rgb(0xF0, 0xF0, 0xF0));
draw_bar(fb, 140, 156, 104, 6, self.player().hp, self.player().max_hp);
text(
fb,
&format!("{}/{}", self.player().hp, self.player().max_hp),
250,
154,
Rgba::rgb(0xC8, 0xC8, 0xC8),
);
text(
fb,
&format!("MP {}/{}", self.player().mp, self.player().max_mp),
140,
168,
Rgba::rgb(0x90, 0xA8, 0xC8),
);
match &self.phase {
Phase::Root => {
draw_textbox(fb, "What will you do?");
self.draw_menu(fb);
}
Phase::Skills => {
draw_textbox(fb, "Choose a skill!");
self.draw_menu(fb);
}
Phase::Party => {
draw_textbox(fb, "Switch to whom?");
self.draw_menu(fb);
}
Phase::Items => {
draw_textbox(fb, "Use which item?");
self.draw_menu(fb);
}
Phase::ForcedSwitch => {
draw_textbox(fb, "Choose your next fighter!");
self.draw_menu(fb);
}
Phase::Narrate { lines, .. } => {
if let Some(line) = lines.front() {
draw_textbox(fb, line);
}
}
}
}
fn draw_menu(&self, fb: &mut FrameBuffer) {
let items = self.menu_items();
let n = items.len() as u32;
if n == 0 {
return;
}
let max_len = items.iter().map(|o| o.chars().count()).max().unwrap_or(1) as u32;
let w = (max_len + 4).clamp(10, 24);
let h = n + 2;
let tx = (40 - w) as i32;
let ty = DIALOG_AREA.ty as i32 - h as i32;
let config = MenuConfig::new(
TileRect::new(tx.max(0) as u32, ty.max(0) as u32, w, h),
None,
TileRect::new(tx.max(0) as u32 + 1, ty.max(0) as u32 + 1, w - 2, n),
Default::default(),
);
let state = FlexMenuState {
cursor: self.cursor,
scroll_offset: 0,
};
let mut painter = FrameBufferPainter::new(fb);
let mut ui = Ui::new(&mut painter);
draw_flex_menu(&items, &[config], &state, items.len(), &mut ui);
}
}
fn ai_pick(combatant: &Combatant) -> Skill {
combatant
.skills
.iter()
.filter(|s| s.cost <= combatant.mp)
.max_by_key(|s| s.power)
.cloned()
.unwrap_or_else(basic_attack)
}
fn narrate(log: &mut Vec<String>, lines: &mut VecDeque<String>, line: String) {
log.push(line.clone());
lines.push_back(line);
}
fn gained_exp_line(lang: &str, name: &str, n: u32) -> String {
if lang == "zh" {
format!("{name} 获得了 {n} 点经验!")
} else {
format!("{name} gained {n} EXP!")
}
}
fn level_up_line(lang: &str, name: &str, level: u8) -> String {
if lang == "zh" {
format!("{name} 升到了 {level} 级!")
} else {
format!("{name} grew to level {level}!")
}
}
fn sent_out_line(lang: &str, name: &str) -> String {
if lang == "zh" {
format!("对方派出了 {name}!")
} else {
format!("Foe sent out {name}!")
}
}
fn run_safe_line(lang: &str) -> String {
if lang == "zh" {
"顺利逃走了!".to_string()
} else {
"Got away safely!".to_string()
}
}
fn run_blocked_line(lang: &str) -> String {
if lang == "zh" {
"无法从训练家的战斗中逃走!".to_string()
} else {
"Can't escape from a trainer battle!".to_string()
}
}
fn trainer_money_line(lang: &str, money: u32, currency: &str) -> String {
if lang == "zh" {
format!("赢得了 {money} {currency}!")
} else {
format!("Got {money} {currency} for winning!")
}
}
fn ability_intro_line(lang: &str, name: &str, ability: &str) -> String {
let label = prettify_id(ability);
if lang == "zh" {
format!("{name} 的 {label}!")
} else {
format!("{name}'s {label}!")
}
}
fn weather_start_line(lang: &str, weather: &str) -> String {
if lang == "zh" {
format!("{weather} 开始了!")
} else {
format!("A {weather} rages!")
}
}
fn prettify_id(id: &str) -> String {
id.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut chars = w.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Clone)]
struct MirrorSnap {
hp: u16,
status: Option<hooks::StatusId>,
stages: Vec<i8>,
}
fn snap_mirrors(hooks: &HookState) -> [MirrorSnap; 2] {
[Side::Player, Side::Enemy].map(|side| {
let b = hooks.battler(side);
MirrorSnap {
hp: b.hp,
status: b.status.clone(),
stages: (0..hooks.stat_names.len())
.map(|i| {
b.stat_stages
.get(hooks::StatId(i as u16))
.copied()
.unwrap_or(0)
})
.collect(),
}
})
}
fn diff_lines(hooks: &HookState, before: &[MirrorSnap; 2], names: [&str; 2], residual: bool) -> Vec<String> {
let after = snap_mirrors(hooks);
let mut out = Vec::new();
for i in 0..2 {
let (b, a, name) = (&before[i], &after[i], names[i]);
let status_name = |id: &hooks::StatusId| {
hooks
.status_names
.get(id.0 as usize)
.map(String::as_str)
.unwrap_or("?")
};
match (&b.status, &a.status) {
(None, Some(id)) => out.push(format!("{name} was afflicted with {}!", status_name(id))),
(Some(id), None) => out.push(format!("{name} is no longer {}!", status_name(id))),
_ => {}
}
for (s, stat) in hooks.stat_names.iter().enumerate() {
let (bv, av) = (b.stages[s], a.stages[s]);
if av > bv {
out.push(format!("{name}'s {} rose!", stat_label(&normalize_stat_key(stat))));
} else if av < bv {
out.push(format!("{name}'s {} fell!", stat_label(&normalize_stat_key(stat))));
}
}
if a.hp < b.hp {
match (&a.status, &b.status, residual) {
(Some(id), _, true) | (_, Some(id), true) => {
out.push(format!("{name} is hurt by {}!", status_name(id)));
}
_ => out.push(format!("{name} lost {} HP!", b.hp - a.hp)),
}
} else if a.hp > b.hp {
out.push(format!("{name} recovered {} HP!", a.hp - b.hp));
}
}
out
}
pub(crate) fn text(fb: &mut FrameBuffer, s: &str, x: u32, y: u32, color: Rgba) {
embedded_font::draw_text(s, x, y, color, fb);
}
pub(crate) fn draw_panel(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32) {
fb.fill_rect(x, y, w, h, Rgba::rgb(0xC8, 0xC8, 0xD0));
fb.fill_rect(x + 1, y + 1, w - 2, h - 2, Rgba::rgb(0x28, 0x28, 0x38));
}
fn draw_bar(fb: &mut FrameBuffer, x: u32, y: u32, w: u32, h: u32, hp: u32, max_hp: u32) {
let frac = if max_hp == 0 {
0.0
} else {
hp as f32 / max_hp as f32
};
fb.fill_rect(x, y, w, h, Rgba::rgb(0x10, 0x10, 0x18));
let fill = if frac < 0.25 {
Rgba::rgb(0xD0, 0x40, 0x38)
} else {
Rgba::rgb(0x40, 0xC0, 0x58)
};
let inner = ((w - 2) as f32 * frac.clamp(0.0, 1.0)) as u32;
if inner > 0 {
fb.fill_rect(x + 1, y + 1, inner, h - 2, fill);
}
}
fn blob_color(id: &str) -> Rgba {
let hash = id
.bytes()
.fold(0x811C_9DC5_u32, |h, b| h.wrapping_mul(16_777_619) ^ b as u32);
let hue = (hash >> 8) as u8;
Rgba::rgb(0x60 + hue % 0x70, 0x60 + (hue / 3) % 0x70, 0x60 + (hue / 7) % 0x70)
}
fn draw_blob(fb: &mut FrameBuffer, x: i32, y: i32, size: i32, color: Rgba) {
let r = (size / 7).max(2);
for dy in 0..size {
for dx in 0..size {
let (px, py) = (x + dx, y + dy);
if px < 0 || py < 0 || px >= SCREEN_W || py >= SCREEN_H {
continue;
}
if (dx < r || dx >= size - r) && (dy < r || dy >= size - r) {
continue;
}
fb.set_pixel(px as u32, py as u32, color);
}
}
}
#[cfg(test)]
mod tests;