mod decoder;
mod encoder;
use core::f32::consts::*;
#[cfg(feature = "bevy_reflect")]
use bevy_reflect::prelude::*;
use bitflags::bitflags;
use glam::{IVec2, Vec2};
use serde::{Deserialize, Serialize};
pub use decoder::{DecodeError, Decoder};
pub use encoder::{EncodeError, Encoder};
pub const SCALE: f32 = 8.;
#[derive(Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
feature = "bevy_reflect",
derive(Reflect),
reflect(Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct BattleTabletop {
pub width: u32,
pub height: u32,
pub army1_file_stem: String,
pub army2_file_stem: String,
pub ctl_file_stem: String,
unknown1: String,
unknown2: String,
unknown3: Vec<i32>,
pub objectives: Vec<Objective>,
pub obstacles: Vec<Obstacle>,
obstacles_unknown1: i32,
pub regions: Vec<Region>,
pub nodes: Vec<Node>,
}
pub const ELIMINATE_ALL_ENEMIES_ID: i32 = 1;
pub const KILL_DREAD_KING_ID: i32 = 2;
pub const CRITICAL_REGIMENT_LOSE_CONDITION_ID: i32 = 3;
pub const SPATIAL_SOUND_EFFECT_PRESET_ID: i32 = 4;
pub const KILL_MANNFRED_VON_CARSTEIN_ID: i32 = 5;
pub const FIREWORKS_ID: i32 = 6;
pub const INITIAL_REGIMENT_ORIENTATION_ID: i32 = 7;
pub const KILL_HAND_OF_NAGASH_ID: i32 = 8;
pub const KILL_BLACK_GRAIL_ID: i32 = 9;
pub const GOLD_COLLECTION_METRIC_ID: i32 = 10;
pub const ENEMY_CASUALTIES_ID: i32 = 11;
pub const PLAYER_ELIMINATION_LOSE_CONDITION_ID: i32 = 26;
#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
feature = "bevy_reflect",
derive(Reflect),
reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct Objective {
pub id: i32,
pub value1: i32,
pub value2: i32,
}
impl Objective {
#[inline]
pub fn rotation_radians(value: i32) -> f32 {
(value as f32 / 512.0) * TAU
}
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
feature = "bevy_reflect",
derive(Reflect),
reflect(Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct Obstacle {
pub flags: ObstacleFlags,
pub position: IVec2,
pub height: i32,
pub radius: u32,
pub unknown: i32,
}
impl Obstacle {
#[inline]
pub fn world_position(&self) -> Vec2 {
Vec2::new(
self.position.x as f32 / SCALE,
self.position.y as f32 / SCALE,
)
}
#[inline]
pub fn world_height(&self) -> f32 {
self.height as f32 / SCALE
}
#[inline]
pub fn world_radius(&self) -> f32 {
self.radius as f32 / SCALE
}
}
bitflags! {
#[repr(transparent)]
#[derive(Clone, Copy, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(opaque), reflect(Default, Deserialize, Hash, PartialEq, Serialize))]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct ObstacleFlags: u32 {
const NONE = 0;
const ACTIVE = 1 << 0;
const BLOCKS_MOVEMENT = 1 << 1;
const BLOCKS_PROJECTILES = 1 << 2;
const UNKNOWN_FLAG_1 = 1 << 3;
const UNKNOWN_FLAG_2 = 1 << 4;
const UNKNOWN_FLAG_3 = 1 << 5;
const UNKNOWN_FLAG_4 = 1 << 6;
const UNKNOWN_FLAG_5 = 1 << 7;
const UNKNOWN_FLAG_6 = 1 << 8;
const UNKNOWN_FLAG_7 = 1 << 9;
const UNKNOWN_FLAG_8 = 1 << 10;
const UNKNOWN_FLAG_9 = 1 << 11;
const UNKNOWN_FLAG_10 = 1 << 12;
const UNKNOWN_FLAG_11 = 1 << 13;
const UNKNOWN_FLAG_12 = 1 << 14;
const UNKNOWN_FLAG_13 = 1 << 15;
}
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
feature = "bevy_reflect",
derive(Reflect),
reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct LineSegment {
pub start: IVec2,
pub end: IVec2,
}
impl LineSegment {
#[inline]
pub fn world_start(&self) -> Vec2 {
Vec2::new(self.start.x as f32 / SCALE, self.start.y as f32 / SCALE)
}
#[inline]
pub fn world_end(&self) -> Vec2 {
Vec2::new(self.end.x as f32 / SCALE, self.end.y as f32 / SCALE)
}
fn is_point_on_line_segment(&self, point: &IVec2) -> bool {
let crossproduct = (point.y - self.start.y) * (self.end.x - self.start.x)
- (point.x - self.start.x) * (self.end.y - self.start.y);
if crossproduct != 0 {
return false;
}
let dotproduct = (point.x - self.start.x) * (self.end.x - self.start.x)
+ (point.y - self.start.y) * (self.end.y - self.start.y);
if dotproduct < 0 {
return false;
}
let squared_length_line =
(self.end.x - self.start.x).pow(2) + (self.end.y - self.start.y).pow(2);
if dotproduct > squared_length_line {
return false;
}
true
}
fn is_ray_intersecting_segment(&self, point: &IVec2) -> bool {
if point.y < self.start.y.min(self.end.y) || point.y > self.start.y.max(self.end.y) {
return false;
}
if self.end.y == self.start.y {
return false;
}
let intersection_x = self.start.x
+ (point.y - self.start.y) * (self.end.x - self.start.x) / (self.end.y - self.start.y);
intersection_x >= point.x
}
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
feature = "bevy_reflect",
derive(Reflect),
reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct Region {
pub display_name: String,
display_name_residual_bytes: Option<Vec<u8>>,
pub flags: RegionFlags,
pub position: IVec2,
pub line_segments: Vec<LineSegment>,
}
impl Region {
pub fn is_deployment_zone(&self) -> bool {
self.flags.contains(RegionFlags::ARMY1_DEPLOYMENT_ZONE)
|| self.flags.contains(RegionFlags::ARMY2_DEPLOYMENT_ZONE)
}
pub fn is_army1_deployment_zone(&self) -> bool {
self.flags.contains(RegionFlags::ARMY1_DEPLOYMENT_ZONE)
}
pub fn is_army2_deployment_zone(&self) -> bool {
self.flags.contains(RegionFlags::ARMY2_DEPLOYMENT_ZONE)
}
pub fn is_point_contained(&self, point: IVec2) -> bool {
let mut intersections = 0;
for line in &self.line_segments {
if line.is_point_on_line_segment(&point) {
return true;
}
if line.is_ray_intersecting_segment(&point) {
intersections += 1;
}
}
intersections % 2 == 1
}
#[inline]
pub fn world_position(&self) -> Vec2 {
Vec2::new(
self.position.x as f32 / SCALE,
self.position.y as f32 / SCALE,
)
}
}
bitflags! {
#[repr(transparent)]
#[derive(Clone, Copy, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(opaque), reflect(Default, Deserialize, Hash, PartialEq, Serialize))]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct RegionFlags: u32 {
const NONE = 0;
const ACTIVE = 1 << 0;
const CLOSED = 1 << 1;
const OPEN = 1 << 2;
const UNKNOWN_FLAG_2 = 1 << 3;
const BOUNDARY_REVERSED = 1 << 4;
const BATTLE_BOUNDARY = 1 << 5;
const UNKNOWN_FLAG_3 = 1 << 6;
const BOUNDARY = 1 << 7;
const ARMY1_DEPLOYMENT_ZONE = 1 << 8;
const ARMY2_DEPLOYMENT_ZONE = 1 << 9;
const VISIBLE_AREA = 1 << 10;
const UNKNOWN_FLAG_4 = 1 << 11;
const UNKNOWN_FLAG_5 = 1 << 12;
const UNKNOWN_FLAG_6 = 1 << 13;
const UNKNOWN_FLAG_7 = 1 << 14;
const UNKNOWN_FLAG_8 = 1 << 15;
}
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
feature = "bevy_reflect",
derive(Reflect),
reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct Node {
pub flags: NodeFlags,
pub position: IVec2,
pub radius: u32,
pub rotation: i32,
pub node_id: u32,
pub regiment_id: u32,
pub script_id: u32,
}
impl Node {
#[inline]
pub fn is_waypoint(&self) -> bool {
self.flags.contains(NodeFlags::WAYPOINT)
}
#[inline]
pub fn world_position(&self) -> Vec2 {
Vec2::new(
self.position.x as f32 / SCALE,
self.position.y as f32 / SCALE,
)
}
#[inline]
pub fn world_radius(&self) -> f32 {
self.radius as f32 / SCALE
}
#[inline]
pub fn rotation_radians(&self) -> f32 {
(self.rotation as f32 / 512.0) * std::f32::consts::TAU
}
#[inline]
pub fn rotation_degrees(&self) -> f32 {
self.rotation_radians().to_degrees()
}
}
bitflags! {
#[repr(transparent)]
#[derive(Clone, Copy, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(opaque), reflect(Default, Deserialize, Hash, PartialEq, Serialize))]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct NodeFlags: u32 {
const NONE = 0;
const ACTIVE = 1 << 0;
const REGIMENT = 1 << 1;
const WAYPOINT = 1 << 2;
const UNKNOWN_FLAG_1 = 1 << 3;
const UNKNOWN_FLAG_2 = 1 << 4;
const UNKNOWN_FLAG_3 = 1 << 5;
const UNKNOWN_FLAG_4 = 1 << 6;
const UNKNOWN_FLAG_5 = 1 << 7;
const UNKNOWN_FLAG_6 = 1 << 8;
const UNKNOWN_FLAG_7 = 1 << 9;
const UNKNOWN_FLAG_8 = 1 << 10;
const UNKNOWN_FLAG_9 = 1 << 11;
const UNKNOWN_FLAG_10 = 1 << 12;
const UNKNOWN_FLAG_11 = 1 << 13;
const UNKNOWN_FLAG_12 = 1 << 14;
const UNKNOWN_FLAG_13 = 1 << 15;
}
}
#[cfg(test)]
mod tests {
use std::{
ffi::{OsStr, OsString},
fs::File,
path::{Path, PathBuf},
};
use image::{DynamicImage, Rgba};
use imageproc::{drawing::draw_hollow_rect_mut, rect::Rect};
use pretty_assertions::assert_eq;
use crate::project::{self, Project};
use super::*;
#[test]
fn test_region_is_point_contained() {
let region = Region {
line_segments: vec![
LineSegment {
start: IVec2::new(0, 0),
end: IVec2::new(10, 0),
},
LineSegment {
start: IVec2::new(10, 0),
end: IVec2::new(10, 10),
},
LineSegment {
start: IVec2::new(10, 10),
end: IVec2::new(0, 10),
},
LineSegment {
start: IVec2::new(0, 10),
end: IVec2::new(0, 0),
},
],
..Default::default()
};
assert!(region.is_point_contained(IVec2::new(5, 5)));
assert!(region.is_point_contained(IVec2::new(0, 0)));
assert!(region.is_point_contained(IVec2::new(10, 0)));
assert!(region.is_point_contained(IVec2::new(10, 10)));
assert!(region.is_point_contained(IVec2::new(0, 10)));
assert!(!region.is_point_contained(IVec2::new(11, 0)));
assert!(!region.is_point_contained(IVec2::new(0, 11)));
assert!(!region.is_point_contained(IVec2::new(11, 11)));
}
#[test]
fn test_node_rotation() {
let node = Node {
rotation: 0, ..Default::default()
};
assert_eq!(node.rotation_radians(), 0.);
assert_eq!(node.rotation_degrees(), 0.);
let node = Node {
rotation: 256, ..Default::default()
};
assert_eq!(node.rotation_radians(), std::f32::consts::PI);
assert_eq!(node.rotation_degrees(), 180.);
let node = Node {
rotation: 128, ..Default::default()
};
assert_eq!(node.rotation_radians(), std::f32::consts::PI / 2.);
assert_eq!(node.rotation_degrees(), 90.);
let node = Node {
rotation: 384, ..Default::default()
};
assert_eq!(node.rotation_radians(), std::f32::consts::PI * 1.5);
assert_eq!(node.rotation_degrees(), 270.);
}
fn roundtrip_test(original_bytes: &[u8], b: &BattleTabletop) {
let mut encoded_bytes = Vec::new();
Encoder::new(&mut encoded_bytes).encode(b).unwrap();
let original_bytes = original_bytes
.chunks(16)
.map(|chunk| {
chunk
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.join("\n");
let encoded_bytes = encoded_bytes
.chunks(16)
.map(|chunk| {
chunk
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.join("\n");
assert_eq!(original_bytes, encoded_bytes);
}
#[test]
fn test_decode_b1_01() {
let d: PathBuf = [
std::env::var("DARKOMEN_PATH").unwrap().as_str(),
"DARKOMEN",
"GAMEDATA",
"1PBAT",
"B1_01",
"B1_01.BTB",
]
.iter()
.collect();
let original_bytes = std::fs::read(d.clone()).unwrap();
let file = File::open(d).unwrap();
let b = Decoder::new(file).decode().unwrap();
assert_eq!(b.width, 1440);
assert_eq!(b.height, 1600);
assert_eq!(b.army1_file_stem, "B101mrc");
assert_eq!(b.army2_file_stem, "B101nme");
assert_eq!(b.ctl_file_stem, "B101");
const EPSILON: f32 = 0.0001;
assert!(b.obstacles[0]
.world_position()
.abs_diff_eq(Vec2::new(138.625, 47.5), EPSILON));
assert!((b.obstacles[0].world_radius() - 7.875).abs() < EPSILON);
assert!(b.obstacles[5]
.world_position()
.abs_diff_eq(Vec2::new(-0.75, 161.0), EPSILON));
assert!(b.nodes[0]
.world_position()
.abs_diff_eq(Vec2::new(151.25, 119.625), EPSILON));
assert!((b.nodes[0].world_radius() - 6.0).abs() < EPSILON);
assert!((b.nodes[0].rotation_degrees() - 182.10938).abs() < EPSILON);
assert_eq!(b.nodes[0].regiment_id, 131);
roundtrip_test(&original_bytes, &b);
}
#[test]
fn test_decode_all() {
let d: PathBuf = [
std::env::var("DARKOMEN_PATH").unwrap().as_str(),
"DARKOMEN",
"GAMEDATA",
]
.iter()
.collect();
let root_output_dir: PathBuf = [env!("CARGO_MANIFEST_DIR"), "decoded", "btbs"]
.iter()
.collect();
std::fs::create_dir_all(&root_output_dir).unwrap();
fn visit_dirs(dir: &Path, cb: &mut dyn FnMut(&Path)) {
println!("Reading dir {:?}", dir.display());
let mut paths = std::fs::read_dir(dir)
.unwrap()
.map(|res| res.map(|e| e.path()))
.collect::<Result<Vec<_>, std::io::Error>>()
.unwrap();
paths.sort();
for path in paths {
if path.is_dir() {
visit_dirs(&path, cb);
} else {
cb(&path);
}
}
}
visit_dirs(&d, &mut |path| {
let Some(ext) = path.extension() else {
return;
};
if ext.to_string_lossy().to_uppercase() != "BTB" {
return;
}
println!("Decoding {:?}", path.file_name().unwrap());
let parent_dir = path
.components()
.collect::<Vec<_>>()
.iter()
.rev()
.skip(1) .take_while(|c| c.as_os_str() != "DARKOMEN")
.collect::<Vec<_>>()
.iter()
.rev()
.collect::<PathBuf>();
let output_dir = root_output_dir.join(parent_dir);
std::fs::create_dir_all(&output_dir).unwrap();
let original_bytes = std::fs::read(path).unwrap();
let file = File::open(path).unwrap();
let b = Decoder::new(file).decode().unwrap();
assert_eq!(b.width % 8, 0);
assert_eq!(b.height % 8, 0);
roundtrip_test(&original_bytes, &b);
let project_file = File::open(path.with_extension("PRJ"));
if let Ok(project_file) = project_file {
let p = project::Decoder::new(project_file).decode().unwrap();
assert!(b.width / 8 <= p.attributes.width);
assert!(b.height / 8 <= p.attributes.height);
let img = overlay_battle_tabletop_on_terrain(&p, &b);
img.save(
output_dir
.join(path.file_stem().unwrap())
.with_extension("overlay.png"),
)
.unwrap();
}
for id in [
ELIMINATE_ALL_ENEMIES_ID,
CRITICAL_REGIMENT_LOSE_CONDITION_ID,
SPATIAL_SOUND_EFFECT_PRESET_ID,
INITIAL_REGIMENT_ORIENTATION_ID,
PLAYER_ELIMINATION_LOSE_CONDITION_ID,
] {
if path.file_name().unwrap() == "TMPBAT.BTB" {
continue;
}
if path.file_name().unwrap().to_str().unwrap().starts_with('M') {
continue;
}
if path.file_name().unwrap() == "SPARE9.BTB" {
continue;
}
assert!(
b.objectives.iter().any(|obj| obj.id == id),
"Battle tabletop {:?} is missing required objective ID: {}",
path.file_name().unwrap(),
id
);
}
for id in [
ELIMINATE_ALL_ENEMIES_ID,
SPATIAL_SOUND_EFFECT_PRESET_ID,
INITIAL_REGIMENT_ORIENTATION_ID,
PLAYER_ELIMINATION_LOSE_CONDITION_ID,
] {
if !path.file_name().unwrap().to_str().unwrap().starts_with('M') {
continue;
}
assert!(
b.objectives.iter().any(|obj| obj.id == id),
"Battle tabletop {:?} is missing required objective ID: {}",
path.file_name().unwrap(),
id
);
}
for o in &b.obstacles {
assert!(
o.flags.contains(ObstacleFlags::BLOCKS_MOVEMENT)
|| o.flags.contains(ObstacleFlags::BLOCKS_PROJECTILES)
);
assert!(o.flags.contains(ObstacleFlags::ACTIVE));
}
for region in &b.regions {
assert!(region.flags.contains(RegionFlags::ACTIVE));
}
for node in &b.nodes {
assert!(node.flags.contains(NodeFlags::ACTIVE));
if node.flags.contains(NodeFlags::REGIMENT)
&& path.file_name().unwrap() != "TMPBAT.BTB"
{
assert!(node.regiment_id > 0);
}
if node.flags.contains(NodeFlags::WAYPOINT) {
assert!(node.node_id > 0);
}
}
let output_path = append_ext("ron", output_dir.join(path.file_name().unwrap()));
let mut buffer = String::new();
ron::ser::to_writer_pretty(&mut buffer, &b, Default::default()).unwrap();
std::fs::write(output_path, buffer).unwrap();
});
}
#[test]
fn test_summarize_objectives() {
use std::collections::{BTreeMap, BTreeSet};
let d: PathBuf = [
std::env::var("DARKOMEN_PATH").unwrap().as_str(),
"DARKOMEN",
"GAMEDATA",
]
.iter()
.collect();
let mut objective_data: BTreeMap<i32, BTreeMap<String, BTreeSet<(i32, i32)>>> =
BTreeMap::new();
fn visit_dirs(dir: &Path, cb: &mut dyn FnMut(&Path)) {
let mut paths = std::fs::read_dir(dir)
.unwrap()
.map(|res| res.map(|e| e.path()))
.collect::<Result<Vec<_>, std::io::Error>>()
.unwrap();
paths.sort();
for path in paths {
if path.is_dir() {
visit_dirs(&path, cb);
} else {
cb(&path);
}
}
}
visit_dirs(&d, &mut |path| {
let Some(ext) = path.extension() else { return };
if ext.to_string_lossy().to_uppercase() != "BTB" {
return;
}
let file = File::open(path).unwrap();
let b = Decoder::new(file).decode().unwrap();
let file_name = path.file_name().unwrap().to_string_lossy().to_string();
for obj in &b.objectives {
objective_data
.entry(obj.id)
.or_default()
.entry(file_name.clone())
.or_default()
.insert((obj.value1, obj.value2));
}
});
println!("\n=== OBJECTIVE SUMMARY ===\n");
for (obj_id, files) in &objective_data {
let obj_name = match *obj_id {
ELIMINATE_ALL_ENEMIES_ID => "Eliminate all enemies",
KILL_DREAD_KING_ID => "Kill Dread King",
CRITICAL_REGIMENT_LOSE_CONDITION_ID => "Critical regiment lose condition",
SPATIAL_SOUND_EFFECT_PRESET_ID => "Spatial sound effect preset",
KILL_MANNFRED_VON_CARSTEIN_ID => "Kill Mannfred von Carstein",
FIREWORKS_ID => "Fireworks",
INITIAL_REGIMENT_ORIENTATION_ID => "Initial regiment orientation",
KILL_HAND_OF_NAGASH_ID => "Kill Hand of Nagash",
KILL_BLACK_GRAIL_ID => "Kill Black Grail",
GOLD_COLLECTION_METRIC_ID => "Gold collection metric",
ENEMY_CASUALTIES_ID => "Enemy casualties",
PLAYER_ELIMINATION_LOSE_CONDITION_ID => "Player elimination lose condition",
_ => "Unknown",
};
println!(
"Objective ID {}: {} (used in {} files)",
obj_id,
obj_name,
files.len()
);
let mut value_to_files: BTreeMap<BTreeSet<(i32, i32)>, Vec<String>> = BTreeMap::new();
for (file, values) in files {
value_to_files
.entry(values.clone())
.or_default()
.push(file.clone());
}
for (values, file_list) in &value_to_files {
let values_str = values
.iter()
.map(|(v1, v2)| format!("({}, {})", v1, v2))
.collect::<Vec<_>>()
.join(", ");
println!(" Values: {} - Files: {}", values_str, file_list.join(", "));
}
println!();
}
println!("\n=== OBJECTIVE FREQUENCY ===\n");
println!("{:<5} {:<45} {:<10}", "ID", "Name", "Count");
println!("{}", "-".repeat(60));
for (obj_id, files) in &objective_data {
let obj_name = match *obj_id {
ELIMINATE_ALL_ENEMIES_ID => "Eliminate all enemies",
KILL_DREAD_KING_ID => "Kill Dread King",
CRITICAL_REGIMENT_LOSE_CONDITION_ID => "Critical regiment lose condition",
SPATIAL_SOUND_EFFECT_PRESET_ID => "Spatial sound effect preset",
KILL_MANNFRED_VON_CARSTEIN_ID => "Kill Mannfred von Carstein",
FIREWORKS_ID => "Fireworks",
INITIAL_REGIMENT_ORIENTATION_ID => "Initial regiment orientation",
KILL_HAND_OF_NAGASH_ID => "Kill Hand of Nagash",
KILL_BLACK_GRAIL_ID => "Kill Black Grail",
GOLD_COLLECTION_METRIC_ID => "Gold collection metric",
ENEMY_CASUALTIES_ID => "Enemy casualties",
PLAYER_ELIMINATION_LOSE_CONDITION_ID => "Player elimination lose condition",
_ => "Unknown",
};
println!("{:<5} {:<45} {:<10}", obj_id, obj_name, files.len());
}
}
fn overlay_battle_tabletop_on_terrain(p: &Project, b: &BattleTabletop) -> DynamicImage {
let img = p.terrain.furniture_heightmap_image();
let mut img_buffer = img.to_rgba8();
for pixel in img_buffer.pixels_mut() {
let (r, g, b, a) = (255 - pixel[0], 255 - pixel[1], 255 - pixel[2], pixel[3]); *pixel = Rgba([r, g, b, a]);
}
let start_x = img_buffer.width() as i32 - (b.width / 8) as i32;
let start_y = 0;
let rect = Rect::at(start_x, start_y).of_size(b.width / 8, b.height / 8);
draw_hollow_rect_mut(&mut img_buffer, rect, Rgba([255, 0, 0, 255]));
let img_buffer = image::imageops::rotate180(&img_buffer);
DynamicImage::ImageRgba8(img_buffer)
}
fn append_ext(ext: impl AsRef<OsStr>, path: PathBuf) -> PathBuf {
let mut os_string: OsString = path.into();
os_string.push(".");
os_string.push(ext.as_ref());
os_string.into()
}
}