├── .gitattributes ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── fonts ├── icons.ttf └── tf2-classicons.ttf ├── rustfmt.toml ├── screenshot.png └── src ├── chat.rs ├── demo_player.rs ├── demo_player └── weapons.rs ├── demostf.rs ├── filters.rs ├── gui_filters.rs ├── heatmap.rs ├── heatmap_analyser.rs ├── lib.rs ├── main.rs └── style.rs /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /test_files 3 | /.vscode -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "coldmaps" 3 | version = "0.4.3" 4 | authors = ["Tails8521 "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | tf-demo-parser = "0.4.0" 9 | serde = { version = "1.0.114", features = ["derive"] } 10 | serde_json = "1.0.56" 11 | num_enum = "0.5.0" 12 | image = "0.24.3" 13 | palette = "0.6.1" 14 | iced = { version = "0.4.2", features = ["image", "tokio"] } 15 | iced_native = "0.5.1" 16 | tokio = "1.20.1" 17 | rayon = "1.4.0" 18 | rfd = "0.10.0" 19 | line_drawing = "1.0.0" 20 | enum_dispatch = "0.3.3" 21 | reqwest = { version = "0.11.11", features = ["json"] } 22 | fnv = "1.0.7" 23 | 24 | [profile.release] 25 | # lto = true 26 | # codegen-units = 1 27 | 28 | [profile.dev.package.tf-demo-parser] 29 | opt-level = 2 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Tails8521 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # coldmaps 2 | 3 | A tool for creating heatmaps from Team Fortress 2 demos 4 | ![Screenshot](/screenshot.png) 5 | 6 | # Tutorial video (click on the thumbnail to play) 7 | [![Video thumbnail](https://i3.ytimg.com/vi/p-pbByda4Io/maxresdefault.jpg)](https://www.youtube.com/watch?v=p-pbByda4Io) 8 | 9 | # Download 10 | 11 | Check the [releases page](https://github.com/Tails8521/coldmaps/releases) and download the latest version 12 | 13 | # How to use 14 | 15 | 1: Create a level overview screenshot, if you don't know how to, the video tutorial linked above explains it, the program isn't picky with file formats for screenshots: png, jpg or even tga are supported 16 | 2: Take note of the coordinates of the camera at the moment you took the screenshot (x, y and the cl_leveloverview zoom level), there are two different ways: cl_showpos 1 or the values displayed in the console when you use cl_leveloverview, note that these coordinates are different but the program can understand either of them. 17 | Tip: You can use setpos \ \ \ to position yourself accurately 18 | 3: Drag and drop the screenshot over the program's window 19 | 4: Drag and drop the demo(s) you want to use for the heatmap 20 | 5: Fill the camera coordinates and zoom level, don't forget to tick the checkbox corresponding to what type of coordinates you used (cl_showpos or the console) 21 | 6: The "Export image" button lets you export the heatmap as an image file 22 | 23 | # How to build 24 | 25 | (This step is only needed if you want to build from source, if you're on Windows you can simply download a pre-built exe from the [releases page](https://github.com/Tails8521/coldmaps/releases)) 26 | Download and install [Rust](https://www.rust-lang.org/learn/get-started) then `cargo build` or `cargo build --release` 27 | -------------------------------------------------------------------------------- /fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tails8521/coldmaps/b79a744b44433f3cf34e832896dfd8fe1a96c9a1/fonts/icons.ttf -------------------------------------------------------------------------------- /fonts/tf2-classicons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tails8521/coldmaps/b79a744b44433f3cf34e832896dfd8fe1a96c9a1/fonts/tf2-classicons.ttf -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 180 -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tails8521/coldmaps/b79a744b44433f3cf34e832896dfd8fe1a96c9a1/screenshot.png -------------------------------------------------------------------------------- /src/chat.rs: -------------------------------------------------------------------------------- 1 | use coldmaps::heatmap_analyser::HeatmapAnalysis; 2 | 3 | pub fn format_chat_messages(analysis: &HeatmapAnalysis) -> Vec { 4 | let interval_per_tick = analysis.interval_per_tick; 5 | analysis 6 | .chat 7 | .iter() 8 | .filter_map(|message| { 9 | let total_seconds = (message.tick as f32 * interval_per_tick) as u32; 10 | let minutes = total_seconds / 60; 11 | let seconds = total_seconds % 60; 12 | match message.kind { 13 | tf_demo_parser::demo::message::usermessage::ChatMessageKind::ChatAll => Some(format!("[{:02}:{:02}] {}: {}", minutes, seconds, message.from, message.text,)), 14 | tf_demo_parser::demo::message::usermessage::ChatMessageKind::ChatTeam => { 15 | Some(format!("[{:02}:{:02}] (TEAM) {}: {}", minutes, seconds, message.from, message.text,)) 16 | } 17 | tf_demo_parser::demo::message::usermessage::ChatMessageKind::ChatAllDead => { 18 | Some(format!("[{:02}:{:02}] *DEAD* {}: {}", minutes, seconds, message.from, message.text,)) 19 | } 20 | tf_demo_parser::demo::message::usermessage::ChatMessageKind::ChatTeamDead => { 21 | Some(format!("[{:02}:{:02}] *DEAD*(TEAM) {}: {}", minutes, seconds, message.from, message.text,)) 22 | } 23 | tf_demo_parser::demo::message::usermessage::ChatMessageKind::ChatAllSpec => { 24 | Some(format!("[{:02}:{:02}] *SPEC* {}: {}", minutes, seconds, message.from, message.text,)) 25 | } 26 | tf_demo_parser::demo::message::usermessage::ChatMessageKind::NameChange => None, 27 | tf_demo_parser::demo::message::usermessage::ChatMessageKind::Empty => None, 28 | } 29 | }) 30 | .collect() 31 | } 32 | -------------------------------------------------------------------------------- /src/demo_player.rs: -------------------------------------------------------------------------------- 1 | mod weapons; 2 | 3 | use fnv::FnvHashMap; 4 | use io::{BufRead, BufReader, BufWriter, LineWriter, StdoutLock}; 5 | use std::{ 6 | borrow::Cow, 7 | collections::BTreeMap, 8 | convert::TryFrom, 9 | error::Error, 10 | fs, 11 | io::{self, Write}, 12 | path::PathBuf, 13 | }; 14 | use std::{num::NonZeroU32, str::FromStr}; 15 | 16 | use coldmaps::heatmap_analyser::{handle_to_entity_index, Class, HeatmapAnalyser, HeatmapAnalysis, PlayerState, Spawn, Team, UserId, UserInfo}; 17 | use serde::Serialize; 18 | use std::borrow::Borrow; 19 | use tf_demo_parser::{ 20 | demo::gamevent::GameEvent, 21 | demo::header::Header, 22 | demo::message::packetentities::EntityId, 23 | demo::message::packetentities::PacketEntity, 24 | demo::message::Message, 25 | demo::packet::datatable::ServerClassName, 26 | demo::packet::datatable::{ParseSendTable, SendTableName}, 27 | demo::packet::stringtable::StringTableEntry, 28 | demo::parser::handler::BorrowMessageHandler, 29 | demo::parser::DemoTicker, 30 | demo::parser::MessageHandler, 31 | demo::vector::Vector, 32 | demo::vector::VectorXY, 33 | demo::{message::packetentities::PVS, sendprop::SendPropIdentifier}, 34 | demo::{packet::datatable::ServerClass, sendprop::SendPropName}, 35 | Demo, DemoParser, MessageType, ParseError, ParserState, ReadResult, Stream, 36 | }; 37 | use weapons::Weapon; 38 | 39 | const SECTION_SIZE: usize = 1024; 40 | const SHOW_UNKNOWN_ENTITIES: bool = true; 41 | 42 | #[derive(Debug, Serialize)] 43 | enum Command { 44 | Load(PathBuf), 45 | Tick(usize), 46 | Frame(usize), 47 | TickToFrame, 48 | FrameToTick, 49 | Analysis, 50 | Prefetch(bool), 51 | DumpUnknown(usize), 52 | } 53 | 54 | impl Command { 55 | fn from_str(str: &str) -> Option { 56 | if str == "analysis" { 57 | return Some(Self::Analysis); 58 | } 59 | if str == "frametotick" { 60 | return Some(Self::FrameToTick); 61 | } 62 | if str == "ticktoframe" { 63 | return Some(Self::TickToFrame); 64 | } 65 | if let [command, arg] = str.splitn(2, ' ').collect::>().as_slice() { 66 | if *command == "frame" { 67 | if let Ok(frame) = arg.parse() { 68 | return Some(Self::Frame(frame)); 69 | } 70 | } 71 | if *command == "tick" { 72 | if let Ok(tick) = arg.parse() { 73 | return Some(Self::Tick(tick)); 74 | } 75 | } 76 | if *command == "load" { 77 | return Some(Self::Load(PathBuf::from(arg))); 78 | } 79 | if *command == "prefetch" { 80 | if let Ok(prefetch) = arg.parse() { 81 | return Some(Self::Prefetch(prefetch)); 82 | } 83 | } 84 | if command.starts_with("dump") { 85 | if let Ok(frame) = arg.parse() { 86 | return Some(Self::DumpUnknown(frame)); 87 | } 88 | } 89 | } 90 | None 91 | } 92 | } 93 | 94 | #[derive(Debug, Serialize)] 95 | struct LoadOutput<'a> { 96 | server: &'a str, 97 | nick: &'a str, 98 | map: &'a str, 99 | duration: f32, 100 | ticks: u32, 101 | frames: u32, 102 | is_corrupted: bool, 103 | } 104 | 105 | #[derive(Debug, Serialize)] 106 | struct TickOutput<'a> { 107 | state: &'a HeatmapAnalysis, 108 | ticks_left: bool, 109 | } 110 | 111 | #[derive(Debug, Serialize)] 112 | struct Output { 113 | result: Option, 114 | error: Option>, 115 | } 116 | 117 | struct OutputWriter<'a>(LineWriter>>); 118 | 119 | impl OutputWriter<'_> { 120 | fn write_result(&mut self, result: T) -> Result<(), Box> { 121 | let output = Output { 122 | result: Some(result), 123 | error: None, 124 | }; 125 | serde_json::ser::to_writer(&mut self.0, &output)?; 126 | self.0.write_all(&[b'\n'])?; 127 | Ok(()) 128 | } 129 | 130 | fn write_error(&mut self, error: Cow<'static, str>) -> Result<(), Box> { 131 | let output: Output<()> = Output { result: None, error: Some(error) }; 132 | serde_json::ser::to_writer(&mut self.0, &output)?; 133 | self.0.write_all(&[b'\n'])?; 134 | Ok(()) 135 | } 136 | 137 | fn write_text(&mut self, input: &str) -> Result<(), Box> { 138 | self.0.write_all(input.as_bytes())?; 139 | self.0.write_all(&[b'\n'])?; 140 | Ok(()) 141 | } 142 | } 143 | 144 | fn serialize(input: T) -> String { 145 | let output = Output { result: Some(input), error: None }; 146 | serde_json::to_string(&output).unwrap() 147 | } 148 | 149 | struct BufferSection<'a> { 150 | ticker: DemoTicker<'a, DemoAnalyzer>, 151 | playback_ticker: Option>, 152 | cached_frames: Vec, 153 | first_frame: String, 154 | } 155 | 156 | struct BufferedPlayer<'a> { 157 | sections: Vec>, 158 | playhead_position: usize, 159 | last_frame: usize, 160 | } 161 | 162 | impl<'a> BufferedPlayer<'a> { 163 | fn get_frame(&mut self, playhead_position: usize) -> &str { 164 | self.playhead_position = playhead_position; 165 | let section_idx = playhead_position / SECTION_SIZE; 166 | let frame_idx = playhead_position % SECTION_SIZE; 167 | let section = &mut self.sections[section_idx]; 168 | if frame_idx == 0 { 169 | return §ion.first_frame; 170 | } 171 | if section.playback_ticker.is_none() { 172 | section.playback_ticker = Some(section.ticker.clone()); 173 | section.cached_frames.reserve_exact(SECTION_SIZE - 1); 174 | } 175 | let playback_ticker = section.playback_ticker.as_mut().unwrap(); 176 | while section.cached_frames.len() < frame_idx { 177 | playback_ticker.tick().unwrap_or_default(); 178 | section.cached_frames.push(serialize(playback_ticker.state())); 179 | } 180 | §ion.cached_frames[frame_idx - 1] 181 | } 182 | 183 | fn prefetch(&mut self) { 184 | // For continuous playback to be smooth, we need to buffer at least 1 frame forward and SECTION_SIZE frames backwards for backwards playback. 185 | // This is because by the time we reach frame 0 in the current section, we need the previous section to have its last frame cached 186 | // so we can play it immediately afterwards. 187 | let section_idx = self.playhead_position / SECTION_SIZE; 188 | let frame_idx = self.playhead_position % SECTION_SIZE; 189 | let section = &mut self.sections[section_idx]; 190 | // expand forward 191 | if self.playhead_position != self.last_frame 192 | // if the next frame is in the next section, do nothing because the 1st frame of each section is always there 193 | && frame_idx + 1 < SECTION_SIZE 194 | // if the next frame is already cached we do nothing 195 | && section.cached_frames.len() < frame_idx + 1 196 | { 197 | if section.playback_ticker.is_none() { 198 | section.playback_ticker = Some(section.ticker.clone()); 199 | section.cached_frames.reserve_exact(SECTION_SIZE - 1); 200 | } 201 | let playback_ticker = section.playback_ticker.as_mut().unwrap(); 202 | playback_ticker.tick().unwrap_or_default(); 203 | section.cached_frames.push(serialize(playback_ticker.state())); 204 | } 205 | // expand backward 206 | if section_idx > 0 { 207 | let previous_section = &mut self.sections[section_idx - 1]; 208 | while previous_section.cached_frames.len() < SECTION_SIZE - frame_idx - 1 { 209 | if previous_section.playback_ticker.is_none() { 210 | previous_section.playback_ticker = Some(previous_section.ticker.clone()); 211 | previous_section.cached_frames.reserve_exact(SECTION_SIZE - 1); 212 | } 213 | let previous_playback_ticker = previous_section.playback_ticker.as_mut().unwrap(); 214 | previous_playback_ticker.tick().unwrap_or_default(); 215 | previous_section.cached_frames.push(serialize(previous_playback_ticker.state())); 216 | } 217 | } 218 | } 219 | 220 | fn evict(&mut self) { 221 | let section_idx = self.playhead_position / SECTION_SIZE; 222 | // discard cached sections that are far away 223 | self.sections 224 | .iter_mut() 225 | .enumerate() 226 | .filter(|(idx, section)| ((*idx as isize) < section_idx as isize - 2 || (*idx as isize) > section_idx as isize + 2) && !section.cached_frames.is_empty()) 227 | .for_each(|(_idx, far_section)| { 228 | far_section.cached_frames.clear(); 229 | far_section.cached_frames.shrink_to_fit(); 230 | far_section.playback_ticker = None; 231 | }); 232 | } 233 | } 234 | 235 | struct DemoPlayerState<'a> { 236 | is_corrupted: bool, 237 | frame_to_tick: Vec, 238 | tick_to_frame: Vec, 239 | final_state: String, 240 | demo_header: Header, 241 | player: BufferedPlayer<'a>, 242 | } 243 | 244 | impl<'a> DemoPlayerState<'a> { 245 | fn new(demo: Demo<'a>) -> Result { 246 | let (demo_header, mut ticker) = DemoParser::new_with_analyser(demo.get_stream(), DemoAnalyzer::default()).ticker()?; 247 | let mut frame_to_tick = Vec::with_capacity(demo_header.frames as usize + 6); 248 | let mut tick_to_frame = Vec::new(); 249 | let mut player = BufferedPlayer { 250 | sections: Vec::with_capacity((demo_header.frames as usize + 6) / SECTION_SIZE + 1), 251 | playhead_position: 0, 252 | last_frame: 0, 253 | }; 254 | let is_corrupted = loop { 255 | match ticker.tick() { 256 | Ok(true) => { 257 | let current_tick = ticker.state().current_tick; 258 | if current_tick == 0 { 259 | // This seems to happen for 6 frames at the start of the demo 260 | // If we don't do this, demo_header.frames != frames.len() 261 | continue; 262 | } 263 | let current_frame_index = frame_to_tick.len(); 264 | frame_to_tick.push(current_tick); 265 | while tick_to_frame.len() <= current_tick as usize { 266 | tick_to_frame.push(current_frame_index); 267 | } 268 | if current_frame_index % SECTION_SIZE == 0 { 269 | player.sections.push(BufferSection { 270 | ticker: ticker.clone(), 271 | playback_ticker: None, 272 | cached_frames: Vec::new(), 273 | first_frame: serialize(ticker.state()), 274 | }); 275 | } 276 | } 277 | Ok(false) => { 278 | break false; 279 | } 280 | Err(_err) => { 281 | break true; 282 | } 283 | }; 284 | }; 285 | if demo_header.frames != 0 && demo_header.frames as usize != frame_to_tick.len() { 286 | eprintln!("Expected {} frames in the demo, got {}", demo_header.frames, frame_to_tick.len()); 287 | } 288 | player.last_frame = frame_to_tick.len() - 1; 289 | 290 | // Our final state should be from a HeatmapAnalyser as it contains more useful data (deaths, chat etc.) 291 | let (_demo_header, mut heatmap_ticker) = DemoParser::new_with_analyser(demo.get_stream(), HeatmapAnalyser::default()).ticker()?; 292 | loop { 293 | match heatmap_ticker.tick() { 294 | Ok(true) => (), 295 | Ok(false) => break, 296 | Err(_err) => break, 297 | } 298 | } 299 | let final_state = serialize(heatmap_ticker.state()); 300 | Ok(Self { 301 | is_corrupted, 302 | frame_to_tick, 303 | tick_to_frame, 304 | final_state, 305 | demo_header, 306 | player, 307 | }) 308 | } 309 | } 310 | 311 | pub(crate) fn run() -> Result<(), Box> { 312 | let stdin = io::stdin(); 313 | let stdin_handle = stdin.lock(); 314 | let input = BufReader::new(stdin_handle); 315 | let mut lines = input.lines(); 316 | let stdout = io::stdout(); 317 | let stdout_handle = stdout.lock(); 318 | let output = BufWriter::new(stdout_handle); 319 | let mut output_writer = OutputWriter(LineWriter::new(output)); 320 | 321 | let mut demo_player_state = None; 322 | let mut prefetch = true; 323 | 324 | while let Some(line) = lines.next() { 325 | let line = line?; 326 | if let Some(command) = Command::from_str(&line) { 327 | match command { 328 | Command::Load(path) => match fs::read(path) { 329 | Ok(file) => { 330 | let demo = Demo::owned(file); 331 | match DemoPlayerState::new(demo) { 332 | Ok(new_state) => { 333 | let load_output = LoadOutput { 334 | server: &new_state.demo_header.server, 335 | nick: &new_state.demo_header.nick, 336 | map: &new_state.demo_header.map, 337 | duration: new_state.demo_header.duration, 338 | ticks: new_state.demo_header.ticks, 339 | frames: new_state.demo_header.frames, 340 | is_corrupted: new_state.is_corrupted, 341 | }; 342 | output_writer.write_result(&load_output)?; 343 | demo_player_state = Some(new_state); 344 | } 345 | Err(err) => { 346 | output_writer.write_error(err.to_string().into())?; 347 | } 348 | } 349 | } 350 | Err(err) => { 351 | output_writer.write_error(err.to_string().into())?; 352 | } 353 | }, 354 | Command::Frame(frame) => { 355 | if let Some(DemoPlayerState { player, frame_to_tick, .. }) = demo_player_state.as_mut() { 356 | if frame < frame_to_tick.len() { 357 | let state = player.get_frame(frame); 358 | output_writer.write_text(state)?; 359 | if prefetch { 360 | player.prefetch(); 361 | } 362 | player.evict(); 363 | } else { 364 | output_writer.write_error("Seeking to a frame out of bound".into())?; 365 | } 366 | } else { 367 | output_writer.write_error("No demo loaded".into())?; 368 | } 369 | } 370 | Command::Tick(tick) => { 371 | if let Some(DemoPlayerState { player, tick_to_frame, .. }) = demo_player_state.as_mut() { 372 | if let Some(&frame) = tick_to_frame.get(tick) { 373 | let state = player.get_frame(frame); 374 | output_writer.write_text(state)?; 375 | if prefetch { 376 | player.prefetch(); 377 | } 378 | player.evict(); 379 | } else { 380 | output_writer.write_error("Seeking to a tick out of bound".into())?; 381 | } 382 | } else { 383 | output_writer.write_error("No demo loaded".into())?; 384 | } 385 | } 386 | Command::FrameToTick => { 387 | if let Some(DemoPlayerState { frame_to_tick, .. }) = demo_player_state.as_ref() { 388 | output_writer.write_result(frame_to_tick)?; 389 | } else { 390 | output_writer.write_error("No demo loaded".into())?; 391 | } 392 | } 393 | Command::TickToFrame => { 394 | if let Some(DemoPlayerState { tick_to_frame, .. }) = demo_player_state.as_ref() { 395 | output_writer.write_result(tick_to_frame)?; 396 | } else { 397 | output_writer.write_error("No demo loaded".into())?; 398 | } 399 | } 400 | Command::Analysis => { 401 | if let Some(DemoPlayerState { final_state, .. }) = demo_player_state.as_ref() { 402 | output_writer.write_text(final_state)?; 403 | } else { 404 | output_writer.write_error("No demo loaded".into())?; 405 | } 406 | } 407 | Command::Prefetch(new_prefetch) => { 408 | prefetch = new_prefetch; 409 | if prefetch { 410 | output_writer.write_result("Prefetching enabled")?; 411 | } else { 412 | output_writer.write_result("Prefetching disabled")?; 413 | } 414 | } 415 | Command::DumpUnknown(frame) => { 416 | if let Some(DemoPlayerState { player, frame_to_tick, .. }) = demo_player_state.as_mut() { 417 | if frame < frame_to_tick.len() { 418 | let section_idx = frame / SECTION_SIZE; 419 | let frame_idx = frame % SECTION_SIZE; 420 | let section = &mut player.sections[section_idx]; 421 | let mut ticker = section.ticker.clone(); 422 | for _ in 0..frame_idx { 423 | ticker.tick().unwrap_or_default(); 424 | } 425 | dbg!(&ticker.state().other_entities.iter().filter(|elm| elm.1.position.x != 0.0 && elm.1.position.y != 0.0 && elm.1.position.z != 0.0 426 | && elm.1.entity_content != EntityContent::Other { class_name: String::from("CTFWearable") } 427 | && elm.1.entity_content != EntityContent::Other { class_name: String::from("CTFRagdoll") } 428 | ).collect::>()); 429 | } else { 430 | output_writer.write_error("Seeking to a frame out of bound".into())?; 431 | } 432 | } else { 433 | output_writer.write_error("No demo loaded".into())?; 434 | } 435 | } 436 | } 437 | } else { 438 | output_writer.write_error(format!("Can't parse command: \"{}\"", &line).into())?; 439 | } 440 | } 441 | Ok(()) 442 | } 443 | 444 | #[derive(Debug, Clone, Serialize, PartialEq)] 445 | struct PlayerEntity { 446 | entity: EntityId, 447 | position: Vector, 448 | health: u16, 449 | max_health: u16, 450 | class: Class, 451 | team: Team, 452 | view_angle_horizontal: f32, 453 | view_angle_vertical: f32, 454 | state: PlayerState, 455 | active_weapon: Option, 456 | } 457 | 458 | #[derive(Clone, Copy, Debug, Default, Serialize, PartialEq)] 459 | struct ProjectileProperties { 460 | crit: bool, 461 | team: Team, 462 | } 463 | 464 | #[derive(Clone, Debug, Serialize, PartialEq)] 465 | #[serde(tag = "type")] 466 | enum EntityContent { 467 | Unknown, 468 | Other { 469 | class_name: String, 470 | }, 471 | Pipe(ProjectileProperties), 472 | Sticky(ProjectileProperties), 473 | Rocket(ProjectileProperties), 474 | TeamTrainWatcher { 475 | total_progress: f32, 476 | train_speed_level: i32, 477 | num_cappers: i32, 478 | recede_time: f32, 479 | }, 480 | Cart, 481 | Weapon { 482 | name: Weapon, 483 | id: i32, 484 | owner: Option, 485 | }, 486 | } 487 | 488 | impl Default for EntityContent { 489 | fn default() -> Self { 490 | Self::Unknown 491 | } 492 | } 493 | 494 | #[derive(Default, Clone, Debug, Serialize, PartialEq)] 495 | struct OtherEntity { 496 | entity_content: EntityContent, 497 | position: Vector, 498 | rotation: Vector, 499 | } 500 | 501 | #[derive(Default, Clone, Debug, Serialize, PartialEq)] 502 | struct DemoAnalysis { 503 | current_tick: u32, 504 | users: BTreeMap, 505 | player_entities: Vec, 506 | other_entities: BTreeMap, 507 | } 508 | 509 | impl DemoAnalysis { 510 | fn get_or_create_player_entity(&mut self, entity_id: EntityId) -> &mut PlayerEntity { 511 | let index = match self 512 | .player_entities 513 | .iter_mut() 514 | .enumerate() 515 | .find(|(_index, player)| player.entity == entity_id) 516 | .map(|(index, _)| index) 517 | { 518 | Some(index) => index, 519 | None => { 520 | let player = PlayerEntity { 521 | entity: entity_id, 522 | position: Vector::default(), 523 | health: 0, 524 | max_health: 0, 525 | class: Class::Other, 526 | team: Team::Other, 527 | view_angle_horizontal: 0.0, 528 | view_angle_vertical: 0.0, 529 | state: PlayerState::Alive, 530 | active_weapon: None, 531 | }; 532 | 533 | let index = self.player_entities.len(); 534 | self.player_entities.push(player); 535 | index 536 | } 537 | }; 538 | &mut self.player_entities[index] 539 | } 540 | } 541 | 542 | #[derive(Default, Clone, Debug, Serialize, PartialEq)] 543 | struct DemoAnalyzer { 544 | state: DemoAnalysis, 545 | prop_names: FnvHashMap, 546 | class_names: Vec, 547 | tick_offset: u32, 548 | } 549 | 550 | impl MessageHandler for DemoAnalyzer { 551 | type Output = DemoAnalysis; 552 | 553 | fn does_handle(message_type: MessageType) -> bool { 554 | match message_type { 555 | MessageType::GameEvent | MessageType::PacketEntities => true, 556 | _ => false, 557 | } 558 | } 559 | 560 | fn into_output(self, _state: &ParserState) -> Self::Output { 561 | self.state 562 | } 563 | 564 | fn handle_message(&mut self, message: &Message, tick: u32) { 565 | if self.tick_offset == 0 && tick != 0 { 566 | self.tick_offset = tick - 1; 567 | } 568 | self.state.current_tick = tick - self.tick_offset; // first tick = start of the demo rather than map change 569 | match message { 570 | Message::GameEvent(message) => self.handle_event(&message.event, tick), 571 | Message::PacketEntities(message) => { 572 | for entity in &message.entities { 573 | if entity.pvs == PVS::Delete { 574 | let removed_entity = entity.entity_index; 575 | self.state.player_entities.retain(|player_entity| player_entity.entity != removed_entity); 576 | let _removed = self.state.other_entities.remove(&removed_entity); 577 | } else { 578 | self.handle_entity(entity); 579 | } 580 | } 581 | for removed_entity in &message.removed_entities { 582 | self.state.player_entities.retain(|player_entity| player_entity.entity != *removed_entity); 583 | let _removed = self.state.other_entities.remove(removed_entity); 584 | } 585 | } 586 | _ => {} 587 | } 588 | } 589 | 590 | fn handle_string_entry(&mut self, table: &str, _index: usize, entry: &StringTableEntry) { 591 | match table { 592 | "userinfo" => { 593 | let _ = self.parse_user_info(entry.text.as_ref().map(|s| s.borrow()), entry.extra_data.as_ref().map(|data| data.data.clone())); 594 | } 595 | _ => {} 596 | } 597 | } 598 | 599 | fn handle_data_tables(&mut self, tables: &[ParseSendTable], server_classes: &[ServerClass]) { 600 | self.class_names = server_classes.iter().map(|class| &class.name).cloned().collect(); 601 | 602 | for table in tables { 603 | for prop_def in &table.props { 604 | self.prop_names.insert(prop_def.identifier(), (table.name.clone(), prop_def.name.clone())); 605 | } 606 | } 607 | } 608 | } 609 | 610 | impl BorrowMessageHandler for DemoAnalyzer { 611 | fn borrow_output(&self, _state: &ParserState) -> &Self::Output { 612 | &self.state 613 | } 614 | } 615 | 616 | impl DemoAnalyzer { 617 | fn handle_entity(&mut self, entity: &PacketEntity) { 618 | let class_name: &str = self.class_names.get(usize::from(entity.server_class)).map(|class_name| class_name.as_str()).unwrap_or(""); 619 | match class_name { 620 | "CTFPlayer" => self.handle_player_entity(entity), 621 | "CTFPlayerResource" => self.handle_player_resource(entity), 622 | "CTFGrenadePipebombProjectile" => self.handle_demo_projectile(entity), 623 | "CTFProjectile_Rocket" => self.handle_rocket(entity), 624 | "CTeamTrainWatcher" => self.handle_team_train_watcher(entity), 625 | "CFuncTrackTrain" => self.handle_func_track_train(entity), 626 | "CTFBat" 627 | | "CTFBat_Fish" 628 | | "CTFBat_Giftwrap" 629 | | "CTFBat_Wood" 630 | | "CTFBonesaw" 631 | | "CTFBottle" 632 | | "CTFBreakableMelee" 633 | | "CTFBreakableSign" 634 | | "CTFBuffItem" 635 | | "CTFCannon" 636 | | "CTFChargedSMG" 637 | | "CTFCleaver" 638 | | "CTFClub" 639 | | "CTFCompoundBow" 640 | | "CTFCrossbow" 641 | | "CTFDRGPomson" 642 | | "CTFFireAxe" 643 | | "CTFFists" 644 | | "CTFFlameThrower" 645 | | "CTFFlareGun" 646 | | "CTFFlareGun_Revenge" 647 | | "CTFGrenadeLauncher" 648 | | "CTFJar" 649 | | "CTFJarGas" 650 | | "CTFJarMilk" 651 | | "CTFKatana" 652 | | "CTFKnife" 653 | | "CTFLaserPointer" 654 | | "CTFLunchBox" 655 | | "CTFLunchBox_Drink" 656 | | "CTFMechanicalArm" 657 | | "CTFMinigun" 658 | | "CTFParachute" 659 | | "CTFParachute_Primary" 660 | | "CTFParachute_Secondary" 661 | | "CTFParticleCannon" 662 | | "CTFPEPBrawlerBlaster" 663 | | "CTFPipebombLauncher" 664 | | "CTFPistol" 665 | | "CTFPistol_Scout" 666 | | "CTFPistol_ScoutPrimary" 667 | | "CTFPistol_ScoutSecondary" 668 | | "CTFRevolver" 669 | | "CTFRobotArm" 670 | | "CTFRocketLauncher" 671 | | "CTFRocketLauncher_AirStrike" 672 | | "CTFRocketLauncher_DirectHit" 673 | | "CTFRocketLauncher_Mortar" 674 | | "CTFRocketPack" 675 | | "CTFScatterGun" 676 | | "CTFShotgun" 677 | | "CTFShotgun_HWG" 678 | | "CTFShotgun_Pyro" 679 | | "CTFShotgun_Revenge" 680 | | "CTFShotgun_Soldier" 681 | | "CTFShotgunBuildingRescue" 682 | | "CTFShovel" 683 | | "CTFSlap" 684 | | "CTFSMG" 685 | | "CTFSniperRifle" 686 | | "CTFSniperRifleClassic" 687 | | "CTFSniperRifleDecap" 688 | | "CTFSodaPopper" 689 | | "CTFStickBomb" 690 | | "CTFSword" 691 | | "CTFSyringeGun" 692 | | "CTFWeaponBuilder" 693 | | "CTFWeaponPDA" 694 | | "CTFWeaponPDA_Engineer_Build" 695 | | "CTFWeaponPDA_Engineer_Destroy" 696 | | "CTFWeaponPDA_Spy" 697 | | "CTFWeaponSapper" 698 | | "CTFWearableDemoShield" 699 | | "CTFWearableRazorback" 700 | | "CTFWearableRobotArm" 701 | | "CTFWrench" => self.handle_weapon(entity), 702 | 703 | _ => { 704 | if SHOW_UNKNOWN_ENTITIES { 705 | let class_name = class_name.into(); 706 | self.handle_unknown_entity(entity, class_name); 707 | } 708 | } 709 | } 710 | } 711 | 712 | fn handle_player_resource(&mut self, entity: &PacketEntity) { 713 | for prop in entity.props() { 714 | if let Some((table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 715 | if let Ok(player_id) = u32::from_str(prop_name.as_str()) { 716 | let entity_id = EntityId::from(player_id); 717 | if let Some(player) = self.state.player_entities.iter_mut().find(|player| player.entity == entity_id) { 718 | match table_name.as_str() { 719 | "m_iTeam" => player.team = Team::new(i64::try_from(&prop.value).unwrap_or_default()), 720 | "m_iMaxHealth" => player.max_health = i64::try_from(&prop.value).unwrap_or_default() as u16, 721 | "m_iPlayerClass" => player.class = Class::new(i64::try_from(&prop.value).unwrap_or_default()), 722 | _ => {} 723 | } 724 | } 725 | } 726 | } 727 | } 728 | } 729 | 730 | fn handle_player_entity(&mut self, entity: &PacketEntity) { 731 | let player = self.state.get_or_create_player_entity(entity.entity_index); 732 | 733 | for prop in entity.props() { 734 | if let Some((table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 735 | match table_name.as_str() { 736 | "DT_BasePlayer" => match prop_name.as_str() { 737 | "m_iHealth" => player.health = i64::try_from(&prop.value).unwrap_or_default() as u16, 738 | "m_iMaxHealth" => player.max_health = i64::try_from(&prop.value).unwrap_or_default() as u16, 739 | "m_lifeState" => player.state = PlayerState::new(i64::try_from(&prop.value).unwrap_or_default()), 740 | // "m_fFlags" => { 741 | // match &prop.value { 742 | // tf_demo_parser::demo::sendprop::SendPropValue::Integer(x) => { 743 | // // TODO investigate, 1 = on ground, 2 = ducking, etc. 744 | // eprintln!("{}", x); 745 | // }, 746 | // _ => {} 747 | // } 748 | // } 749 | _ => {} 750 | }, 751 | "DT_TFLocalPlayerExclusive" | "DT_TFNonLocalPlayerExclusive" => match prop_name.as_str() { 752 | "m_vecOrigin" => { 753 | let pos_xy = VectorXY::try_from(&prop.value).unwrap_or_default(); 754 | player.position.x = pos_xy.x; 755 | player.position.y = pos_xy.y; 756 | } 757 | "m_vecOrigin[2]" => player.position.z = f32::try_from(&prop.value).unwrap_or_default(), 758 | "m_angEyeAngles[0]" => player.view_angle_vertical = f32::try_from(&prop.value).unwrap_or_default(), 759 | "m_angEyeAngles[1]" => player.view_angle_horizontal = f32::try_from(&prop.value).unwrap_or_default(), 760 | _ => {} 761 | }, 762 | "DT_BaseCombatCharacter" => match prop_name.as_str() { 763 | "m_hActiveWeapon" => player.active_weapon = handle_to_entity_index(i64::try_from(&prop.value).unwrap_or_default()), 764 | _ => {} 765 | }, 766 | _ => {} 767 | } 768 | } 769 | } 770 | } 771 | 772 | fn handle_unknown_entity(&mut self, entity: &PacketEntity, class_name: String) { 773 | let entry = self.state.other_entities.entry(entity.entity_index).or_insert_with(|| OtherEntity { ..Default::default() }); 774 | entry.entity_content = EntityContent::Other { class_name }; 775 | for prop in entity.props() { 776 | if let Some((_table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 777 | match prop_name.as_str() { 778 | "m_vecOrigin" => entry.position = Vector::try_from(&prop.value).unwrap_or_default(), 779 | "m_angRotation" => entry.rotation = Vector::try_from(&prop.value).unwrap_or_default(), 780 | _ => {} 781 | } 782 | // if prop_name.as_str() == "m_vecOrigin" || prop_name.as_str() == "m_angRotation" { 783 | // if let Ok(value) = Vector::try_from(&prop.value) { 784 | // if value.x == 0.0 && value.y == 0.0 && value.z == 0.0 {continue;} 785 | // dbg!(value); 786 | // dbg!(&self.state); 787 | // panic!(""); 788 | 789 | // } 790 | // } 791 | } 792 | } 793 | } 794 | 795 | fn handle_demo_projectile(&mut self, entity: &PacketEntity) { 796 | let entry = self.state.other_entities.entry(entity.entity_index).or_insert_with(|| OtherEntity { ..Default::default() }); 797 | let (mut itype, mut projectile_properties) = match entry.entity_content { 798 | EntityContent::Pipe(projectile_properties) => (0, projectile_properties), 799 | EntityContent::Sticky(projectile_properties) => (1, projectile_properties), 800 | _ => (-1, Default::default()), 801 | }; 802 | for prop in entity.props() { 803 | if let Some((_table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 804 | match prop_name.as_str() { 805 | "m_vecOrigin" => entry.position = Vector::try_from(&prop.value).unwrap_or_default(), 806 | "m_angRotation" => entry.rotation = Vector::try_from(&prop.value).unwrap_or_default(), 807 | "m_iType" => itype = i64::try_from(&prop.value).unwrap_or(-1), 808 | "m_bCritical" => projectile_properties.crit = i64::try_from(&prop.value).unwrap_or_default() != 0, 809 | "m_iTeamNum" => projectile_properties.team = Team::new(i64::try_from(&prop.value).unwrap_or_default()), 810 | // "m_hThrower" => eprintln!("Demo {}: {}", i64::try_from(&prop.value).unwrap_or_default() & 0b111_1111_1111, entity.entity_index), 811 | _ => {} 812 | } 813 | } 814 | } 815 | entry.entity_content = match itype { 816 | 0 => EntityContent::Pipe(projectile_properties), 817 | 1 => EntityContent::Sticky(projectile_properties), 818 | _ => EntityContent::Unknown, // TODO check for quickiebomb, scotres (DT_TFProjectile_Pipebomb::m_bDefensiveBomb?) etc. 819 | } 820 | } 821 | 822 | fn handle_rocket(&mut self, entity: &PacketEntity) { 823 | let entry = self.state.other_entities.entry(entity.entity_index).or_insert_with(|| OtherEntity { ..Default::default() }); 824 | let mut projectile_properties = if let EntityContent::Rocket(projectile_properties) = entry.entity_content { 825 | projectile_properties 826 | } else { 827 | Default::default() 828 | }; 829 | for prop in entity.props() { 830 | if let Some((_table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 831 | match prop_name.as_str() { 832 | "m_vecOrigin" => entry.position = Vector::try_from(&prop.value).unwrap_or_default(), 833 | "m_angRotation" => entry.rotation = Vector::try_from(&prop.value).unwrap_or_default(), 834 | "m_bCritical" => projectile_properties.crit = i64::try_from(&prop.value).unwrap_or_default() != 0, 835 | "m_iTeamNum" => projectile_properties.team = Team::new(i64::try_from(&prop.value).unwrap_or_default()), 836 | // "m_hOwnerEntity" => eprintln!("Soldier {}: {}", i64::try_from(&prop.value).unwrap_or_default() & 0b111_1111_1111, entity.entity_index), 837 | _ => {} 838 | } 839 | } 840 | } 841 | entry.entity_content = EntityContent::Rocket(projectile_properties); 842 | } 843 | 844 | fn handle_team_train_watcher(&mut self, entity: &PacketEntity) { 845 | let entry = self.state.other_entities.entry(entity.entity_index).or_insert_with(|| OtherEntity { ..Default::default() }); 846 | let (mut total_progress, mut train_speed_level, mut num_cappers, mut recede_time) = if let EntityContent::TeamTrainWatcher { 847 | total_progress, 848 | train_speed_level, 849 | num_cappers, 850 | recede_time, 851 | } = entry.entity_content 852 | { 853 | (total_progress, train_speed_level, num_cappers, recede_time) 854 | } else { 855 | Default::default() 856 | }; 857 | for prop in entity.props() { 858 | if let Some((_table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 859 | match prop_name.as_str() { 860 | "m_vecOrigin" => entry.position = Vector::try_from(&prop.value).unwrap_or_default(), 861 | "m_angRotation" => entry.rotation = Vector::try_from(&prop.value).unwrap_or_default(), 862 | "m_flTotalProgress" => total_progress = f32::try_from(&prop.value).unwrap_or_default(), 863 | "m_iTrainSpeedLevel" => train_speed_level = i64::try_from(&prop.value).unwrap_or_default() as i32, 864 | "m_nNumCappers" => num_cappers = i64::try_from(&prop.value).unwrap_or_default() as i32, 865 | "m_flRecedeTime" => recede_time = f32::try_from(&prop.value).unwrap_or_default(), 866 | _ => {} 867 | } 868 | } 869 | } 870 | entry.entity_content = EntityContent::TeamTrainWatcher { 871 | total_progress, 872 | train_speed_level, 873 | num_cappers, 874 | recede_time, 875 | }; 876 | } 877 | 878 | fn handle_func_track_train(&mut self, entity: &PacketEntity) { 879 | let entry = self.state.other_entities.entry(entity.entity_index).or_insert_with(|| OtherEntity { ..Default::default() }); 880 | for prop in entity.props() { 881 | if let Some((_table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 882 | match prop_name.as_str() { 883 | "m_vecOrigin" => entry.position = Vector::try_from(&prop.value).unwrap_or_default(), 884 | "m_angRotation" => entry.rotation = Vector::try_from(&prop.value).unwrap_or_default(), 885 | _ => {} 886 | } 887 | } 888 | } 889 | entry.entity_content = EntityContent::Cart; 890 | } 891 | 892 | fn handle_weapon(&mut self, entity: &PacketEntity) { 893 | let entry = self.state.other_entities.entry(entity.entity_index).or_insert_with(|| OtherEntity { ..Default::default() }); 894 | let (mut id, mut name, mut owner) = match entry.entity_content { 895 | EntityContent::Weapon { name, id, owner } => (id, name, owner), 896 | _ => (-1, Weapon::Unknown, None), 897 | }; 898 | for prop in entity.props() { 899 | if let Some((_table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 900 | match prop_name.as_str() { 901 | "m_vecOrigin" => entry.position = Vector::try_from(&prop.value).unwrap_or_default(), 902 | "m_angRotation" => entry.rotation = Vector::try_from(&prop.value).unwrap_or_default(), 903 | "moveparent" => owner = handle_to_entity_index(i64::try_from(&prop.value).unwrap_or_default()), 904 | "m_iItemDefinitionIndex" => id = i64::try_from(&prop.value).unwrap_or(-1) as i32, // TODO: for some reason this is not always filled :( 905 | _ => {} 906 | } 907 | } 908 | } 909 | if name == Weapon::Unknown { 910 | name = weapons::index_to_weapon(id); 911 | } 912 | if name == Weapon::Unknown { 913 | // eprintln!("Unknown weapon: {}, {}, owner: {:?}, {}", id, self.class_names.get(usize::from(entity.server_class)).map(|class_name| class_name.as_str()).unwrap_or(""), owner, entity.entity_index); 914 | entry.entity_content = EntityContent::Other { 915 | class_name: self 916 | .class_names 917 | .get(usize::from(entity.server_class)) 918 | .map(|class_name| class_name.as_str()) 919 | .unwrap_or("") 920 | .into(), 921 | }; 922 | return; 923 | } 924 | entry.entity_content = EntityContent::Weapon { name, id, owner }; 925 | } 926 | 927 | fn handle_event(&mut self, event: &GameEvent, tick: u32) { 928 | match event { 929 | GameEvent::PlayerSpawn(event) => { 930 | let spawn = Spawn::from_event(event, tick); 931 | if let Some(user_state) = self.state.users.get_mut(&spawn.user) { 932 | user_state.team = spawn.team; 933 | } 934 | } 935 | _ => {} 936 | } 937 | } 938 | 939 | fn parse_user_info(&mut self, text: Option<&str>, data: Option) -> ReadResult<()> { 940 | if let Some(mut data) = data { 941 | let name: String = data.read_sized(32).unwrap_or_else(|_| "Malformed Name".into()); 942 | let user_id: UserId = data.read::()?.into(); 943 | let steam_id: String = data.read()?; 944 | 945 | let entity_id = if let Some(slot_id) = text { 946 | Some((slot_id.parse::().expect("can't parse player slot") + 1).into()) 947 | } else { 948 | None 949 | }; 950 | 951 | if !steam_id.is_empty() { 952 | self.state 953 | .users 954 | .entry(user_id) 955 | .and_modify(|info| { 956 | if entity_id != None { 957 | info.entity_id = entity_id; 958 | } 959 | }) 960 | .or_insert_with(|| UserInfo { 961 | team: Team::Other, 962 | steam_id, 963 | user_id, 964 | name, 965 | entity_id: entity_id, 966 | }); 967 | } 968 | } 969 | Ok(()) 970 | } 971 | } 972 | -------------------------------------------------------------------------------- /src/demo_player/weapons.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | #[derive(Debug, Copy, Clone, PartialEq, Serialize)] 4 | pub(crate) enum Weapon { 5 | Unknown, 6 | AirStrike, 7 | AliBabasWeeBooties, 8 | Ambassador, 9 | Amputator, 10 | ApocoFists, 11 | ApSap, 12 | Atomizer, 13 | AWPerHand, 14 | Axtinguisher, 15 | BabyFaceBlaster, 16 | Backburner, 17 | BackScatter, 18 | BackScratcher, 19 | BASEJumper, 20 | Bat, 21 | BatOuttaHell, 22 | BatSaber, 23 | BattalionsBackup, 24 | BazaarBargain, 25 | BeggarsBazooka, 26 | BigEarner, 27 | BigKill, 28 | BlackBox, 29 | BlackRose, 30 | Blutsauger, 31 | Bonesaw, 32 | BonkAtomicPunch, 33 | Bootlegger, 34 | BostonBasher, 35 | Bottle, 36 | BrassBeast, 37 | BreadBite, 38 | BuffaloSteakSandvich, 39 | BuffBanner, 40 | Bushwacka, 41 | CandyCane, 42 | CAPPER, 43 | CharginTarge, 44 | ClaidheamhMor, 45 | Classic, 46 | CleanersCarbine, 47 | CloakAndDagger, 48 | Concheror, 49 | ConniversKunai, 50 | ConscientiousObjector, 51 | ConstructionPDA, 52 | CowMangler5000, 53 | CozyCamper, 54 | CrossingGuard, 55 | CrusadersCrossbow, 56 | DalokohsBar, 57 | Deflector, 58 | Degreaser, 59 | DarwinsDangerShield, 60 | DeadRinger, 61 | DestructionPDA, 62 | Detonator, 63 | Diamondback, 64 | DisguiseKitPDA, 65 | DirectHit, 66 | DisciplinaryAction, 67 | DragonsFury, 68 | Enforcer, 69 | EnthusiastsTimepiece, 70 | Equalizer, 71 | EscapePlan, 72 | EurekaEffect, 73 | EvictionNotice, 74 | Eyelander, 75 | FamilyBusiness, 76 | FanOWar, 77 | FireAxe, 78 | Fishcake, 79 | Fists, 80 | FistsOfSteel, 81 | FlameThrower, 82 | FlareGun, 83 | FlyingGuillotine, 84 | ForceANature, 85 | FortifiedCompound, 86 | FreedomStaff, 87 | FrontierJustice, 88 | FryingPan, 89 | GasPasser, 90 | GigarCounter, 91 | GlovesOfRunningUrgently, 92 | GoldenFryingPan, 93 | GoldenWrench, 94 | GrenadeLauncher, 95 | Gunboats, 96 | Gunslinger, 97 | HalfZatoichi, 98 | HamShank, 99 | HitmansHeatmaker, 100 | HolidayPunch, 101 | HolyMackerel, 102 | Homewrecker, 103 | Huntsman, 104 | HorselessHeadlessHorsemannsHeadtaker, 105 | HotHand, 106 | HuoLongHeater, 107 | InvisWatch, 108 | IronBomber, 109 | IronCurtain, 110 | Jag, 111 | Jarate, 112 | KillingGlovesOfBoxing, 113 | Knife, 114 | Kritzkrieg, 115 | Kukri, 116 | LEtranger, 117 | LibertyLauncher, 118 | LochNLoad, 119 | Lollichop, 120 | LooseCannon, 121 | Lugermorph, 122 | Machina, 123 | Manmelter, 124 | Mantreads, 125 | MarketGardener, 126 | Maul, 127 | MediGun, 128 | MemoryMaker, 129 | Minigun, 130 | MutatedMilk, 131 | Natascha, 132 | NecroSmasher, 133 | NeonAnnihilator, 134 | NessiesNineIron, 135 | NostromoNapalmer, 136 | Original, 137 | Overdose, 138 | PainTrain, 139 | PanicAttack, 140 | PersianPersuader, 141 | Phlogistinator, 142 | Pistol, 143 | Pomson6000, 144 | PostalPummeler, 145 | Powerjack, 146 | PrettyBoysPocketPistol, 147 | PrinnyMachete, 148 | Quackenbirdt, 149 | QuickFix, 150 | QuickiebombLauncher, 151 | Rainblower, 152 | Razorback, 153 | RedTapeRecorder, 154 | RescueRanger, 155 | ReserveShooter, 156 | Revolver, 157 | RighteousBison, 158 | RoboSandvich, 159 | RocketJumper, 160 | RocketLauncher, 161 | Sandman, 162 | Sandvich, 163 | Sapper, 164 | Saxxy, 165 | Scattergun, 166 | ScorchShot, 167 | ScotsmansSkullcutter, 168 | ScottishHandshake, 169 | ScottishResistance, 170 | SecondBanana, 171 | SelfAwareBeautyMark, 172 | Shahanshah, 173 | SharpDresser, 174 | SharpenedVolcanoFragment, 175 | ShootingStar, 176 | ShortCircuit, 177 | Shortstop, 178 | Shotgun, 179 | Shovel, 180 | SMG, 181 | SnackAttack, 182 | SniperRifle, 183 | SodaPopper, 184 | SolemnVow, 185 | SouthernHospitality, 186 | SplendidScreen, 187 | Spycicle, 188 | StickybombLauncher, 189 | StickyJumper, 190 | SunOnAStick, 191 | SydneySleeper, 192 | SyringeGun, 193 | ThermalThruster, 194 | ThreeRuneBlade, 195 | ThirdDegree, 196 | TideTurner, 197 | Tomislav, 198 | Toolbox, 199 | TribalmansShiv, 200 | Ubersaw, 201 | UllapoolCaber, 202 | UnarmedCombat, 203 | Vaccinator, 204 | VitaSaw, 205 | WangaPrick, 206 | WarriorsSpirit, 207 | Widowmaker, 208 | Winger, 209 | Wrangler, 210 | WrapAssassin, 211 | Wrench, 212 | YourEternalReward, 213 | } 214 | 215 | // https://wiki.alliedmods.net/Team_fortress_2_item_definition_indexes#Weapons 216 | 217 | pub(crate) fn index_to_weapon(index: i32) -> Weapon { 218 | match index { 219 | 13 => Weapon::Scattergun, 220 | 200 => Weapon::Scattergun, // Renamed/Strange 221 | 45 => Weapon::ForceANature, 222 | 220 => Weapon::Shortstop, 223 | 448 => Weapon::SodaPopper, 224 | 669 => Weapon::Scattergun, // Festive 225 | 772 => Weapon::BabyFaceBlaster, 226 | 799 => Weapon::Scattergun, // Silver Botkiller Scattergun Mk.I 227 | 808 => Weapon::Scattergun, // Gold Botkiller Scattergun Mk.I 228 | 888 => Weapon::Scattergun, // Rust Botkiller Scattergun Mk.I 229 | 897 => Weapon::Scattergun, // Blood Botkiller Scattergun Mk.I 230 | 906 => Weapon::Scattergun, // Carbonado Botkiller Scattergun Mk.I 231 | 915 => Weapon::Scattergun, // Diamond Botkiller Scattergun Mk.I 232 | 964 => Weapon::Scattergun, // Silver Botkiller Scattergun Mk.II 233 | 973 => Weapon::Scattergun, // Gold Botkiller Scattergun Mk.II 234 | 1078 => Weapon::ForceANature, // Festive 235 | 1103 => Weapon::BackScatter, 236 | 15002 => Weapon::Scattergun, // Night Terror 237 | 15015 => Weapon::Scattergun, // Tartan Torpedo 238 | 15021 => Weapon::Scattergun, // Country Crusher 239 | 15029 => Weapon::Scattergun, // Backcountry Blaster 240 | 15036 => Weapon::Scattergun, // Spruce Deuce 241 | 15053 => Weapon::Scattergun, // Current Event 242 | 15065 => Weapon::Scattergun, // Macabre Web 243 | 15069 => Weapon::Scattergun, // Nutcracker 244 | 15106 => Weapon::Scattergun, // Blue Mew 245 | 15107 => Weapon::Scattergun, // Flower Power 246 | 15108 => Weapon::Scattergun, // Shot to Hell 247 | 15131 => Weapon::Scattergun, // Coffin Nail 248 | 15151 => Weapon::Scattergun, // Killer Bee 249 | 15157 => Weapon::Scattergun, // Corsair 250 | 251 | 23 => Weapon::Pistol, 252 | 209 => Weapon::Pistol, // Renamed/Strange 253 | 46 => Weapon::BonkAtomicPunch, 254 | 160 => Weapon::Lugermorph, // Vintage 255 | 449 => Weapon::Winger, 256 | 773 => Weapon::PrettyBoysPocketPistol, 257 | 812 => Weapon::FlyingGuillotine, 258 | 833 => Weapon::FlyingGuillotine, // Genuine 259 | 1121 => Weapon::MutatedMilk, 260 | 1145 => Weapon::BonkAtomicPunch, // Festive 261 | 15013 => Weapon::Pistol, // Red Rock Roscoe 262 | 15018 => Weapon::Pistol, // Homemade Heater 263 | 15035 => Weapon::Pistol, // Hickory Holepuncher 264 | 15041 => Weapon::Pistol, // Local Hero 265 | 15046 => Weapon::Pistol, // Black Dahlia 266 | 15056 => Weapon::Pistol, // Sandstone Special 267 | 15060 => Weapon::Pistol, // Macabre Web 268 | 15061 => Weapon::Pistol, // Nutcracker 269 | 15100 => Weapon::Pistol, // Blue Mew 270 | 15101 => Weapon::Pistol, // Brain Candy 271 | 15102 => Weapon::Pistol, // Shot to Hell 272 | 15126 => Weapon::Pistol, // Dressed To Kill 273 | 15148 => Weapon::Pistol, // Blitzkrieg 274 | 30666 => Weapon::CAPPER, 275 | 276 | 0 => Weapon::Bat, 277 | 190 => Weapon::Bat, // Renamed/Strange 278 | 44 => Weapon::Sandman, 279 | 221 => Weapon::HolyMackerel, 280 | 264 => Weapon::FryingPan, 281 | 317 => Weapon::CandyCane, 282 | 325 => Weapon::BostonBasher, 283 | 349 => Weapon::SunOnAStick, 284 | 355 => Weapon::FanOWar, 285 | 423 => Weapon::Saxxy, 286 | 450 => Weapon::Atomizer, 287 | 452 => Weapon::ThreeRuneBlade, 288 | 474 => Weapon::ConscientiousObjector, 289 | 572 => Weapon::UnarmedCombat, 290 | 648 => Weapon::WrapAssassin, 291 | 660 => Weapon::Bat, // Festive 292 | 880 => Weapon::FreedomStaff, 293 | 939 => Weapon::BatOuttaHell, 294 | 954 => Weapon::MemoryMaker, 295 | 999 => Weapon::HolyMackerel, // Festive 296 | 1013 => Weapon::HamShank, 297 | 1071 => Weapon::GoldenFryingPan, 298 | 1123 => Weapon::NecroSmasher, 299 | 1127 => Weapon::CrossingGuard, 300 | 30667 => Weapon::BatSaber, 301 | 30758 => Weapon::PrinnyMachete, 302 | 303 | 18 => Weapon::RocketLauncher, 304 | 205 => Weapon::RocketLauncher, // Renamed/Strange 305 | 127 => Weapon::DirectHit, 306 | 228 => Weapon::BlackBox, 307 | 237 => Weapon::RocketJumper, 308 | 414 => Weapon::LibertyLauncher, 309 | 441 => Weapon::CowMangler5000, 310 | 513 => Weapon::Original, 311 | 658 => Weapon::RocketLauncher, // Festive 312 | 730 => Weapon::BeggarsBazooka, 313 | 800 => Weapon::RocketLauncher, // Silver Botkiller Rocket Launcher Mk.I 314 | 809 => Weapon::RocketLauncher, // Gold Botkiller Rocket Launcher Mk.I 315 | 889 => Weapon::RocketLauncher, // Rust Botkiller Rocket Launcher Mk.I 316 | 898 => Weapon::RocketLauncher, // Blood Botkiller Rocket Launcher Mk.I 317 | 907 => Weapon::RocketLauncher, // Carbonado Botkiller Rocket Launcher Mk.I 318 | 916 => Weapon::RocketLauncher, // Diamond Botkiller Rocket Launcher Mk.I 319 | 965 => Weapon::RocketLauncher, // Silver Botkiller Rocket Launcher Mk.II 320 | 974 => Weapon::RocketLauncher, // Gold Botkiller Rocket Launcher Mk.II 321 | 1085 => Weapon::BlackBox, // Festive 322 | 1104 => Weapon::AirStrike, 323 | 15006 => Weapon::RocketLauncher, // Woodland Warrior 324 | 15014 => Weapon::RocketLauncher, // Sand Cannon 325 | 15028 => Weapon::RocketLauncher, // American Pastoral 326 | 15043 => Weapon::RocketLauncher, // Smalltown Bringdown 327 | 15052 => Weapon::RocketLauncher, // Shell Shocker 328 | 15057 => Weapon::RocketLauncher, // Aqua Marine 329 | 15081 => Weapon::RocketLauncher, // Autumn 330 | 15104 => Weapon::RocketLauncher, // Blue Mew 331 | 15105 => Weapon::RocketLauncher, // Brain Candy 332 | 15129 => Weapon::RocketLauncher, // Coffin Nail 333 | 15130 => Weapon::RocketLauncher, // High Roller's 334 | 15150 => Weapon::RocketLauncher, // Warhawk 335 | 336 | 10 => Weapon::Shotgun, 337 | 199 => Weapon::Shotgun, // Renamed/Strange 338 | 129 => Weapon::BuffBanner, 339 | 133 => Weapon::Gunboats, 340 | 226 => Weapon::BattalionsBackup, 341 | 354 => Weapon::Concheror, 342 | 415 => Weapon::ReserveShooter, 343 | 442 => Weapon::RighteousBison, 344 | 444 => Weapon::Mantreads, 345 | 1001 => Weapon::BuffBanner, // Festive 346 | 1101 => Weapon::BASEJumper, 347 | 1141 => Weapon::Shotgun, // Festive 348 | 1153 => Weapon::PanicAttack, 349 | 15003 => Weapon::Shotgun, // Backwoods Boomstick 350 | 15016 => Weapon::Shotgun, // Rustic Ruiner 351 | 15044 => Weapon::Shotgun, // Civic Duty 352 | 15047 => Weapon::Shotgun, // Lightning Rod 353 | 15085 => Weapon::Shotgun, // Autumn 354 | 15109 => Weapon::Shotgun, // Flower Power 355 | 15132 => Weapon::Shotgun, // Coffin Nail 356 | 15133 => Weapon::Shotgun, // Dressed to Kill 357 | 15152 => Weapon::Shotgun, // Red Bear 358 | 359 | 6 => Weapon::Shovel, 360 | 196 => Weapon::Shovel, // Renamed/Strange 361 | 128 => Weapon::Equalizer, 362 | 154 => Weapon::PainTrain, 363 | 357 => Weapon::HalfZatoichi, 364 | 416 => Weapon::MarketGardener, 365 | 447 => Weapon::DisciplinaryAction, 366 | 775 => Weapon::EscapePlan, 367 | 368 | 21 => Weapon::FlameThrower, 369 | 208 => Weapon::FlameThrower, // Renamed/Strange 370 | 40 => Weapon::Backburner, 371 | 215 => Weapon::Degreaser, 372 | 594 => Weapon::Phlogistinator, 373 | 659 => Weapon::FlameThrower, // Festive 374 | 741 => Weapon::Rainblower, 375 | 798 => Weapon::FlameThrower, // Silver Botkiller Flame Thrower Mk.I 376 | 807 => Weapon::FlameThrower, // Gold Botkiller Flame Thrower Mk.I 377 | 887 => Weapon::FlameThrower, // Rust Botkiller Flame Thrower Mk.I 378 | 896 => Weapon::FlameThrower, // Blood Botkiller Flame Thrower Mk.I 379 | 905 => Weapon::FlameThrower, // Carbonado Botkiller Flame Thrower Mk.I 380 | 914 => Weapon::FlameThrower, // Diamond Botkiller Flame Thrower Mk.I 381 | 963 => Weapon::FlameThrower, // Silver Botkiller Flame Thrower Mk.II 382 | 972 => Weapon::FlameThrower, // Gold Botkiller Flame Thrower Mk.II 383 | 1146 => Weapon::Backburner, // Festive 384 | 1178 => Weapon::DragonsFury, 385 | 15005 => Weapon::FlameThrower, // Forest Fire 386 | 15017 => Weapon::FlameThrower, // Barn Burner 387 | 15030 => Weapon::FlameThrower, // Bovine Blazemaker 388 | 15034 => Weapon::FlameThrower, // Earth, Sky and Fire 389 | 15049 => Weapon::FlameThrower, // Flash Fryer 390 | 15054 => Weapon::FlameThrower, // Turbine Torcher 391 | 15066 => Weapon::FlameThrower, // Autumn 392 | 15067 => Weapon::FlameThrower, // Pumpkin Patch 393 | 15068 => Weapon::FlameThrower, // Nutcracker 394 | 15089 => Weapon::FlameThrower, // Balloonicorn 395 | 15090 => Weapon::FlameThrower, // Rainbow 396 | 15115 => Weapon::FlameThrower, // Coffin Nai 397 | 15141 => Weapon::FlameThrower, // Warhawk 398 | 30474 => Weapon::NostromoNapalmer, 399 | 400 | 12 => Weapon::Shotgun, 401 | 39 => Weapon::FlareGun, 402 | 351 => Weapon::Detonator, 403 | 595 => Weapon::Manmelter, 404 | 740 => Weapon::ScorchShot, 405 | 1081 => Weapon::FlareGun, // Festive 406 | 1179 => Weapon::ThermalThruster, 407 | 1180 => Weapon::GasPasser, 408 | 409 | 2 => Weapon::FireAxe, 410 | 192 => Weapon::FireAxe, // Renamed/Strange 411 | 38 => Weapon::Axtinguisher, 412 | 153 => Weapon::Homewrecker, 413 | 214 => Weapon::Powerjack, 414 | 326 => Weapon::BackScratcher, 415 | 348 => Weapon::SharpenedVolcanoFragment, 416 | 457 => Weapon::PostalPummeler, 417 | 466 => Weapon::Maul, 418 | 593 => Weapon::ThirdDegree, 419 | 739 => Weapon::Lollichop, 420 | 813 => Weapon::NeonAnnihilator, 421 | 834 => Weapon::NeonAnnihilator, // Genuine 422 | 1000 => Weapon::Axtinguisher, // Festive 423 | 1181 => Weapon::HotHand, 424 | 425 | 19 => Weapon::GrenadeLauncher, 426 | 206 => Weapon::GrenadeLauncher, // Renamed/Strange 427 | 308 => Weapon::LochNLoad, 428 | 405 => Weapon::AliBabasWeeBooties, 429 | 608 => Weapon::Bootlegger, 430 | 996 => Weapon::LooseCannon, 431 | 1007 => Weapon::GrenadeLauncher, // Festive 432 | 1151 => Weapon::IronBomber, 433 | 15077 => Weapon::GrenadeLauncher, // Autumn 434 | 15079 => Weapon::GrenadeLauncher, // Macabre Web 435 | 15091 => Weapon::GrenadeLauncher, // Rainbow 436 | 15092 => Weapon::GrenadeLauncher, // Sweet Dreams 437 | 15116 => Weapon::GrenadeLauncher, // Coffin Nail 438 | 15117 => Weapon::GrenadeLauncher, // Top Shelf 439 | 15142 => Weapon::GrenadeLauncher, // Warhawk 440 | 15158 => Weapon::GrenadeLauncher, // Butcher Bird 441 | 442 | 20 => Weapon::StickybombLauncher, 443 | 207 => Weapon::StickybombLauncher, // Renamed/Strange 444 | 130 => Weapon::ScottishResistance, 445 | 131 => Weapon::CharginTarge, 446 | 265 => Weapon::StickyJumper, 447 | 406 => Weapon::SplendidScreen, 448 | 661 => Weapon::StickybombLauncher, // Festive 449 | 797 => Weapon::StickybombLauncher, // Silver Botkiller Stickybomb Launcher Mk.I 450 | 806 => Weapon::StickybombLauncher, // Gold Botkiller Stickybomb Launcher Mk.I 451 | 886 => Weapon::StickybombLauncher, // Rust Botkiller Stickybomb Launcher Mk.I 452 | 895 => Weapon::StickybombLauncher, // Blood Botkiller Stickybomb Launcher Mk.I 453 | 904 => Weapon::StickybombLauncher, // Carbonado Botkiller Stickybomb Launcher Mk.I 454 | 913 => Weapon::StickybombLauncher, // Diamond Botkiller Stickybomb Launcher Mk.I 455 | 962 => Weapon::StickybombLauncher, // Silver Botkiller Stickybomb Launcher Mk.II 456 | 971 => Weapon::StickybombLauncher, // Gold Botkiller Stickybomb Launcher Mk.II 457 | 1099 => Weapon::TideTurner, 458 | 1144 => Weapon::CharginTarge, // Festive 459 | 1150 => Weapon::QuickiebombLauncher, 460 | 15009 => Weapon::StickybombLauncher, // Sudden Flurry 461 | 15012 => Weapon::StickybombLauncher, // Carpet Bomber 462 | 15024 => Weapon::StickybombLauncher, // Blasted Bombardier 463 | 15038 => Weapon::StickybombLauncher, // Rooftop Wrangler 464 | 15045 => Weapon::StickybombLauncher, // Liquid Asset 465 | 15048 => Weapon::StickybombLauncher, // Pink Elephant 466 | 15082 => Weapon::StickybombLauncher, // Autumn 467 | 15083 => Weapon::StickybombLauncher, // Pumpkin Patch 468 | 15084 => Weapon::StickybombLauncher, // Macabre Web 469 | 15113 => Weapon::StickybombLauncher, // Sweet Dreams 470 | 15137 => Weapon::StickybombLauncher, // Coffin Nail 471 | 15138 => Weapon::StickybombLauncher, // Dressed to Kill 472 | 15155 => Weapon::StickybombLauncher, // Blitzkrieg 473 | 474 | 1 => Weapon::Bottle, 475 | 191 => Weapon::Bottle, // Renamed/Strange 476 | 132 => Weapon::Eyelander, 477 | 172 => Weapon::ScotsmansSkullcutter, 478 | 266 => Weapon::HorselessHeadlessHorsemannsHeadtaker, 479 | 307 => Weapon::UllapoolCaber, 480 | 327 => Weapon::ClaidheamhMor, 481 | 404 => Weapon::PersianPersuader, 482 | 482 => Weapon::NessiesNineIron, 483 | 609 => Weapon::ScottishHandshake, 484 | 1082 => Weapon::Eyelander, // Festive 485 | 486 | 15 => Weapon::Minigun, 487 | 202 => Weapon::Minigun, // Renamed/Strange 488 | 41 => Weapon::Natascha, 489 | 298 => Weapon::IronCurtain, 490 | 312 => Weapon::BrassBeast, 491 | 424 => Weapon::Tomislav, 492 | 654 => Weapon::Minigun, // Festive 493 | 793 => Weapon::Minigun, // Silver Botkiller Minigun Mk.I 494 | 802 => Weapon::Minigun, // Gold Botkiller Minigun Mk.I 495 | 811 => Weapon::HuoLongHeater, 496 | 832 => Weapon::HuoLongHeater, // Genuine 497 | 850 => Weapon::Deflector, 498 | 882 => Weapon::Minigun, // Rust Botkiller Minigun Mk.I 499 | 891 => Weapon::Minigun, // Blood Botkiller Minigun Mk.I 500 | 900 => Weapon::Minigun, // Carbonado Botkiller Minigun Mk.I 501 | 909 => Weapon::Minigun, // Diamond Botkiller Minigun Mk.I 502 | 958 => Weapon::Minigun, // Silver Botkiller Minigun Mk.II 503 | 967 => Weapon::Minigun, // Gold Botkiller Minigun Mk.II 504 | 15004 => Weapon::Minigun, // King of the Jungle 505 | 15020 => Weapon::Minigun, // Iron Wood 506 | 15026 => Weapon::Minigun, // Antique Annihilator 507 | 15031 => Weapon::Minigun, // War Room 508 | 15040 => Weapon::Minigun, // Citizen Pain 509 | 15055 => Weapon::Minigun, // Brick House 510 | 15086 => Weapon::Minigun, // Macabre Web 511 | 15087 => Weapon::Minigun, // Pumpkin Patch 512 | 15088 => Weapon::Minigun, // Nutcracker 513 | 15098 => Weapon::Minigun, // Brain Candy 514 | 15099 => Weapon::Minigun, // Mister Cuddles 515 | 15123 => Weapon::Minigun, // Coffin Nail 516 | 15124 => Weapon::Minigun, // Dressed to Kill 517 | 15125 => Weapon::Minigun, // Top Shelf 518 | 15147 => Weapon::Minigun, // Butcher Bird 519 | 520 | 11 => Weapon::Shotgun, 521 | 42 => Weapon::Sandvich, 522 | 159 => Weapon::DalokohsBar, 523 | 311 => Weapon::BuffaloSteakSandvich, 524 | 425 => Weapon::FamilyBusiness, 525 | 433 => Weapon::Fishcake, 526 | 863 => Weapon::RoboSandvich, 527 | 1002 => Weapon::Sandvich, // Festive 528 | 1190 => Weapon::SecondBanana, 529 | 530 | 5 => Weapon::Fists, 531 | 195 => Weapon::Fists, // Renamed/Strange 532 | 43 => Weapon::KillingGlovesOfBoxing, 533 | 239 => Weapon::GlovesOfRunningUrgently, 534 | 310 => Weapon::WarriorsSpirit, 535 | 331 => Weapon::FistsOfSteel, 536 | 426 => Weapon::EvictionNotice, 537 | 587 => Weapon::ApocoFists, 538 | 656 => Weapon::HolidayPunch, 539 | 1084 => Weapon::GlovesOfRunningUrgently, // Festive 540 | 1100 => Weapon::BreadBite, 541 | 1184 => Weapon::GlovesOfRunningUrgently, // MvM 542 | 543 | 9 => Weapon::Shotgun, 544 | 141 => Weapon::FrontierJustice, 545 | 527 => Weapon::Widowmaker, 546 | 588 => Weapon::Pomson6000, 547 | 997 => Weapon::RescueRanger, 548 | 1004 => Weapon::FrontierJustice, // Festive 549 | 550 | 22 => Weapon::Pistol, 551 | 140 => Weapon::Wrangler, 552 | 528 => Weapon::ShortCircuit, 553 | 1086 => Weapon::Wrangler, // Festive 554 | 30668 => Weapon::GigarCounter, 555 | 556 | 7 => Weapon::Wrench, 557 | 197 => Weapon::Wrench, // Renamed/Strange 558 | 142 => Weapon::Gunslinger, 559 | 155 => Weapon::SouthernHospitality, 560 | 169 => Weapon::GoldenWrench, 561 | 329 => Weapon::Jag, 562 | 589 => Weapon::EurekaEffect, 563 | 662 => Weapon::Wrench, // Festive 564 | 795 => Weapon::Wrench, // Silver Botkiller Wrench Mk.I 565 | 804 => Weapon::Wrench, // Gold Botkiller Wrench Mk.I 566 | 884 => Weapon::Wrench, // Rust Botkiller Wrench Mk.I 567 | 893 => Weapon::Wrench, // Blood Botkiller Wrench Mk.I 568 | 902 => Weapon::Wrench, // Carbonado Botkiller Wrench Mk.I 569 | 911 => Weapon::Wrench, // Diamond Botkiller Wrench Mk.I 570 | 960 => Weapon::Wrench, // Silver Botkiller Wrench Mk.II 571 | 969 => Weapon::Wrench, // Gold Botkiller Wrench Mk.II 572 | 15073 => Weapon::Wrench, // Nutcracker 573 | 15074 => Weapon::Wrench, // Autumn 574 | 15075 => Weapon::Wrench, // Boneyard 575 | 15139 => Weapon::Wrench, // Dressed to Kill 576 | 15140 => Weapon::Wrench, // Top Shelf 577 | 15114 => Weapon::Wrench, // Torqued to Hell 578 | 15156 => Weapon::Wrench, // Airwolf 579 | 580 | 25 => Weapon::ConstructionPDA, 581 | 737 => Weapon::ConstructionPDA, // Renamed/Strange 582 | 583 | 26 => Weapon::DestructionPDA, 584 | 585 | 28 => Weapon::Toolbox, 586 | 587 | 17 => Weapon::SyringeGun, 588 | 204 => Weapon::SyringeGun, // Renamed/Strange 589 | 36 => Weapon::Blutsauger, 590 | 305 => Weapon::CrusadersCrossbow, 591 | 412 => Weapon::Overdose, 592 | 1079 => Weapon::CrusadersCrossbow, // Festive 593 | 594 | 29 => Weapon::MediGun, 595 | 211 => Weapon::MediGun, // Renamed/Strange 596 | 35 => Weapon::Kritzkrieg, 597 | 411 => Weapon::QuickFix, 598 | 663 => Weapon::MediGun, // Festive 599 | 796 => Weapon::MediGun, // Silver Botkiller Medi Gun Mk.I 600 | 805 => Weapon::MediGun, // Gold Botkiller Medi Gun Mk.I 601 | 885 => Weapon::MediGun, // Rust Botkiller Medi Gun Mk.I 602 | 894 => Weapon::MediGun, // Blood Botkiller Medi Gun Mk.I 603 | 903 => Weapon::MediGun, // Carbonado Botkiller Medi Gun Mk.I 604 | 912 => Weapon::MediGun, // Diamond Botkiller Medi Gun Mk.I 605 | 961 => Weapon::MediGun, // Silver Botkiller Medi Gun Mk.II 606 | 970 => Weapon::MediGun, // Gold Botkiller Medi Gun Mk.II 607 | 998 => Weapon::Vaccinator, 608 | 15008 => Weapon::MediGun, // Masked Mender 609 | 15010 => Weapon::MediGun, // Wrapped Reviver 610 | 15025 => Weapon::MediGun, // Reclaimed Reanimator 611 | 15039 => Weapon::MediGun, // Civil Servant 612 | 15050 => Weapon::MediGun, // Spark of Life 613 | 15078 => Weapon::MediGun, // Wildwood 614 | 15097 => Weapon::MediGun, // Flower Power 615 | 15120 => Weapon::MediGun, // Coffin Nail (this one is incorrectly listed in the wiki as 15123) 616 | 15121 => Weapon::MediGun, // Dressed To Kill 617 | 15122 => Weapon::MediGun, // High Roller's 618 | 15145 => Weapon::MediGun, // Blitzkrieg 619 | 15146 => Weapon::MediGun, // Corsair 620 | 621 | 8 => Weapon::Bonesaw, 622 | 198 => Weapon::Bonesaw, // Renamed/Strange 623 | 37 => Weapon::Ubersaw, 624 | 173 => Weapon::VitaSaw, 625 | 304 => Weapon::Amputator, 626 | 413 => Weapon::SolemnVow, 627 | 1003 => Weapon::Ubersaw, // Festive 628 | 1143 => Weapon::Bonesaw, // Festive 629 | 630 | 14 => Weapon::SniperRifle, 631 | 201 => Weapon::SniperRifle, // Renamed/Strange 632 | 56 => Weapon::Huntsman, 633 | 230 => Weapon::SydneySleeper, 634 | 402 => Weapon::BazaarBargain, 635 | 526 => Weapon::Machina, 636 | 664 => Weapon::SniperRifle, // Festive 637 | 752 => Weapon::HitmansHeatmaker, 638 | 792 => Weapon::SniperRifle, // Silver Botkiller Sniper Rifle Mk.I 639 | 801 => Weapon::SniperRifle, // Gold Botkiller Sniper Rifle Mk.I 640 | 851 => Weapon::AWPerHand, 641 | 881 => Weapon::SniperRifle, // Rust Botkiller Sniper Rifle Mk.I 642 | 890 => Weapon::SniperRifle, // Blood Botkiller Sniper Rifle Mk.I 643 | 899 => Weapon::SniperRifle, // Carbonado Botkiller Sniper Rifle Mk.I 644 | 908 => Weapon::SniperRifle, // Diamond Botkiller Sniper Rifle Mk.I 645 | 957 => Weapon::SniperRifle, // Silver Botkiller Sniper Rifle Mk.II 646 | 966 => Weapon::SniperRifle, // Gold Botkiller Sniper Rifle Mk.II 647 | 1005 => Weapon::Huntsman, // Festive 648 | 1092 => Weapon::FortifiedCompound, 649 | 1098 => Weapon::Classic, 650 | 15000 => Weapon::SniperRifle, // Night Owl 651 | 15007 => Weapon::SniperRifle, // Purple Range 652 | 15019 => Weapon::SniperRifle, // Lumber From Down Under 653 | 15023 => Weapon::SniperRifle, // Shot in the Dark 654 | 15033 => Weapon::SniperRifle, // Bogtrotter 655 | 15059 => Weapon::SniperRifle, // Thunderbolt 656 | 15070 => Weapon::SniperRifle, // Pumpkin Patch 657 | 15071 => Weapon::SniperRifle, // Boneyard 658 | 15072 => Weapon::SniperRifle, // Wildwood 659 | 15111 => Weapon::SniperRifle, // Balloonicorn 660 | 15112 => Weapon::SniperRifle, // Rainbow 661 | 15135 => Weapon::SniperRifle, // Coffin Nail 662 | 15136 => Weapon::SniperRifle, // Dressed to Kill 663 | 15154 => Weapon::SniperRifle, // Airwolf 664 | 30665 => Weapon::ShootingStar, // Shooting Star 665 | 666 | 16 => Weapon::SMG, 667 | 203 => Weapon::SMG, // Renamed/Strange 668 | 57 => Weapon::Razorback, 669 | 58 => Weapon::Jarate, 670 | 231 => Weapon::DarwinsDangerShield, 671 | 642 => Weapon::CozyCamper, 672 | 751 => Weapon::CleanersCarbine, 673 | 1083 => Weapon::Jarate, // Festive 674 | 1105 => Weapon::SelfAwareBeautyMark, 675 | 1149 => Weapon::SMG, // Festive 676 | 15001 => Weapon::SMG, // Woodsy Widowmaker 677 | 15022 => Weapon::SMG, // Plaid Potshotter 678 | 15032 => Weapon::SMG, // Treadplate Tormenter 679 | 15037 => Weapon::SMG, // Team Sprayer 680 | 15058 => Weapon::SMG, // Low Profile 681 | 15076 => Weapon::SMG, // Wildwood 682 | 15110 => Weapon::SMG, // Blue Mew 683 | 15134 => Weapon::SMG, // High Roller's 684 | 15153 => Weapon::SMG, // Blitzkrieg 685 | 686 | 3 => Weapon::Kukri, 687 | 193 => Weapon::Kukri, // Renamed/Strange 688 | 171 => Weapon::TribalmansShiv, 689 | 232 => Weapon::Bushwacka, 690 | 401 => Weapon::Shahanshah, 691 | 692 | 24 => Weapon::Revolver, 693 | 210 => Weapon::Revolver, // Renamed/Strange 694 | 61 => Weapon::Ambassador, 695 | 161 => Weapon::BigKill, 696 | 224 => Weapon::LEtranger, 697 | 460 => Weapon::Enforcer, 698 | 525 => Weapon::Diamondback, 699 | 1006 => Weapon::Ambassador, // Festive 700 | 1142 => Weapon::Revolver, // Festive 701 | 15011 => Weapon::Revolver, // Psychedelic Slugger 702 | 15027 => Weapon::Revolver, // Old Country 703 | 15042 => Weapon::Revolver, // Mayor 704 | 15051 => Weapon::Revolver, // Dead Reckoner 705 | 15062 => Weapon::Revolver, // Boneyard 706 | 15063 => Weapon::Revolver, // Wildwood 707 | 15064 => Weapon::Revolver, // Macabre Web 708 | 15103 => Weapon::Revolver, // Flower Power 709 | 15127 => Weapon::Revolver, // Coffin Nail (this one is incorrectly listed in the wiki as 15129) 710 | 15128 => Weapon::Revolver, // Top Shelf 711 | 15149 => Weapon::Revolver, // Blitzkrieg 712 | 713 | 735 => Weapon::Sapper, 714 | 736 => Weapon::Sapper, // Renamed/Strange 715 | 810 => Weapon::RedTapeRecorder, 716 | 831 => Weapon::RedTapeRecorder, // Genuine 717 | 933 => Weapon::ApSap, // Genuine 718 | 1080 => Weapon::Sapper, // Festive 719 | 1102 => Weapon::SnackAttack, 720 | 721 | 4 => Weapon::Knife, 722 | 194 => Weapon::Knife, // Renamed/Strange 723 | 225 => Weapon::YourEternalReward, 724 | 356 => Weapon::ConniversKunai, 725 | 461 => Weapon::BigEarner, 726 | 574 => Weapon::WangaPrick, 727 | 638 => Weapon::SharpDresser, 728 | 649 => Weapon::Spycicle, 729 | 665 => Weapon::Knife, // Festive 730 | 727 => Weapon::BlackRose, 731 | 794 => Weapon::Knife, // Silver Botkiller Knife Mk.I 732 | 803 => Weapon::Knife, // Gold Botkiller Knife Mk.I 733 | 883 => Weapon::Knife, // Rust Botkiller Knife Mk.I 734 | 892 => Weapon::Knife, // Blood Botkiller Knife Mk.I 735 | 901 => Weapon::Knife, // Carbonado Botkiller Knife Mk.I 736 | 910 => Weapon::Knife, // Diamond Botkiller Knife Mk.I 737 | 959 => Weapon::Knife, // Silver Botkiller Knife Mk.II 738 | 968 => Weapon::Knife, // Gold Botkiller Knife Mk.II 739 | 15080 => Weapon::Knife, // Boneyard (this one is incorrectly listed in the wiki as 15062) 740 | 15094 => Weapon::Knife, // Blue Mew 741 | 15095 => Weapon::Knife, // Brain Candy 742 | 15096 => Weapon::Knife, // Stabbed to Hell 743 | 15118 => Weapon::Knife, // Dressed to Kill 744 | 15119 => Weapon::Knife, // Top Shelf 745 | 15143 => Weapon::Knife, // Blitzkrieg 746 | 15144 => Weapon::Knife, // Airwolf 747 | 748 | 27 => Weapon::DisguiseKitPDA, 749 | 750 | 30 => Weapon::InvisWatch, 751 | 212 => Weapon::InvisWatch, // Renamed/Strange 752 | 59 => Weapon::DeadRinger, 753 | 60 => Weapon::CloakAndDagger, 754 | 297 => Weapon::EnthusiastsTimepiece, 755 | 947 => Weapon::Quackenbirdt, 756 | 757 | _ => Weapon::Unknown, 758 | } 759 | } 760 | -------------------------------------------------------------------------------- /src/demostf.rs: -------------------------------------------------------------------------------- 1 | use coldmaps::heatmap::LEVELOVERVIEW_SCALE_MULTIPLIER; 2 | use image::io::Reader; 3 | use image::{ImageFormat, RgbImage}; 4 | use reqwest::StatusCode; 5 | use serde::Deserialize; 6 | use std::collections::HashMap; 7 | use std::io::Cursor; 8 | 9 | pub async fn get_boundary(map: &str) -> Result, reqwest::Error> { 10 | let cache: HashMap = reqwest::get("https://github.com/demostf/demos.tf/raw/master/src/Analyse/mapboundries.json") 11 | .await? 12 | .json() 13 | .await?; 14 | Ok(cache.get(map).map(|boundary| { 15 | let x = (boundary.max.x + boundary.min.x) / 2.0; 16 | let y = (boundary.max.y + boundary.min.y) / 2.0; 17 | let scale = (boundary.max.y - boundary.min.y) / LEVELOVERVIEW_SCALE_MULTIPLIER / 2.0; 18 | (x, y, scale) 19 | })) 20 | } 21 | 22 | pub async fn get_image(map: &str) -> Result, reqwest::Error> { 23 | let result = reqwest::get(&format!("https://github.com/demostf/demos.tf/raw/master/src/images/leveloverview/dist/{}.png", map)).await?; 24 | if result.status() == StatusCode::NOT_FOUND { 25 | return Ok(None); 26 | } 27 | let body = result.bytes().await?; 28 | let reader = Cursor::new(body); 29 | let mut reader = Reader::new(reader); 30 | reader.set_format(ImageFormat::Png); 31 | 32 | Ok(reader.decode().ok().map(|image| image.into_rgb8())) 33 | } 34 | 35 | #[derive(Debug, Deserialize)] 36 | struct Boundary { 37 | #[serde(rename = "boundary_min")] 38 | min: Point, 39 | #[serde(rename = "boundary_max")] 40 | max: Point, 41 | } 42 | 43 | #[derive(Debug, Deserialize)] 44 | pub struct Point { 45 | pub x: f32, 46 | pub y: f32, 47 | } 48 | -------------------------------------------------------------------------------- /src/filters.rs: -------------------------------------------------------------------------------- 1 | use crate::heatmap_analyser::{Death, PlayerEntity, PlayerState, Team}; 2 | use enum_dispatch::enum_dispatch; 3 | use std::fmt::Display; 4 | use tf_demo_parser::demo::vector::Vector; 5 | 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 7 | pub enum OrderedOperator { 8 | Equal, 9 | NotEqual, 10 | Greater, 11 | Smaller, 12 | GreaterOrEqual, 13 | SmallerOrEqual, 14 | } 15 | 16 | impl OrderedOperator { 17 | pub const ALL: [OrderedOperator; 6] = [ 18 | OrderedOperator::Equal, 19 | OrderedOperator::NotEqual, 20 | OrderedOperator::Greater, 21 | OrderedOperator::Smaller, 22 | OrderedOperator::GreaterOrEqual, 23 | OrderedOperator::SmallerOrEqual, 24 | ]; 25 | } 26 | 27 | impl Default for OrderedOperator { 28 | fn default() -> Self { 29 | OrderedOperator::Equal 30 | } 31 | } 32 | 33 | impl Display for OrderedOperator { 34 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 35 | match self { 36 | OrderedOperator::Equal => write!(f, "="), 37 | OrderedOperator::NotEqual => write!(f, "≠"), 38 | OrderedOperator::Greater => write!(f, ">"), 39 | OrderedOperator::Smaller => write!(f, "<"), 40 | OrderedOperator::GreaterOrEqual => write!(f, "≥"), 41 | OrderedOperator::SmallerOrEqual => write!(f, "≤"), 42 | } 43 | } 44 | } 45 | 46 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 47 | pub enum PropertyOperator { 48 | IsPresent, 49 | IsNotPresent, 50 | } 51 | 52 | impl PropertyOperator { 53 | pub const ALL: [PropertyOperator; 2] = [PropertyOperator::IsPresent, PropertyOperator::IsNotPresent]; 54 | } 55 | 56 | impl Default for PropertyOperator { 57 | fn default() -> Self { 58 | PropertyOperator::IsPresent 59 | } 60 | } 61 | 62 | impl Display for PropertyOperator { 63 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 64 | match self { 65 | PropertyOperator::IsPresent => write!(f, "present"), 66 | PropertyOperator::IsNotPresent => write!(f, "not present"), 67 | } 68 | } 69 | } 70 | 71 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 72 | pub enum Property { 73 | Suicide, 74 | Posthumous, 75 | DuringRound, 76 | DiedToSentry, 77 | } 78 | 79 | impl Property { 80 | pub const ALL: [Property; 4] = [Property::Suicide, Property::Posthumous, Property::DuringRound, Property::DiedToSentry]; 81 | } 82 | 83 | impl Default for Property { 84 | fn default() -> Self { 85 | Property::Suicide 86 | } 87 | } 88 | 89 | impl Display for Property { 90 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 91 | match self { 92 | Property::Suicide => write!(f, "Suicide"), 93 | Property::Posthumous => write!(f, "Posthumous"), 94 | Property::DuringRound => write!(f, "During round"), 95 | Property::DiedToSentry => write!(f, "Died to sentry"), 96 | } 97 | } 98 | } 99 | 100 | #[enum_dispatch] 101 | #[derive(Debug)] 102 | pub enum Filter { 103 | KillerTeamFilter, 104 | VictimTeamFilter, 105 | KillerClassFilter, 106 | VictimClassFilter, 107 | KillerElevationFilter, 108 | VictimElevationFilter, 109 | Distance2DFilter, 110 | Distance3DFilter, 111 | RoundFilter, 112 | PropertyFilter, 113 | } 114 | 115 | #[enum_dispatch(Filter)] 116 | pub trait FilterTrait { 117 | fn apply(&self, death: &Death) -> bool; 118 | } 119 | 120 | #[derive(Debug)] 121 | pub struct KillerTeamFilter { 122 | pub team: Team, 123 | } 124 | 125 | impl FilterTrait for KillerTeamFilter { 126 | fn apply(&self, death: &Death) -> bool { 127 | match &death.killer_entity_state { 128 | Some(PlayerEntity { team, .. }) => *team == self.team, 129 | None => false, 130 | } 131 | } 132 | } 133 | 134 | #[derive(Debug)] 135 | pub struct VictimTeamFilter { 136 | pub team: Team, 137 | } 138 | 139 | impl FilterTrait for VictimTeamFilter { 140 | fn apply(&self, death: &Death) -> bool { 141 | match &death.victim_entity_state { 142 | Some(PlayerEntity { team, .. }) => *team == self.team, 143 | None => false, 144 | } 145 | } 146 | } 147 | 148 | #[derive(Debug)] 149 | pub struct KillerClassFilter { 150 | pub classes: [bool; 10], 151 | } 152 | 153 | impl FilterTrait for KillerClassFilter { 154 | fn apply(&self, death: &Death) -> bool { 155 | match &death.killer_entity_state { 156 | Some(PlayerEntity { class, .. }) => self.classes[*class as usize], 157 | None => false, 158 | } 159 | } 160 | } 161 | 162 | #[derive(Debug)] 163 | pub struct VictimClassFilter { 164 | pub classes: [bool; 10], 165 | } 166 | 167 | impl FilterTrait for VictimClassFilter { 168 | fn apply(&self, death: &Death) -> bool { 169 | match &death.victim_entity_state { 170 | Some(PlayerEntity { class, .. }) => self.classes[*class as usize], 171 | None => false, 172 | } 173 | } 174 | } 175 | 176 | #[derive(Debug)] 177 | pub struct KillerElevationFilter { 178 | pub op: OrderedOperator, 179 | pub z: f32, 180 | } 181 | 182 | impl FilterTrait for KillerElevationFilter { 183 | fn apply(&self, death: &Death) -> bool { 184 | match (&death.killer_entity_state, self.op) { 185 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::Equal) => *z == self.z, 186 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::NotEqual) => *z != self.z, 187 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::Greater) => *z > self.z, 188 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::Smaller) => *z < self.z, 189 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::GreaterOrEqual) => *z >= self.z, 190 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::SmallerOrEqual) => *z <= self.z, 191 | (None, _) => false, 192 | } 193 | } 194 | } 195 | 196 | #[derive(Debug)] 197 | pub struct VictimElevationFilter { 198 | pub op: OrderedOperator, 199 | pub z: f32, 200 | } 201 | 202 | impl FilterTrait for VictimElevationFilter { 203 | fn apply(&self, death: &Death) -> bool { 204 | match (&death.victim_entity_state, self.op) { 205 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::Equal) => *z == self.z, 206 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::NotEqual) => *z != self.z, 207 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::Greater) => *z > self.z, 208 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::Smaller) => *z < self.z, 209 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::GreaterOrEqual) => *z >= self.z, 210 | (Some(PlayerEntity { position: Vector { z, .. }, .. }), OrderedOperator::SmallerOrEqual) => *z <= self.z, 211 | (None, _) => false, 212 | } 213 | } 214 | } 215 | 216 | #[derive(Debug)] 217 | pub struct Distance2DFilter { 218 | pub op: OrderedOperator, 219 | pub distance: f32, 220 | } 221 | 222 | impl FilterTrait for Distance2DFilter { 223 | fn apply(&self, death: &Death) -> bool { 224 | if let (Some(killer_entity), Some(victim_entity)) = (&death.killer_entity_state, &death.victim_entity_state) { 225 | let distance_x = killer_entity.position.x - victim_entity.position.x; 226 | let distance_y = killer_entity.position.y - victim_entity.position.y; 227 | let distance = (distance_x * distance_x + distance_y * distance_y).sqrt(); 228 | match self.op { 229 | OrderedOperator::Equal => distance == self.distance, 230 | OrderedOperator::NotEqual => distance != self.distance, 231 | OrderedOperator::Greater => distance > self.distance, 232 | OrderedOperator::Smaller => distance < self.distance, 233 | OrderedOperator::GreaterOrEqual => distance >= self.distance, 234 | OrderedOperator::SmallerOrEqual => distance <= self.distance, 235 | } 236 | } else { 237 | false 238 | } 239 | } 240 | } 241 | 242 | #[derive(Debug)] 243 | pub struct Distance3DFilter { 244 | pub op: OrderedOperator, 245 | pub distance: f32, 246 | } 247 | 248 | impl FilterTrait for Distance3DFilter { 249 | fn apply(&self, death: &Death) -> bool { 250 | if let (Some(killer_entity), Some(victim_entity)) = (&death.killer_entity_state, &death.victim_entity_state) { 251 | let distance_x = killer_entity.position.x - victim_entity.position.x; 252 | let distance_y = killer_entity.position.y - victim_entity.position.y; 253 | let distance_z = killer_entity.position.z - victim_entity.position.z; 254 | let distance = (distance_x * distance_x + distance_y * distance_y + distance_z * distance_z).sqrt(); 255 | match self.op { 256 | OrderedOperator::Equal => distance == self.distance, 257 | OrderedOperator::NotEqual => distance != self.distance, 258 | OrderedOperator::Greater => distance > self.distance, 259 | OrderedOperator::Smaller => distance < self.distance, 260 | OrderedOperator::GreaterOrEqual => distance >= self.distance, 261 | OrderedOperator::SmallerOrEqual => distance <= self.distance, 262 | } 263 | } else { 264 | false 265 | } 266 | } 267 | } 268 | 269 | #[derive(Debug)] 270 | pub struct RoundFilter { 271 | pub op: OrderedOperator, 272 | pub round: u32, 273 | } 274 | 275 | impl FilterTrait for RoundFilter { 276 | fn apply(&self, death: &Death) -> bool { 277 | match self.op { 278 | OrderedOperator::Equal => death.round == self.round, 279 | OrderedOperator::NotEqual => death.round != self.round, 280 | OrderedOperator::Greater => death.round > self.round, 281 | OrderedOperator::Smaller => death.round < self.round, 282 | OrderedOperator::GreaterOrEqual => death.round >= self.round, 283 | OrderedOperator::SmallerOrEqual => death.round <= self.round, 284 | } 285 | } 286 | } 287 | 288 | #[derive(Debug)] 289 | pub struct PropertyFilter { 290 | pub op: PropertyOperator, 291 | pub property: Property, 292 | } 293 | 294 | impl FilterTrait for PropertyFilter { 295 | fn apply(&self, death: &Death) -> bool { 296 | let ret = match self.property { 297 | Property::Suicide => death.killer == death.victim, 298 | Property::Posthumous => match death.killer_entity_state { 299 | Some(PlayerEntity { state: PlayerState::Alive, .. }) => false, 300 | _ => true, 301 | }, 302 | Property::DuringRound => death.during_round, 303 | Property::DiedToSentry => death.sentry_position.is_some(), 304 | }; 305 | match self.op { 306 | PropertyOperator::IsPresent => ret, 307 | PropertyOperator::IsNotPresent => !ret, 308 | } 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/gui_filters.rs: -------------------------------------------------------------------------------- 1 | use crate::{delete_icon, style, Message}; 2 | use coldmaps::{ 3 | filters::{ 4 | Distance2DFilter, Distance3DFilter, Filter, KillerClassFilter, KillerElevationFilter, KillerTeamFilter, OrderedOperator, Property, PropertyFilter, PropertyOperator, 5 | RoundFilter, VictimClassFilter, VictimElevationFilter, VictimTeamFilter, 6 | }, 7 | heatmap_analyser::Team, 8 | }; 9 | use iced::{alignment, button, pick_list, scrollable, text_input, Button, Column, Container, Element, Font, Length, PickList, Row, Scrollable, Text, TextInput}; 10 | use std::fmt::Display; 11 | use style::ActiveButtonHighlight; 12 | 13 | const CLASS_ICONS: Font = Font::External { 14 | name: "ClassIcons", 15 | bytes: include_bytes!("../fonts/tf2-classicons.ttf"), 16 | }; 17 | 18 | fn icon(unicode: char) -> Text { 19 | Text::new(&unicode.to_string()) 20 | .font(CLASS_ICONS) 21 | .color([0.0, 0.0, 0.0]) 22 | .horizontal_alignment(alignment::Horizontal::Center) 23 | .size(20) 24 | } 25 | 26 | const CLASS_ICONS_CHARS: [char; 10] = [ 27 | '?', // Other 28 | '🐇', // Scout 29 | '🎷', // Sniper 30 | '💥', // Soldier 31 | '💣', // Demoman 32 | '💗', // Medic 33 | '💪', // Heavy 34 | '🔥', // Pyro 35 | '📦', // Spy 36 | '🔧', // Engineer 37 | ]; 38 | 39 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 40 | pub enum FilterType { 41 | KillerTeamFilter, 42 | VictimTeamFilter, 43 | KillerClassFilter, 44 | VictimClassFilter, 45 | KillerElevationFilter, 46 | VictimElevationFilter, 47 | Distance2DFilter, 48 | Distance3DFilter, 49 | RoundFilter, 50 | PropertyFilter, 51 | } 52 | 53 | impl FilterType { 54 | const ALL: [FilterType; 10] = [ 55 | FilterType::KillerTeamFilter, 56 | FilterType::VictimTeamFilter, 57 | FilterType::KillerClassFilter, 58 | FilterType::VictimClassFilter, 59 | FilterType::KillerElevationFilter, 60 | FilterType::VictimElevationFilter, 61 | FilterType::Distance2DFilter, 62 | FilterType::Distance3DFilter, 63 | FilterType::RoundFilter, 64 | FilterType::PropertyFilter, 65 | ]; 66 | } 67 | 68 | impl Default for FilterType { 69 | fn default() -> Self { 70 | FilterType::KillerTeamFilter 71 | } 72 | } 73 | 74 | impl Display for FilterType { 75 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 76 | match self { 77 | FilterType::KillerTeamFilter => write!(f, "Killer's team"), 78 | FilterType::VictimTeamFilter => write!(f, "Victim's team"), 79 | FilterType::KillerClassFilter => write!(f, "Killer's class"), 80 | FilterType::VictimClassFilter => write!(f, "Victim's class"), 81 | FilterType::KillerElevationFilter => write!(f, "Killer's elevation"), 82 | FilterType::VictimElevationFilter => write!(f, "Victim's elevation"), 83 | FilterType::Distance2DFilter => write!(f, "2D distance"), 84 | FilterType::Distance3DFilter => write!(f, "3D distance"), 85 | FilterType::RoundFilter => write!(f, "Round #"), 86 | FilterType::PropertyFilter => write!(f, "Death property"), 87 | } 88 | } 89 | } 90 | 91 | pub struct FiltersPane { 92 | pub theme: style::Theme, 93 | pub busy: bool, 94 | pub filters: Vec, 95 | scroll_state: scrollable::State, 96 | add_filter_button_state: button::State, 97 | } 98 | 99 | impl Default for FiltersPane { 100 | fn default() -> Self { 101 | let mut no_suicides_by_default = FilterRow { 102 | selected_filter: FilterType::PropertyFilter, 103 | selected_property: Property::Suicide, 104 | selected_property_operator: PropertyOperator::IsNotPresent, 105 | ..Default::default() 106 | }; 107 | no_suicides_by_default.filter = no_suicides_by_default.try_generate_filter(); 108 | let mut only_in_round_kills_by_default = FilterRow { 109 | selected_filter: FilterType::PropertyFilter, 110 | selected_property: Property::DuringRound, 111 | selected_property_operator: PropertyOperator::IsPresent, 112 | ..Default::default() 113 | }; 114 | only_in_round_kills_by_default.filter = only_in_round_kills_by_default.try_generate_filter(); 115 | Self { 116 | filters: vec![no_suicides_by_default, only_in_round_kills_by_default], 117 | theme: Default::default(), 118 | busy: Default::default(), 119 | scroll_state: Default::default(), 120 | add_filter_button_state: Default::default(), 121 | } 122 | } 123 | } 124 | 125 | impl FiltersPane { 126 | pub(crate) fn view(&mut self) -> Element { 127 | let theme = self.theme; 128 | let (filters, style): (Element<_>, _) = if self.filters.is_empty() { 129 | ( 130 | Container::new(Text::new("No filter").width(Length::Fill).size(20).horizontal_alignment(alignment::Horizontal::Center)) 131 | .width(Length::Fill) 132 | .center_y() 133 | .into(), 134 | style::ResultContainer::Ok, 135 | ) 136 | } else { 137 | let style = if self.filters.iter().all(|filter_row| filter_row.filter.is_some()) { 138 | style::ResultContainer::Ok 139 | } else { 140 | style::ResultContainer::Error 141 | }; 142 | let col = self 143 | .filters 144 | .iter_mut() 145 | .enumerate() 146 | .fold(Column::new(), |col, (index, filter_row)| col.push(filter_row.view(index, theme))); 147 | (col.into(), style) 148 | }; 149 | 150 | let add_filter_button = Button::new(&mut self.add_filter_button_state, Text::new("Add filter")) 151 | .style(self.theme) 152 | .on_press(Message::AddFilter); 153 | let header = Row::new().push(add_filter_button); 154 | let filters_scroll = Scrollable::new(&mut self.scroll_state).push(filters).width(Length::Fill).height(Length::Fill); 155 | let view = Column::new().push(header).push(filters_scroll); 156 | let result_container = Container::new(view).width(Length::Fill).height(Length::Fill).center_x().center_y().padding(10).style(style); 157 | 158 | Container::new(result_container).padding(4).width(Length::Fill).height(Length::Fill).into() 159 | } 160 | } 161 | 162 | #[derive(Default, Debug)] 163 | pub struct FilterRow { 164 | pub filter: Option, 165 | pub delete_button: button::State, 166 | pub filter_pick_list: pick_list::State, 167 | pub selected_filter: FilterType, 168 | pub class_button_state_scout: button::State, 169 | pub class_button_state_sniper: button::State, 170 | pub class_button_state_soldier: button::State, 171 | pub class_button_state_demoman: button::State, 172 | pub class_button_state_medic: button::State, 173 | pub class_button_state_heavy: button::State, 174 | pub class_button_state_pyro: button::State, 175 | pub class_button_state_spy: button::State, 176 | pub class_button_state_engineer: button::State, 177 | pub class_buttons_selected: [bool; 10], 178 | pub team_button_blu: button::State, 179 | pub team_button_red: button::State, 180 | pub team_button_selected: Team, 181 | pub ordered_operator_pick_list: pick_list::State, 182 | pub selected_ordered_operator: OrderedOperator, 183 | pub text_input_state: text_input::State, 184 | pub text_input: String, 185 | pub property_operator_pick_list: pick_list::State, 186 | pub selected_property_operator: PropertyOperator, 187 | pub property_pick_list: pick_list::State, 188 | pub selected_property: Property, 189 | } 190 | 191 | impl FilterRow { 192 | fn view(&mut self, index: usize, theme: style::Theme) -> Element { 193 | let pick_list = PickList::new(&mut self.filter_pick_list, &FilterType::ALL[..], Some(self.selected_filter), move |selected| { 194 | Message::FilterSelected(index, selected) 195 | }); 196 | 197 | let filter_options = match self.selected_filter { 198 | FilterType::KillerTeamFilter | FilterType::VictimTeamFilter => { 199 | let mut row = Row::new(); 200 | row = row.push(Button::new(&mut self.team_button_blu, Text::new("BLU")).on_press(Message::BluTeamClicked(index)).style( 201 | if self.team_button_selected == Team::Blu { 202 | ActiveButtonHighlight::Highlighted 203 | } else { 204 | ActiveButtonHighlight::NotHighlighted 205 | }, 206 | )); 207 | row = row.push(Button::new(&mut self.team_button_red, Text::new("RED")).on_press(Message::RedTeamClicked(index)).style( 208 | if self.team_button_selected == Team::Red { 209 | ActiveButtonHighlight::Highlighted 210 | } else { 211 | ActiveButtonHighlight::NotHighlighted 212 | }, 213 | )); 214 | row 215 | } 216 | FilterType::KillerClassFilter | FilterType::VictimClassFilter => { 217 | let mut row = Row::new(); 218 | row = row.push( 219 | Button::new(&mut self.class_button_state_scout, icon(CLASS_ICONS_CHARS[1])) 220 | .on_press(Message::ClassIconClicked(index, 1)) 221 | .style(if self.class_buttons_selected[1] { 222 | ActiveButtonHighlight::Highlighted 223 | } else { 224 | ActiveButtonHighlight::NotHighlighted 225 | }), 226 | ); 227 | row = row.push( 228 | Button::new(&mut self.class_button_state_soldier, icon(CLASS_ICONS_CHARS[3])) 229 | .on_press(Message::ClassIconClicked(index, 3)) 230 | .style(if self.class_buttons_selected[3] { 231 | ActiveButtonHighlight::Highlighted 232 | } else { 233 | ActiveButtonHighlight::NotHighlighted 234 | }), 235 | ); 236 | row = row.push( 237 | Button::new(&mut self.class_button_state_pyro, icon(CLASS_ICONS_CHARS[7])) 238 | .on_press(Message::ClassIconClicked(index, 7)) 239 | .style(if self.class_buttons_selected[7] { 240 | ActiveButtonHighlight::Highlighted 241 | } else { 242 | ActiveButtonHighlight::NotHighlighted 243 | }), 244 | ); 245 | row = row.push( 246 | Button::new(&mut self.class_button_state_demoman, icon(CLASS_ICONS_CHARS[4])) 247 | .on_press(Message::ClassIconClicked(index, 4)) 248 | .style(if self.class_buttons_selected[4] { 249 | ActiveButtonHighlight::Highlighted 250 | } else { 251 | ActiveButtonHighlight::NotHighlighted 252 | }), 253 | ); 254 | row = row.push( 255 | Button::new(&mut self.class_button_state_heavy, icon(CLASS_ICONS_CHARS[6])) 256 | .on_press(Message::ClassIconClicked(index, 6)) 257 | .style(if self.class_buttons_selected[6] { 258 | ActiveButtonHighlight::Highlighted 259 | } else { 260 | ActiveButtonHighlight::NotHighlighted 261 | }), 262 | ); 263 | row = row.push( 264 | Button::new(&mut self.class_button_state_engineer, icon(CLASS_ICONS_CHARS[9])) 265 | .on_press(Message::ClassIconClicked(index, 9)) 266 | .style(if self.class_buttons_selected[9] { 267 | ActiveButtonHighlight::Highlighted 268 | } else { 269 | ActiveButtonHighlight::NotHighlighted 270 | }), 271 | ); 272 | row = row.push( 273 | Button::new(&mut self.class_button_state_medic, icon(CLASS_ICONS_CHARS[5])) 274 | .on_press(Message::ClassIconClicked(index, 5)) 275 | .style(if self.class_buttons_selected[5] { 276 | ActiveButtonHighlight::Highlighted 277 | } else { 278 | ActiveButtonHighlight::NotHighlighted 279 | }), 280 | ); 281 | row = row.push( 282 | Button::new(&mut self.class_button_state_sniper, icon(CLASS_ICONS_CHARS[2])) 283 | .on_press(Message::ClassIconClicked(index, 2)) 284 | .style(if self.class_buttons_selected[2] { 285 | ActiveButtonHighlight::Highlighted 286 | } else { 287 | ActiveButtonHighlight::NotHighlighted 288 | }), 289 | ); 290 | row = row.push( 291 | Button::new(&mut self.class_button_state_spy, icon(CLASS_ICONS_CHARS[8])) 292 | .on_press(Message::ClassIconClicked(index, 8)) 293 | .style(if self.class_buttons_selected[8] { 294 | ActiveButtonHighlight::Highlighted 295 | } else { 296 | ActiveButtonHighlight::NotHighlighted 297 | }), 298 | ); 299 | row 300 | } 301 | FilterType::KillerElevationFilter | FilterType::VictimElevationFilter | FilterType::Distance2DFilter | FilterType::Distance3DFilter | FilterType::RoundFilter => { 302 | let pick_list = PickList::new( 303 | &mut self.ordered_operator_pick_list, 304 | &OrderedOperator::ALL[..], 305 | Some(self.selected_ordered_operator), 306 | move |selected| Message::OrderedOperatorSelected(index, selected), 307 | ); 308 | let text_input = TextInput::new(&mut self.text_input_state, "value", &self.text_input, move |selected| { 309 | Message::FilterTextInputChanged(index, selected) 310 | }) 311 | .size(30) 312 | .style(theme); 313 | Row::new().push(pick_list).push(text_input) 314 | } 315 | FilterType::PropertyFilter => { 316 | let property_operator_pick_list = PickList::new( 317 | &mut self.property_operator_pick_list, 318 | &PropertyOperator::ALL[..], 319 | Some(self.selected_property_operator), 320 | move |selected| Message::PropertyOperatorSelected(index, selected), 321 | ); 322 | let property_pick_list = PickList::new(&mut self.property_pick_list, &Property::ALL[..], Some(self.selected_property), move |selected| { 323 | Message::PropertySelected(index, selected) 324 | }); 325 | Row::new().push(property_pick_list).push(property_operator_pick_list) 326 | } 327 | }; 328 | 329 | let delete_button = Button::new(&mut self.delete_button, delete_icon()).style(theme).on_press(Message::FilterRemoved(index)); 330 | let row = Row::new().push(delete_button).push(pick_list).push(filter_options); 331 | let container_style = if self.filter.is_some() { 332 | style::ResultContainer::Ok 333 | } else { 334 | style::ResultContainer::Error 335 | }; 336 | let result_container = Container::new(row).width(Length::Fill).center_y().padding(4).style(container_style).into(); 337 | result_container 338 | } 339 | 340 | pub fn try_generate_filter(&mut self) -> Option { 341 | match self.selected_filter { 342 | FilterType::KillerTeamFilter => Some( 343 | KillerTeamFilter { 344 | team: match self.team_button_selected { 345 | Team::Red => Team::Red, 346 | Team::Blu => Team::Blu, 347 | _ => return None, 348 | }, 349 | } 350 | .into(), 351 | ), 352 | FilterType::VictimTeamFilter => Some( 353 | VictimTeamFilter { 354 | team: match self.team_button_selected { 355 | Team::Red => Team::Red, 356 | Team::Blu => Team::Blu, 357 | _ => return None, 358 | }, 359 | } 360 | .into(), 361 | ), 362 | FilterType::KillerClassFilter => Some( 363 | KillerClassFilter { 364 | classes: if self.class_buttons_selected.iter().any(|&b| b) { 365 | self.class_buttons_selected 366 | } else { 367 | [false, true, true, true, true, true, true, true, true, true] 368 | // none selected = all selected 369 | }, 370 | } 371 | .into(), 372 | ), 373 | FilterType::VictimClassFilter => Some( 374 | VictimClassFilter { 375 | classes: if self.class_buttons_selected.iter().any(|&b| b) { 376 | self.class_buttons_selected 377 | } else { 378 | [false, true, true, true, true, true, true, true, true, true] 379 | // none selected = all selected 380 | }, 381 | } 382 | .into(), 383 | ), 384 | FilterType::KillerElevationFilter => Some( 385 | KillerElevationFilter { 386 | op: self.selected_ordered_operator, 387 | z: match self.text_input.parse() { 388 | Ok(value) => value, 389 | Err(_) => return None, 390 | }, 391 | } 392 | .into(), 393 | ), 394 | FilterType::VictimElevationFilter => Some( 395 | VictimElevationFilter { 396 | op: self.selected_ordered_operator, 397 | z: match self.text_input.parse() { 398 | Ok(value) => value, 399 | Err(_) => return None, 400 | }, 401 | } 402 | .into(), 403 | ), 404 | FilterType::Distance2DFilter => Some( 405 | Distance2DFilter { 406 | op: self.selected_ordered_operator, 407 | distance: match self.text_input.parse() { 408 | Ok(value) => value, 409 | Err(_) => return None, 410 | }, 411 | } 412 | .into(), 413 | ), 414 | FilterType::Distance3DFilter => Some( 415 | Distance3DFilter { 416 | op: self.selected_ordered_operator, 417 | distance: match self.text_input.parse() { 418 | Ok(value) => value, 419 | Err(_) => return None, 420 | }, 421 | } 422 | .into(), 423 | ), 424 | FilterType::RoundFilter => Some( 425 | RoundFilter { 426 | op: self.selected_ordered_operator, 427 | round: match self.text_input.parse() { 428 | Ok(value) => value, 429 | Err(_) => return None, 430 | }, 431 | } 432 | .into(), 433 | ), 434 | FilterType::PropertyFilter => Some( 435 | PropertyFilter { 436 | op: self.selected_property_operator, 437 | property: self.selected_property, 438 | } 439 | .into(), 440 | ), 441 | } 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/heatmap.rs: -------------------------------------------------------------------------------- 1 | use crate::heatmap_analyser::Death; 2 | use image::{ImageBuffer, Pixel, Rgb}; 3 | use palette::{Gradient, LinSrgba}; 4 | use std::fmt::Display; 5 | 6 | pub const LEVELOVERVIEW_SCALE_MULTIPLIER: f32 = 512.0; 7 | 8 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 9 | pub enum CoordsType { 10 | ShowPos, 11 | Console, 12 | } 13 | 14 | impl Default for CoordsType { 15 | fn default() -> Self { 16 | Self::ShowPos 17 | } 18 | } 19 | 20 | impl Display for CoordsType { 21 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 | match self { 23 | CoordsType::ShowPos => write!(f, "cl_showpos"), 24 | CoordsType::Console => write!(f, "Console"), 25 | } 26 | } 27 | } 28 | 29 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 30 | pub enum HeatmapType { 31 | VictimPosition, 32 | KillerPosition, 33 | Lines, 34 | } 35 | 36 | impl Default for HeatmapType { 37 | fn default() -> Self { 38 | Self::VictimPosition 39 | } 40 | } 41 | 42 | impl Display for HeatmapType { 43 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 44 | match self { 45 | HeatmapType::VictimPosition => write!(f, "Victim position"), 46 | HeatmapType::KillerPosition => write!(f, "Killer position"), 47 | HeatmapType::Lines => write!(f, "Killer -> victim lines"), 48 | } 49 | } 50 | } 51 | 52 | #[derive(Debug)] 53 | struct HeatMapParameters { 54 | screen_width: f32, 55 | screen_height: f32, 56 | left_x: f32, 57 | right_x: f32, 58 | top_y: f32, 59 | bottom_y: f32, 60 | radius: f32, 61 | intensity: Option, 62 | use_sentry_position: bool, 63 | } 64 | 65 | #[derive(Debug)] 66 | pub struct HeatMapGenerator { 67 | params: HeatMapParameters, 68 | } 69 | 70 | impl HeatMapGenerator { 71 | pub fn new( 72 | pos_x: f32, 73 | pos_y: f32, 74 | screen_width: u32, 75 | screen_height: u32, 76 | scale: f32, 77 | coords_type: CoordsType, 78 | radius: f32, 79 | intensity: Option, 80 | use_sentry_position: bool, 81 | ) -> Self { 82 | let screen_width = screen_width as f32; 83 | let screen_height = screen_height as f32; 84 | let aspect_ratio = screen_width / screen_height; 85 | match coords_type { 86 | CoordsType::ShowPos => Self { 87 | params: HeatMapParameters { 88 | screen_width, 89 | screen_height, 90 | left_x: pos_x - scale * LEVELOVERVIEW_SCALE_MULTIPLIER * aspect_ratio, 91 | right_x: pos_x + scale * LEVELOVERVIEW_SCALE_MULTIPLIER * aspect_ratio, 92 | top_y: pos_y + scale * LEVELOVERVIEW_SCALE_MULTIPLIER, 93 | bottom_y: pos_y - scale * LEVELOVERVIEW_SCALE_MULTIPLIER, 94 | radius, 95 | intensity, 96 | use_sentry_position, 97 | }, 98 | }, 99 | CoordsType::Console => Self { 100 | params: HeatMapParameters { 101 | screen_width, 102 | screen_height, 103 | left_x: pos_x, 104 | right_x: pos_x + scale * LEVELOVERVIEW_SCALE_MULTIPLIER * aspect_ratio * 2.0, 105 | top_y: pos_y, 106 | bottom_y: pos_y - scale * LEVELOVERVIEW_SCALE_MULTIPLIER * 2.0, 107 | radius, 108 | intensity, 109 | use_sentry_position, 110 | }, 111 | }, 112 | } 113 | } 114 | 115 | pub fn generate_heatmap<'a>(&self, heatmap_type: HeatmapType, deaths: impl IntoIterator, image: &mut ImageBuffer, Vec>) { 116 | // lines 117 | if heatmap_type == HeatmapType::Lines { 118 | let line_gradient = Gradient::new(vec![ 119 | LinSrgba::new(0.0, 0.0, 1.0, 1.0), 120 | LinSrgba::new(1.0, 1.0, 0.0, 1.0), 121 | // LinSrgba::new(0.0, 0.6, 1.0, 1.0), 122 | // LinSrgba::new(0.067, 0.8, 1.0, 1.0), 123 | // LinSrgba::new(0.33, 1.0, 0.33, 1.0), 124 | // LinSrgba::new(1.0, 0.0, 0.0, 1.0), 125 | // LinSrgba::new(1.0, 0.8, 0.0, 1.0), 126 | 127 | // LinSrgba::new(1.0, 0.0, 0.0, 1.0), 128 | // LinSrgba::new(1.0, 1.0, 0.0, 1.0), 129 | // LinSrgba::new(0.0, 1.0, 0.0, 1.0), 130 | // LinSrgba::new(0.0, 1.0, 1.0, 1.0), 131 | // LinSrgba::new(0.0, 0.0, 1.0, 1.0), 132 | ]); 133 | for death in deaths { 134 | let killer_pos = if self.params.use_sentry_position { 135 | if let Some(sentry_position) = death.sentry_position { 136 | Some(sentry_position) 137 | } else { 138 | death.killer_entity_state.as_ref().map(|entity| entity.position) 139 | } 140 | } else { 141 | death.killer_entity_state.as_ref().map(|entity| entity.position) 142 | }; 143 | let victim_pos = death.victim_entity_state.as_ref().map(|entity| entity.position); 144 | if let (Some(killer_pos), Some(victim_pos)) = (killer_pos, victim_pos) { 145 | let killer_coords = self.game_coords_to_screen_coords(killer_pos.x, killer_pos.y); 146 | let victim_coords = self.game_coords_to_screen_coords(victim_pos.x, victim_pos.y); 147 | let points: Vec<((i32, i32), f32)> = line_drawing::XiaolinWu::::new(killer_coords, victim_coords).collect(); 148 | 149 | // this is needed because the line drawing algorithm doesn't always go in the start-end order, we need to check what order was used and invert the gradient as needed 150 | let (first_point_x, first_point_y) = match points.get(0) { 151 | Some(((first_point_x, first_point_y), _)) => (*first_point_x as f32, *first_point_y as f32), 152 | None => continue, 153 | }; 154 | let dist_killer_x = killer_coords.0 - first_point_x; 155 | let dist_killer_y = killer_coords.1 - first_point_y; 156 | let dist_victim_x = victim_coords.0 - first_point_x; 157 | let dist_victim_y = victim_coords.1 - first_point_y; 158 | let invert_gradient = dist_killer_x * dist_killer_x + dist_killer_y * dist_killer_y > dist_victim_x * dist_victim_x + dist_victim_y * dist_victim_y; 159 | 160 | let len = points.len() as f32; 161 | for (index, ((x, y), alpha)) in points.iter().enumerate() { 162 | let (x, y) = (*x, *y); 163 | if y < 0 || y >= image.height() as i32 || x < 0 || x >= image.width() as i32 { 164 | continue; 165 | } 166 | let color = if invert_gradient { 167 | line_gradient.get(1.0 - ((index + 1) as f32 / len)) 168 | } else { 169 | line_gradient.get((index + 1) as f32 / len) 170 | }; 171 | let pixel = image.get_pixel_mut(x as u32, y as u32); 172 | if let [r, g, b] = pixel.channels() { 173 | *pixel = Rgb::from([ 174 | ((alpha * color.red + (1.0 - alpha) * (*r as f32 / 255.0)) * 255.0) as u8, 175 | ((alpha * color.green + (1.0 - alpha) * (*g as f32 / 255.0)) * 255.0) as u8, 176 | ((alpha * color.blue + (1.0 - alpha) * (*b as f32 / 255.0)) * 255.0) as u8, 177 | ]); 178 | } else { 179 | unreachable!(); 180 | } 181 | } 182 | } 183 | } 184 | return; 185 | } 186 | 187 | // heatmap 188 | let heatmap_gradient = Gradient::new(vec![ 189 | // LinSrgba::new(0.0, 0.0, 0.0, 0.0), 190 | LinSrgba::new(0.0, 0.0, 1.0, 0.0), 191 | LinSrgba::new(0.0, 1.0, 1.0, 0.25), 192 | LinSrgba::new(0.0, 1.0, 0.0, 0.5), 193 | LinSrgba::new(1.0, 1.0, 0.0, 0.75), 194 | LinSrgba::new(1.0, 0.0, 0.0, 1.0), 195 | // LinSrgba::new(1.0, 1.0, 1.0, 1.0), 196 | ]); 197 | let nb_pixels = (image.width() * image.height()) as usize; 198 | let mut intensities = Vec::with_capacity(nb_pixels); 199 | intensities.resize_with(nb_pixels, || 0.0); 200 | let mut max_intensity = f32::NEG_INFINITY; 201 | let intensity_increment = if let Some(increment) = self.params.intensity { increment / 100.0 } else { 1.0 }; 202 | let radius = self.params.radius / 10.0; 203 | let pixels_iters = (radius * 2.0).ceil() as i32; 204 | for death in deaths { 205 | let game_coords = match (heatmap_type, self.params.use_sentry_position) { 206 | (HeatmapType::VictimPosition, _) => death.victim_entity_state.as_ref().map(|entity| entity.position), 207 | (HeatmapType::KillerPosition, false) => death.killer_entity_state.as_ref().map(|entity| entity.position), 208 | (HeatmapType::KillerPosition, true) => { 209 | if let Some(sentry_position) = death.sentry_position { 210 | Some(sentry_position) 211 | } else { 212 | death.killer_entity_state.as_ref().map(|entity| entity.position) 213 | } 214 | } 215 | (HeatmapType::Lines, _) => unreachable!(), 216 | }; 217 | if let Some(game_coords) = game_coords { 218 | let (x_f, y_f) = self.game_coords_to_screen_coords(game_coords.x, game_coords.y); 219 | let x_i = x_f.round() as i32; 220 | let y_i = y_f.round() as i32; 221 | for y_offset in -pixels_iters..pixels_iters { 222 | let y = y_i + y_offset; 223 | if y < 0 || y >= image.height() as i32 { 224 | continue; 225 | } 226 | for x_offset in -pixels_iters..pixels_iters { 227 | let x = x_i + x_offset; 228 | if x < 0 || x >= image.width() as i32 { 229 | continue; 230 | } 231 | let x_dist = x_f - x as f32; 232 | let y_dist = y_f - y as f32; 233 | let dist = (x_dist * x_dist + y_dist * y_dist).sqrt(); 234 | let intensity = intensity_increment * gaussian(dist, radius); 235 | let intensity_index = (y * image.width() as i32 + x) as usize; 236 | intensities[intensity_index] += intensity; 237 | if intensities[intensity_index] > max_intensity { 238 | max_intensity = intensities[intensity_index]; 239 | } 240 | } 241 | } 242 | } 243 | } 244 | for (pixel, base_intensity) in image.pixels_mut().zip(intensities) { 245 | let intensity = if self.params.intensity.is_none() { 246 | base_intensity * 2.0 / max_intensity // auto intensity 247 | } else { 248 | base_intensity 249 | }; 250 | let heat_color = heatmap_gradient.get(intensity); 251 | if let [r, g, b] = pixel.channels() { 252 | *pixel = Rgb::from([ 253 | ((heat_color.alpha * heat_color.red + (1.0 - heat_color.alpha) * (*r as f32 / 255.0)) * 255.0) as u8, 254 | ((heat_color.alpha * heat_color.green + (1.0 - heat_color.alpha) * (*g as f32 / 255.0)) * 255.0) as u8, 255 | ((heat_color.alpha * heat_color.blue + (1.0 - heat_color.alpha) * (*b as f32 / 255.0)) * 255.0) as u8, 256 | ]); 257 | } else { 258 | unreachable!(); 259 | } 260 | } 261 | } 262 | 263 | fn game_coords_to_screen_coords(&self, x: f32, y: f32) -> (f32, f32) { 264 | let p = &self.params; 265 | ( 266 | (x - p.left_x) / (p.right_x - p.left_x) * p.screen_width, 267 | (1.0 - (y - p.top_y)) / (p.top_y - p.bottom_y) * p.screen_height, 268 | ) 269 | } 270 | } 271 | 272 | fn gaussian(x: f32, std_dev: f32) -> f32 { 273 | (-((x * x) / (2.0 * std_dev * std_dev))).exp() 274 | } 275 | -------------------------------------------------------------------------------- /src/heatmap_analyser.rs: -------------------------------------------------------------------------------- 1 | use fnv::FnvHashMap; 2 | use num_enum::TryFromPrimitive; 3 | use serde::{Deserialize, Serialize}; 4 | use std::borrow::Borrow; 5 | use std::convert::TryFrom; 6 | use std::str::FromStr; 7 | use std::{ 8 | collections::{BTreeMap, HashMap}, 9 | num::NonZeroU32, 10 | }; 11 | use tf_demo_parser::demo::message::packetentities::{EntityId, PacketEntity}; 12 | use tf_demo_parser::demo::message::usermessage::{ChatMessageKind, SayText2Message, UserMessage}; 13 | use tf_demo_parser::demo::message::{Message, MessageType}; 14 | use tf_demo_parser::demo::packet::datatable::SendTableName; 15 | use tf_demo_parser::demo::packet::{ 16 | datatable::{ParseSendTable, ServerClass, ServerClassName}, 17 | stringtable::StringTableEntry, 18 | }; 19 | use tf_demo_parser::demo::sendprop::{SendPropIdentifier, SendPropName}; 20 | use tf_demo_parser::demo::{ 21 | gameevent_gen::{GameEvent, PlayerDeathEvent, PlayerSpawnEvent, TeamPlayRoundWinEvent}, 22 | message::packetentities::PVS, 23 | }; 24 | use tf_demo_parser::demo::{ 25 | parser::handler::{BorrowMessageHandler, MessageHandler}, 26 | sendprop::{SendProp, SendPropValue}, 27 | vector::{Vector, VectorXY}, 28 | }; 29 | use tf_demo_parser::{ParserState, ReadResult, Stream}; 30 | 31 | const MAX_PLAYER_ENTITY: u32 = 34; 32 | 33 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 34 | pub struct ChatMessage { 35 | pub kind: ChatMessageKind, 36 | pub from: String, 37 | pub text: String, 38 | pub tick: u32, 39 | } 40 | 41 | impl ChatMessage { 42 | pub fn from_message(message: &SayText2Message, tick: u32) -> Self { 43 | ChatMessage { 44 | kind: message.kind, 45 | from: message.from.clone().unwrap_or_default(), 46 | text: message.text.clone(), 47 | tick, 48 | } 49 | } 50 | } 51 | 52 | #[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Hash, TryFromPrimitive)] 53 | #[repr(u8)] 54 | pub enum Team { 55 | Other = 0, 56 | Spectator = 1, 57 | Red = 2, 58 | Blu = 3, 59 | } 60 | 61 | impl Team { 62 | pub fn new(number: U) -> Self 63 | where 64 | u8: TryFrom, 65 | { 66 | Team::try_from(u8::try_from(number).unwrap_or_default()).unwrap_or_default() 67 | } 68 | } 69 | 70 | impl Default for Team { 71 | fn default() -> Self { 72 | Team::Other 73 | } 74 | } 75 | 76 | #[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Hash, TryFromPrimitive)] 77 | #[repr(u8)] 78 | pub enum Class { 79 | Other = 0, 80 | Scout = 1, 81 | Sniper = 2, 82 | Soldier = 3, 83 | Demoman = 4, 84 | Medic = 5, 85 | Heavy = 6, 86 | Pyro = 7, 87 | Spy = 8, 88 | Engineer = 9, 89 | } 90 | 91 | impl Class { 92 | pub fn new(number: U) -> Self 93 | where 94 | u8: TryFrom, 95 | { 96 | Class::try_from(u8::try_from(number).unwrap_or_default()).unwrap_or_default() 97 | } 98 | } 99 | 100 | impl Default for Class { 101 | fn default() -> Self { 102 | Class::Other 103 | } 104 | } 105 | 106 | #[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] 107 | pub struct UserId(u32); 108 | 109 | impl From for UserId { 110 | fn from(int: u32) -> Self { 111 | UserId(int) 112 | } 113 | } 114 | 115 | impl From for UserId { 116 | fn from(int: u16) -> Self { 117 | UserId(int as u32) 118 | } 119 | } 120 | 121 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 122 | pub struct Spawn { 123 | pub user: UserId, 124 | pub class: Class, 125 | pub team: Team, 126 | pub tick: u32, 127 | } 128 | 129 | impl Spawn { 130 | pub fn from_event(event: &PlayerSpawnEvent, tick: u32) -> Self { 131 | Spawn { 132 | user: UserId::from(event.user_id), 133 | class: Class::new(event.class), 134 | team: Team::new(event.team), 135 | tick, 136 | } 137 | } 138 | } 139 | 140 | #[derive(Debug, Clone, Serialize, Deserialize)] 141 | pub struct UserInfo { 142 | pub name: String, 143 | pub user_id: UserId, 144 | pub steam_id: String, 145 | pub entity_id: Option, 146 | pub team: Team, 147 | } 148 | 149 | impl PartialEq for UserInfo { 150 | fn eq(&self, other: &UserInfo) -> bool { 151 | self.name == other.name && self.user_id == other.user_id && self.steam_id == other.steam_id && self.team == other.team 152 | } 153 | } 154 | 155 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 156 | pub struct Death { 157 | pub weapon: String, 158 | pub victim: UserId, 159 | pub victim_steamid: String, 160 | pub victim_entity: u32, 161 | pub victim_entity_state: Option, 162 | pub assister: Option, 163 | pub assister_steamid: Option, 164 | pub killer: UserId, 165 | pub killer_steamid: String, 166 | pub killer_entity: u32, // probably the projectile entity rather than the killer'sm unless it's hitscan? 167 | pub killer_entity_state: Option, 168 | pub tick: u32, 169 | pub round: u32, 170 | pub during_round: bool, 171 | pub sentry_position: Option, 172 | } 173 | 174 | impl Death { 175 | pub fn from_event(event: &PlayerDeathEvent, tick: u32, users: &BTreeMap, round: u32, during_round: bool) -> Self { 176 | let (assister, assister_steamid) = if event.assister < (16 * 1024) { 177 | let assister = UserId::from(event.assister); 178 | (Some(assister), Some(users.get(&assister).expect("Can't get assister").steam_id.clone())) 179 | } else { 180 | (None, None) 181 | }; 182 | let killer = UserId::from(if event.attacker == 0 { 183 | event.user_id // if world killed the player, count it as a suicide 184 | } else { 185 | event.attacker 186 | }); 187 | let victim = UserId::from(event.user_id); 188 | Death { 189 | assister, 190 | assister_steamid, 191 | tick, 192 | round, 193 | during_round, 194 | killer, 195 | killer_steamid: users.get(&killer).expect("Can't get killer").steam_id.clone(), 196 | killer_entity: if event.attacker == 0 { 197 | event.victim_ent_index // if world killed the player, count it as a suicide 198 | } else { 199 | event.inflictor_ent_index 200 | }, 201 | killer_entity_state: None, 202 | weapon: event.weapon.clone(), 203 | victim, 204 | victim_steamid: users.get(&victim).expect("Can't get victim").steam_id.clone(), 205 | victim_entity: event.victim_ent_index, 206 | victim_entity_state: None, 207 | sentry_position: None, 208 | } 209 | } 210 | } 211 | 212 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 213 | pub struct Round { 214 | pub winner: Team, 215 | length: f32, 216 | end_tick: u32, 217 | } 218 | 219 | impl Round { 220 | pub fn from_event(event: &TeamPlayRoundWinEvent, tick: u32) -> Self { 221 | Round { 222 | winner: Team::new(event.team), 223 | length: event.round_time, 224 | end_tick: tick, 225 | } 226 | } 227 | } 228 | 229 | #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] 230 | pub struct World { 231 | boundary_min: Vector, 232 | boundary_max: Vector, 233 | } 234 | 235 | #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq)] 236 | pub struct HeatmapAnalyser { 237 | pub state: HeatmapAnalysis, 238 | prop_names: FnvHashMap, 239 | user_id_map: HashMap, 240 | class_names: Vec, // indexed by ClassId 241 | } 242 | 243 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] 244 | pub enum PlayerState { 245 | Alive = 0, 246 | Dying = 1, 247 | Death = 2, 248 | Respawnable = 3, 249 | } 250 | 251 | impl PlayerState { 252 | pub fn new(number: i64) -> Self { 253 | match number { 254 | 1 => PlayerState::Dying, 255 | 2 => PlayerState::Death, 256 | 3 => PlayerState::Respawnable, 257 | _ => PlayerState::Alive, 258 | } 259 | } 260 | } 261 | 262 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 263 | pub struct PlayerEntity { 264 | pub entity: EntityId, 265 | pub position: Vector, 266 | pub health: u16, 267 | pub max_health: u16, 268 | pub class: Class, 269 | pub team: Team, 270 | pub view_angle_horizontal: f32, 271 | pub view_angle_vertical: f32, 272 | pub state: PlayerState, 273 | } 274 | 275 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 276 | pub enum OtherEntity { 277 | Sentry { position: Option }, 278 | SentryRocket { sentry: Option }, 279 | } 280 | 281 | impl MessageHandler for HeatmapAnalyser { 282 | type Output = HeatmapAnalysis; 283 | 284 | fn does_handle(message_type: MessageType) -> bool { 285 | match message_type { 286 | MessageType::GameEvent | MessageType::UserMessage | MessageType::ServerInfo | MessageType::PacketEntities => true, 287 | _ => false, 288 | } 289 | } 290 | 291 | fn handle_message(&mut self, message: &Message, tick: u32) { 292 | if self.state.tick_offset == 0 && tick != 0 { 293 | self.state.tick_offset = tick - 1; 294 | } 295 | self.state.current_tick = tick - self.state.tick_offset; // first tick = start of the demo rather than map change 296 | match message { 297 | Message::ServerInfo(message) => self.state.interval_per_tick = message.interval_per_tick, 298 | Message::GameEvent(message) => self.handle_event(&message.event, tick), 299 | Message::UserMessage(message) => self.handle_user_message(&message, tick), 300 | Message::PacketEntities(message) => { 301 | for entity in &message.entities { 302 | if entity.pvs == PVS::Delete { 303 | let removed_entity = entity.entity_index; 304 | let _removed = self.state.other_entities.remove(&removed_entity); 305 | } else { 306 | self.handle_entity(entity); 307 | } 308 | } 309 | for removed_entity in &message.removed_entities { 310 | let _removed = self.state.other_entities.remove(removed_entity); 311 | } 312 | } 313 | _ => unreachable!(), 314 | } 315 | } 316 | 317 | fn handle_string_entry(&mut self, table: &str, _index: usize, entry: &StringTableEntry) { 318 | match table { 319 | "userinfo" => { 320 | let _ = self.parse_user_info(entry.text.as_ref().map(|s| s.borrow()), entry.extra_data.as_ref().map(|data| data.data.clone())); 321 | } 322 | _ => {} 323 | } 324 | } 325 | 326 | fn handle_data_tables(&mut self, tables: &[ParseSendTable], server_classes: &[ServerClass]) { 327 | self.class_names = server_classes.iter().map(|class| &class.name).cloned().collect(); 328 | 329 | for table in tables { 330 | for prop_def in &table.props { 331 | self.prop_names.insert(prop_def.identifier(), (table.name.clone(), prop_def.name.clone())); 332 | } 333 | } 334 | } 335 | 336 | fn into_output(self, _state: &ParserState) -> Self::Output { 337 | self.state 338 | } 339 | } 340 | 341 | impl BorrowMessageHandler for HeatmapAnalyser { 342 | fn borrow_output(&self, _state: &ParserState) -> &Self::Output { 343 | &self.state 344 | } 345 | } 346 | 347 | impl HeatmapAnalyser { 348 | pub fn handle_entity(&mut self, entity: &PacketEntity) { 349 | let class_name: &str = self.class_names.get(usize::from(entity.server_class)).map(|class_name| class_name.as_str()).unwrap_or(""); 350 | match class_name { 351 | "CTFPlayer" => self.handle_player_entity(entity), 352 | "CTFPlayerResource" => self.handle_player_resource(entity), 353 | "CWorld" => self.handle_world_entity(entity), 354 | "CObjectSentrygun" => self.handle_sentry_entity(entity), 355 | "CTFProjectile_SentryRocket" => self.handle_sentry_rocket_entity(entity), 356 | _ => {} 357 | } 358 | } 359 | 360 | pub fn handle_player_resource(&mut self, entity: &PacketEntity) { 361 | for prop in entity.props() { 362 | if let Some((table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 363 | if let Ok(player_id) = u32::from_str(prop_name.as_str()) { 364 | let entity_id = EntityId::from(player_id); 365 | if let Some(player) = self.state.player_entities.iter_mut().find(|player| player.entity == entity_id) { 366 | match table_name.as_str() { 367 | "m_iTeam" => player.team = Team::new(i64::try_from(&prop.value).unwrap_or_default()), 368 | "m_iMaxHealth" => player.max_health = i64::try_from(&prop.value).unwrap_or_default() as u16, 369 | "m_iPlayerClass" => player.class = Class::new(i64::try_from(&prop.value).unwrap_or_default()), 370 | _ => {} 371 | } 372 | } 373 | } 374 | } 375 | } 376 | } 377 | 378 | pub fn handle_player_entity(&mut self, entity: &PacketEntity) { 379 | let player = self.state.get_or_create_player_entity(entity.entity_index); 380 | 381 | for prop in entity.props() { 382 | if let Some((table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 383 | match table_name.as_str() { 384 | "DT_BasePlayer" => match prop_name.as_str() { 385 | "m_iHealth" => player.health = i64::try_from(&prop.value).unwrap_or_default() as u16, 386 | "m_iMaxHealth" => player.max_health = i64::try_from(&prop.value).unwrap_or_default() as u16, 387 | "m_lifeState" => player.state = PlayerState::new(i64::try_from(&prop.value).unwrap_or_default()), 388 | _ => {} 389 | }, 390 | "DT_TFLocalPlayerExclusive" | "DT_TFNonLocalPlayerExclusive" => match prop_name.as_str() { 391 | "m_vecOrigin" => { 392 | let pos_xy = VectorXY::try_from(&prop.value).unwrap_or_default(); 393 | player.position.x = pos_xy.x; 394 | player.position.y = pos_xy.y; 395 | } 396 | "m_vecOrigin[2]" => player.position.z = f32::try_from(&prop.value).unwrap_or_default(), 397 | "m_angEyeAngles[0]" => player.view_angle_vertical = f32::try_from(&prop.value).unwrap_or_default(), 398 | "m_angEyeAngles[1]" => player.view_angle_horizontal = f32::try_from(&prop.value).unwrap_or_default(), 399 | _ => {} 400 | }, 401 | _ => {} 402 | } 403 | } 404 | } 405 | } 406 | 407 | pub fn handle_world_entity(&mut self, entity: &PacketEntity) { 408 | if let ( 409 | Some(SendProp { 410 | value: SendPropValue::Vector(boundary_min), 411 | .. 412 | }), 413 | Some(SendProp { 414 | value: SendPropValue::Vector(boundary_max), 415 | .. 416 | }), 417 | ) = (entity.get_prop_by_name("DT_WORLD", "m_WorldMins"), entity.get_prop_by_name("DT_WORLD", "m_WorldMaxs")) 418 | { 419 | self.state.world = Some(World { 420 | boundary_min: boundary_min.clone(), 421 | boundary_max: boundary_max.clone(), 422 | }) 423 | } 424 | } 425 | 426 | fn handle_sentry_entity(&mut self, entity: &PacketEntity) { 427 | for prop in entity.props() { 428 | if let Some((_table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 429 | let entry = self 430 | .state 431 | .other_entities 432 | .entry(entity.entity_index) 433 | .or_insert_with(|| OtherEntity::Sentry { position: None }); 434 | let mut position = if let OtherEntity::Sentry { position } = *entry { position } else { None }; 435 | match prop_name.as_str() { 436 | "m_vecOrigin" => position = Some(Vector::try_from(&prop.value).unwrap_or_default()), 437 | _ => {} 438 | } 439 | *entry = OtherEntity::Sentry { position }; 440 | } 441 | } 442 | } 443 | 444 | fn handle_sentry_rocket_entity(&mut self, entity: &PacketEntity) { 445 | for prop in entity.props() { 446 | if let Some((_table_name, prop_name)) = self.prop_names.get(&prop.identifier) { 447 | let entry = self 448 | .state 449 | .other_entities 450 | .entry(entity.entity_index) 451 | .or_insert_with(|| OtherEntity::SentryRocket { sentry: None }); 452 | let mut sentry = if let OtherEntity::SentryRocket { sentry } = *entry { sentry } else { None }; 453 | match prop_name.as_str() { 454 | "m_hOwnerEntity" => { 455 | let handle = i64::try_from(&prop.value).unwrap_or_default(); 456 | let entity_id = handle_to_entity_index(handle); 457 | sentry = entity_id.map(|id| id.get().into()); 458 | } 459 | _ => {} 460 | } 461 | *entry = OtherEntity::SentryRocket { sentry }; 462 | } 463 | } 464 | } 465 | 466 | fn handle_user_message(&mut self, message: &UserMessage, tick: u32) { 467 | if let UserMessage::SayText2(text_message) = message { 468 | if text_message.kind == ChatMessageKind::NameChange { 469 | if let Some(from) = text_message.from.clone() { 470 | self.change_name(from, text_message.text.clone()); 471 | } 472 | } else { 473 | self.state.chat.push(ChatMessage::from_message(text_message, tick)); 474 | } 475 | } 476 | } 477 | 478 | fn change_name(&mut self, from: String, to: String) { 479 | if let Some(user) = self.state.users.values_mut().find(|user| user.name == from) { 480 | user.name = to; 481 | } 482 | } 483 | 484 | fn handle_event(&mut self, event: &GameEvent, tick: u32) { 485 | const WIN_REASON_TIME_LIMIT: u8 = 6; 486 | 487 | match event { 488 | GameEvent::PlayerDeath(event) => { 489 | let round = self.state.rounds.len() as u32 + 1; 490 | let mut death = Death::from_event(event, tick, &self.state.users, round, self.state.in_round); 491 | let killer = self.state.users.get_mut(&death.killer).expect("got a kill from unknown user"); 492 | if death.killer_entity < MAX_PLAYER_ENTITY { 493 | killer.entity_id = Some(EntityId::from(death.killer_entity)); 494 | } 495 | if let Some(killer_entity) = killer.entity_id { 496 | death.killer_entity_state = Some(self.state.get_or_create_player_entity(killer_entity).clone()); 497 | } 498 | let victim = self.state.users.get_mut(&death.victim).expect("got a kill on unknown user"); 499 | if death.victim_entity < MAX_PLAYER_ENTITY { 500 | victim.entity_id = Some(EntityId::from(death.victim_entity)); 501 | } 502 | if let Some(victim_entity) = victim.entity_id { 503 | death.victim_entity_state = Some(self.state.get_or_create_player_entity(victim_entity).clone()); 504 | } 505 | match death.weapon.as_str() { 506 | "obj_sentrygun" | "obj_sentrygun2" | "obj_sentrygun3" | "obj_minisentry" => { 507 | if let Some(entity) = self.state.other_entities.get(&death.killer_entity.into()) { 508 | match entity { 509 | OtherEntity::Sentry { position } => { 510 | death.sentry_position = *position; 511 | } 512 | OtherEntity::SentryRocket { sentry } => { 513 | if let Some(sentry_entity) = sentry { 514 | if let Some(entity) = self.state.other_entities.get(sentry_entity) { 515 | match entity { 516 | OtherEntity::Sentry { position } => { 517 | death.sentry_position = *position; 518 | } 519 | _ => {} 520 | } 521 | } 522 | } 523 | } 524 | } 525 | } 526 | } 527 | _ => {} 528 | } 529 | self.state.deaths.push(death); 530 | } 531 | GameEvent::PlayerSpawn(event) => { 532 | let spawn = Spawn::from_event(event, tick); 533 | if let Some(user_state) = self.state.users.get_mut(&spawn.user) { 534 | user_state.team = spawn.team; 535 | } 536 | } 537 | GameEvent::TeamPlayRoundStart(_event) => { 538 | self.state.in_round = true; 539 | } 540 | GameEvent::TeamPlayRoundWin(event) => { 541 | self.state.in_round = false; 542 | if event.win_reason != WIN_REASON_TIME_LIMIT { 543 | self.state.rounds.push(Round::from_event(event, tick)) 544 | } 545 | } 546 | _ => {} 547 | } 548 | } 549 | 550 | fn parse_user_info(&mut self, text: Option<&str>, data: Option) -> ReadResult<()> { 551 | if let Some(mut data) = data { 552 | let name: String = data.read_sized(32).unwrap_or_else(|_| "Malformed Name".into()); 553 | let user_id: UserId = data.read::()?.into(); 554 | let steam_id: String = data.read()?; 555 | 556 | let entity_id = if let Some(slot_id) = text { 557 | Some((slot_id.parse::().expect("can't parse player slot") + 1).into()) 558 | } else { 559 | None 560 | }; 561 | 562 | if !steam_id.is_empty() { 563 | self.state 564 | .users 565 | .entry(user_id) 566 | .and_modify(|info| { 567 | if entity_id != None { 568 | info.entity_id = entity_id; 569 | } 570 | }) 571 | .or_insert_with(|| UserInfo { 572 | team: Team::Other, 573 | steam_id, 574 | user_id, 575 | name, 576 | entity_id: entity_id, 577 | }); 578 | } 579 | } 580 | 581 | Ok(()) 582 | } 583 | } 584 | 585 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 586 | pub struct HeatmapAnalysis { 587 | pub tick_offset: u32, 588 | pub current_tick: u32, 589 | pub interval_per_tick: f32, 590 | pub chat: Vec, 591 | pub users: BTreeMap, 592 | pub deaths: Vec, 593 | pub rounds: Vec, 594 | pub in_round: bool, 595 | 596 | pub player_entities: Vec, 597 | pub other_entities: HashMap, 598 | pub world: Option, 599 | pub map: String, 600 | } 601 | 602 | impl Default for HeatmapAnalysis { 603 | fn default() -> Self { 604 | Self { 605 | chat: Default::default(), 606 | users: { 607 | let mut users = BTreeMap::new(); 608 | let world = UserInfo { 609 | entity_id: Some(EntityId::from(0)), 610 | name: "world".into(), 611 | user_id: UserId::from(0u32), 612 | steam_id: "".into(), 613 | team: Team::default(), 614 | }; 615 | users.insert(UserId::from(0u32), world); 616 | users 617 | }, 618 | deaths: Default::default(), 619 | rounds: Default::default(), 620 | in_round: Default::default(), 621 | tick_offset: Default::default(), 622 | current_tick: Default::default(), 623 | interval_per_tick: Default::default(), 624 | player_entities: { 625 | let mut player_entities = Vec::new(); 626 | let world = PlayerEntity { 627 | class: Class::default(), 628 | entity: EntityId::from(0), 629 | position: Vector::default(), 630 | health: 0, 631 | max_health: 0, 632 | team: Team::default(), 633 | state: PlayerState::Alive, 634 | view_angle_horizontal: 0.0, 635 | view_angle_vertical: 0.0, 636 | }; 637 | player_entities.push(world); 638 | player_entities 639 | }, 640 | other_entities: Default::default(), 641 | world: Default::default(), 642 | map: Default::default(), 643 | } 644 | } 645 | } 646 | 647 | impl HeatmapAnalysis { 648 | pub fn get_or_create_player_entity(&mut self, entity_id: EntityId) -> &mut PlayerEntity { 649 | let index = match self 650 | .player_entities 651 | .iter_mut() 652 | .enumerate() 653 | .find(|(_index, player)| player.entity == entity_id) 654 | .map(|(index, _)| index) 655 | { 656 | Some(index) => index, 657 | None => { 658 | let player = PlayerEntity { 659 | entity: entity_id, 660 | position: Vector::default(), 661 | health: 0, 662 | max_health: 0, 663 | class: Class::Other, 664 | team: Team::Other, 665 | view_angle_horizontal: 0.0, 666 | view_angle_vertical: 0.0, 667 | state: PlayerState::Alive, 668 | }; 669 | 670 | let index = self.player_entities.len(); 671 | self.player_entities.push(player); 672 | index 673 | } 674 | }; 675 | &mut self.player_entities[index] 676 | } 677 | } 678 | 679 | pub fn handle_to_entity_index(handle: i64) -> Option { 680 | let ret = handle as u32 & 0b111_1111_1111; // The rest of the bits is probably some kind of generational index 681 | if ret == 2047 { 682 | return None; 683 | } 684 | NonZeroU32::new(ret) 685 | } 686 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod filters; 2 | pub mod heatmap; 3 | pub mod heatmap_analyser; 4 | 5 | use heatmap_analyser::{Death, HeatmapAnalyser, HeatmapAnalysis}; 6 | use image::{ImageBuffer, Rgb}; 7 | use rayon::prelude::*; 8 | use std::{fs, path::PathBuf}; 9 | 10 | use heatmap::{CoordsType, HeatmapType}; 11 | use tf_demo_parser::{Demo, DemoParser}; 12 | 13 | #[derive(Debug, Clone, Default)] 14 | pub struct DemoProcessingOutput { 15 | pub path: PathBuf, 16 | pub heatmap_analysis: Option, 17 | pub error: Option, 18 | pub map: String, 19 | } 20 | 21 | pub fn process_demos(inputs: Vec) -> Vec { 22 | inputs 23 | .par_iter() 24 | .map(|path| { 25 | let file = match fs::read(&path) { 26 | Ok(file) => file, 27 | Err(err) => { 28 | return DemoProcessingOutput { 29 | path: path.clone(), 30 | heatmap_analysis: None, 31 | error: Some(err.to_string()), 32 | map: String::new(), 33 | } 34 | } 35 | }; 36 | let demo = Demo::owned(file); 37 | let (header, mut ticker) = DemoParser::new_with_analyser(demo.get_stream(), HeatmapAnalyser::default()).ticker().unwrap(); 38 | loop { 39 | match ticker.tick() { 40 | Ok(true) => continue, 41 | Ok(false) => { 42 | break DemoProcessingOutput { 43 | path: path.clone(), 44 | heatmap_analysis: Some(ticker.into_state()), 45 | error: None, 46 | map: header.map, 47 | } 48 | } 49 | Err(_err) => { 50 | let heatmap_analysis = ticker.into_state(); 51 | let error = Some(format!( 52 | "{}: Demo is corrupted, could only analyse up to tick {}", 53 | path.to_string_lossy(), 54 | heatmap_analysis.current_tick 55 | )); 56 | break DemoProcessingOutput { 57 | path: path.clone(), 58 | heatmap_analysis: Some(heatmap_analysis), 59 | map: header.map, 60 | error, 61 | }; 62 | } 63 | }; 64 | } 65 | }) 66 | .collect() 67 | } 68 | 69 | pub fn generate_heatmap<'a>( 70 | heatmap_type: HeatmapType, 71 | deaths: impl IntoIterator, 72 | mut image: ImageBuffer, Vec>, 73 | screen_width: u32, 74 | screen_height: u32, 75 | pos_x: f32, 76 | pos_y: f32, 77 | scale: f32, 78 | coords_type: CoordsType, 79 | radius: f32, 80 | intensity: Option, 81 | use_sentry_position: bool, 82 | ) -> ImageBuffer, Vec> { 83 | let heatmap_generator = heatmap::HeatMapGenerator::new(pos_x, pos_y, screen_width, screen_height, scale, coords_type, radius, intensity, use_sentry_position); 84 | heatmap_generator.generate_heatmap(heatmap_type, deaths, &mut image); 85 | image 86 | } 87 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use chat::format_chat_messages; 2 | use coldmaps::*; 3 | 4 | mod chat; 5 | mod demo_player; 6 | mod demostf; 7 | mod gui_filters; 8 | mod style; 9 | 10 | use filters::{FilterTrait, OrderedOperator, Property, PropertyOperator}; 11 | use gui_filters::{FilterType, FiltersPane}; 12 | use heatmap::{CoordsType, HeatmapType}; 13 | use heatmap_analyser::{HeatmapAnalysis, Team}; 14 | use iced::{ 15 | alignment, button, executor, image::Handle, pane_grid, scrollable, slider, text_input, window, Application, Button, Checkbox, Column, Command, Container, Element, Font, Image, 16 | Length, Point, Radio, Rectangle, Row, Scrollable, Settings, Size, Slider, Subscription, Text, TextInput, 17 | }; 18 | use image::{io::Reader, ImageBuffer, Pixel, Rgb, RgbImage}; 19 | use pane_grid::{Axis, Pane}; 20 | use rfd::AsyncFileDialog; 21 | use std::{mem, path::PathBuf, time::Instant}; 22 | 23 | const VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); 24 | 25 | const ICONS: Font = Font::External { 26 | name: "Icons", 27 | bytes: include_bytes!("../fonts/icons.ttf"), 28 | }; 29 | 30 | fn icon(unicode: char, color: [f32; 3]) -> Text { 31 | Text::new(&unicode.to_string()) 32 | .font(ICONS) 33 | .color(color) 34 | .horizontal_alignment(alignment::Horizontal::Center) 35 | .size(20) 36 | } 37 | 38 | fn delete_icon() -> Text { 39 | icon('\u{F1F8}', [1.0, 0.0, 0.0]) 40 | } 41 | 42 | fn chat_preview_icon() -> Text { 43 | icon('\u{E802}', [1.0, 1.0, 1.0]) 44 | } 45 | 46 | pub fn main() -> Result<(), iced::Error> { 47 | let args: Vec = std::env::args().collect(); 48 | if let Some(arg) = args.get(1) { 49 | if arg == "--demoplayer" { 50 | demo_player::run().unwrap(); 51 | return Ok(()); 52 | } 53 | } 54 | App::run(Settings { 55 | antialiasing: true, 56 | window: window::Settings { 57 | size: (1280, 720), 58 | ..Default::default() 59 | }, 60 | ..Default::default() 61 | }) 62 | } 63 | 64 | struct App { 65 | pane_grid_state: pane_grid::State, 66 | theme: style::Theme, 67 | busy: bool, 68 | // TODO visual indicator? 69 | dropped_files: Vec, 70 | demos_pane: Pane, 71 | filters_pane: Pane, 72 | settings_pane: Pane, 73 | preview_pane: Pane, 74 | log_pane: Pane, 75 | } 76 | 77 | struct HeatmapImage { 78 | image: ImageBuffer, Vec>, 79 | image_with_heatmap_overlay: ImageBuffer, Vec>, 80 | handle: Handle, 81 | } 82 | 83 | #[derive(Debug)] 84 | struct DemoFile { 85 | _path: PathBuf, 86 | file_name: String, 87 | delete_button: button::State, 88 | chat_preview_button: button::State, 89 | heatmap_analysis: HeatmapAnalysis, 90 | } 91 | 92 | #[derive(Debug, Clone)] 93 | enum Message { 94 | WindowEventOccurred(iced_native::Event), 95 | PaneResized(pane_grid::ResizeEvent), 96 | DemoRemoved(usize), 97 | ChatPreview(usize), 98 | ThemeChanged(style::Theme), 99 | CoordsTypeChanged(CoordsType), 100 | HeatmapTypeChanged(HeatmapType), 101 | XPosInputChanged(String), 102 | YPosInputChanged(String), 103 | ScaleInputChanged(String), 104 | AutoIntensityCheckboxToggled(bool), 105 | UseSentryPositionCheckboxToggled(bool), 106 | IntensityChanged(f32), 107 | RadiusChanged(f32), 108 | DesaturateChanged(f32), 109 | ProcessDemosDone(TimedResult>), 110 | ExportImagePressed, 111 | ImageNameSelected(Option), 112 | EndOfDemoFilesDrop(()), 113 | MapSet(String), 114 | DemosTFImageLoader(Option<(RgbImage, f32, f32, f32)>), 115 | LevelImageSet(RgbImage), 116 | AddFilter, 117 | FilterSelected(usize, FilterType), 118 | ClassIconClicked(usize, usize), 119 | BluTeamClicked(usize), 120 | RedTeamClicked(usize), 121 | OrderedOperatorSelected(usize, OrderedOperator), 122 | PropertyOperatorSelected(usize, PropertyOperator), 123 | PropertySelected(usize, Property), 124 | FilterTextInputChanged(usize, String), 125 | FilterRemoved(usize), 126 | } 127 | 128 | #[derive(Debug, Clone)] 129 | struct TimedResult { 130 | result: T, 131 | time_elapsed: f32, 132 | } 133 | 134 | enum PaneState { 135 | DemoList(DemoList), 136 | FiltersPane(FiltersPane), 137 | SettingsPane(SettingsPane), 138 | Preview(Preview), 139 | LogPane(LogPane), 140 | } 141 | 142 | impl PaneState { 143 | fn view(&mut self) -> Element { 144 | match self { 145 | PaneState::DemoList(pane) => pane.view(), 146 | PaneState::FiltersPane(pane) => pane.view(), 147 | PaneState::SettingsPane(pane) => pane.view(), 148 | PaneState::Preview(pane) => pane.view(), 149 | PaneState::LogPane(pane) => pane.view(), 150 | } 151 | } 152 | } 153 | 154 | #[derive(Default)] 155 | struct DemoList { 156 | theme: style::Theme, 157 | busy: bool, 158 | scroll_state: scrollable::State, 159 | demo_files: Vec, 160 | } 161 | 162 | impl DemoList { 163 | fn view(&mut self) -> Element { 164 | let (demos_list, style): (Element<_>, _) = if self.demo_files.is_empty() { 165 | ( 166 | Container::new( 167 | Text::new("Drag and drop demo files to add them") 168 | .width(Length::Fill) 169 | .size(24) 170 | .horizontal_alignment(alignment::Horizontal::Center), 171 | ) 172 | .width(Length::Fill) 173 | .into(), 174 | style::ResultContainer::Error, 175 | ) 176 | } else { 177 | let theme = self.theme; 178 | ( 179 | self.demo_files 180 | .iter_mut() 181 | .enumerate() 182 | .fold(Column::new().spacing(10), |column, (index, demo)| { 183 | let delete_button = Button::new(&mut demo.delete_button, delete_icon()).style(theme).on_press(Message::DemoRemoved(index)); 184 | let chat_preview_button = Button::new(&mut demo.chat_preview_button, chat_preview_icon()) 185 | .style(theme) 186 | .on_press(Message::ChatPreview(index)); 187 | let row = Row::new() 188 | .spacing(2) 189 | .push(delete_button) 190 | .push(chat_preview_button) 191 | .push(Text::new(&demo.file_name).size(20)); 192 | column.push(row) 193 | }) 194 | .into(), 195 | style::ResultContainer::Ok, 196 | ) 197 | }; 198 | let demos_scroll = Scrollable::new(&mut self.scroll_state).push(demos_list).width(Length::Fill).height(Length::Fill); 199 | 200 | let result_container = Container::new(demos_scroll) 201 | .width(Length::Fill) 202 | .height(Length::Fill) 203 | .center_x() 204 | .center_y() 205 | .padding(10) 206 | .style(style); 207 | 208 | Container::new(result_container).padding(4).width(Length::Fill).height(Length::Fill).into() 209 | } 210 | } 211 | 212 | struct SettingsPane { 213 | theme: style::Theme, 214 | busy: bool, 215 | scroll_state: scrollable::State, 216 | x_pos_input_state: text_input::State, 217 | x_pos_input: String, 218 | x_pos: Option, 219 | y_pos_input_state: text_input::State, 220 | y_pos_input: String, 221 | y_pos: Option, 222 | scale_input_state: text_input::State, 223 | scale_input: String, 224 | scale: Option, 225 | export_image_button: button::State, 226 | image_ready: bool, 227 | coords_type: CoordsType, 228 | heatmap_type: HeatmapType, 229 | auto_intensity: bool, 230 | use_sentry_position: bool, 231 | intensity_state: slider::State, 232 | intensity: f32, 233 | radius_state: slider::State, 234 | radius: f32, 235 | desaturate_state: slider::State, 236 | desaturate: f32, 237 | } 238 | 239 | impl Default for SettingsPane { 240 | fn default() -> Self { 241 | Self { 242 | theme: Default::default(), 243 | busy: Default::default(), 244 | scroll_state: Default::default(), 245 | x_pos_input_state: Default::default(), 246 | x_pos_input: "0".into(), 247 | x_pos: Some(0.0), 248 | y_pos_input_state: Default::default(), 249 | y_pos_input: "0".into(), 250 | y_pos: Some(0.0), 251 | scale_input_state: Default::default(), 252 | scale_input: Default::default(), 253 | scale: Default::default(), 254 | export_image_button: Default::default(), 255 | image_ready: Default::default(), 256 | coords_type: Default::default(), 257 | heatmap_type: Default::default(), 258 | auto_intensity: true, 259 | use_sentry_position: true, 260 | intensity_state: Default::default(), 261 | intensity: 50.0, 262 | radius_state: Default::default(), 263 | radius: 50.0, 264 | desaturate_state: Default::default(), 265 | desaturate: 0.0, 266 | } 267 | } 268 | } 269 | 270 | impl SettingsPane { 271 | fn view(&mut self) -> Element { 272 | let style = if self.x_pos.is_some() && self.y_pos.is_some() && self.scale.is_some() { 273 | style::ResultContainer::Ok 274 | } else { 275 | style::ResultContainer::Error 276 | }; 277 | let choose_theme = style::Theme::ALL.iter().fold(Column::new().spacing(10).push(Text::new("Theme:")), |column, theme| { 278 | column.push(Radio::new(*theme, &format!("{:?}", theme), Some(self.theme), Message::ThemeChanged).style(self.theme)) 279 | }); 280 | let choose_coords_type = [CoordsType::ShowPos, CoordsType::Console] 281 | .iter() 282 | .fold(Column::new().spacing(10).push(Text::new("Coordinates origin:")), |column, coords_type| { 283 | column.push(Radio::new(*coords_type, &format!("{}", coords_type), Some(self.coords_type), Message::CoordsTypeChanged).style(self.theme)) 284 | }); 285 | let choose_heatmap_type = [HeatmapType::VictimPosition, HeatmapType::KillerPosition, HeatmapType::Lines] 286 | .iter() 287 | .fold(Column::new().spacing(10).push(Text::new("Heatmap type:")), |column, heatmap_type| { 288 | column.push(Radio::new(*heatmap_type, &format!("{}", heatmap_type), Some(self.heatmap_type), Message::HeatmapTypeChanged).style(self.theme)) 289 | }); 290 | 291 | let x_pos_input = TextInput::new(&mut self.x_pos_input_state, "Camera x position", &self.x_pos_input, Message::XPosInputChanged).style(self.theme); 292 | let x_pos_style = if self.x_pos.is_some() { 293 | style::ResultContainer::Ok 294 | } else { 295 | style::ResultContainer::Error 296 | }; 297 | let x_pos_border = Container::new(x_pos_input).padding(3).width(Length::Fill).style(x_pos_style); 298 | 299 | let y_pos_input = TextInput::new(&mut self.y_pos_input_state, "Camera y position", &self.y_pos_input, Message::YPosInputChanged).style(self.theme); 300 | let y_pos_style = if self.y_pos.is_some() { 301 | style::ResultContainer::Ok 302 | } else { 303 | style::ResultContainer::Error 304 | }; 305 | let y_pos_border = Container::new(y_pos_input).padding(3).width(Length::Fill).style(y_pos_style); 306 | 307 | let scale_input = TextInput::new(&mut self.scale_input_state, "Camera scale", &self.scale_input, Message::ScaleInputChanged).style(self.theme); 308 | let scale_style = if self.scale.is_some() { 309 | style::ResultContainer::Ok 310 | } else { 311 | style::ResultContainer::Error 312 | }; 313 | let scale_border = Container::new(scale_input).padding(3).width(Length::Fill).style(scale_style); 314 | let mut export_image_button = Button::new(&mut self.export_image_button, Text::new("Export image")) 315 | .padding(10) 316 | .style(self.theme) 317 | .width(Length::Fill); 318 | if self.image_ready { 319 | export_image_button = export_image_button.on_press(Message::ExportImagePressed); 320 | } 321 | 322 | let coords_label = match self.coords_type { 323 | CoordsType::ShowPos => "Camera coordinates (use cl_showpos)", 324 | CoordsType::Console => "Camera coordinates (use the console)", 325 | }; 326 | 327 | let mut heatmap_options = Column::new().spacing(10); 328 | if self.heatmap_type != HeatmapType::Lines { 329 | let intensity_text = if self.auto_intensity { 330 | "Heatmap intensity: Auto".into() 331 | } else { 332 | format!("Heatmap intensity: {:.2}", self.intensity / 100.0) 333 | }; 334 | let intensity_checkbox = Container::new(Checkbox::new(self.auto_intensity, "Auto", Message::AutoIntensityCheckboxToggled).style(self.theme)) 335 | .align_x(alignment::Horizontal::Right) 336 | .width(Length::Fill); 337 | let intensity_label = Row::new().spacing(10).push(Text::new(&intensity_text)).push(intensity_checkbox); 338 | heatmap_options = heatmap_options.push(intensity_label); 339 | if !self.auto_intensity { 340 | let intensity_slider = Slider::new(&mut self.intensity_state, 1.0..=100.0, self.intensity, Message::IntensityChanged).style(self.theme); 341 | heatmap_options = heatmap_options.push(intensity_slider); 342 | } 343 | let radius_label = Row::new().spacing(10).push(Text::new(&format!("Heatmap radius: {:.1}", self.radius / 10.0))); 344 | let radius_slider = Slider::new(&mut self.radius_state, 1.0..=100.0, self.radius, Message::RadiusChanged).style(self.theme); 345 | heatmap_options = heatmap_options.push(radius_label).push(radius_slider); 346 | } 347 | let desaturate_label = Row::new().spacing(10).push(Text::new(&format!("Desaturate level overview: {:.0}%", self.desaturate))); 348 | let desaturate_slider = Slider::new(&mut self.desaturate_state, 0.0..=100.0, self.desaturate, Message::DesaturateChanged).style(self.theme); 349 | heatmap_options = heatmap_options.push(desaturate_label).push(desaturate_slider); 350 | let use_sentry_position_checkbox = 351 | Checkbox::new(self.use_sentry_position, "Use sentry position for sentry kills", Message::UseSentryPositionCheckboxToggled).style(self.theme); 352 | heatmap_options = heatmap_options.push(use_sentry_position_checkbox); 353 | 354 | let settings_content: Element<_> = Column::new() 355 | .push(choose_heatmap_type) 356 | .push(Text::new(coords_label)) 357 | .push(x_pos_border) 358 | .push(y_pos_border) 359 | .push(Text::new("cl_leveloverview scale")) 360 | .push(scale_border) 361 | .push(export_image_button) 362 | .push(heatmap_options) 363 | .push(choose_coords_type) 364 | .push(choose_theme) 365 | .spacing(10) 366 | .into(); 367 | 368 | let scroll = Scrollable::new(&mut self.scroll_state).push(settings_content); 369 | 370 | let result_container = Container::new(scroll).width(Length::Fill).height(Length::Fill).padding(10).style(style); 371 | 372 | Container::new(result_container).padding(4).width(Length::Fill).height(Length::Fill).into() 373 | } 374 | } 375 | 376 | #[derive(Default)] 377 | struct Preview { 378 | theme: style::Theme, 379 | heatmap_image: Option, 380 | } 381 | 382 | impl Preview { 383 | fn view(&mut self) -> Element { 384 | let (image, style): (Element<_>, _) = if let Some(heatmap_image) = &self.heatmap_image { 385 | (Image::new(heatmap_image.handle.clone()).into(), style::ResultContainer::Ok) 386 | } else { 387 | ( 388 | Text::new("Drag and drop the level overview screenshot to use it") 389 | .width(Length::Fill) 390 | .size(24) 391 | .horizontal_alignment(alignment::Horizontal::Center) 392 | .into(), 393 | style::ResultContainer::Error, 394 | ) 395 | }; 396 | 397 | let column = Column::new().push(image); 398 | 399 | let result_container = Container::new(column) 400 | .width(Length::Fill) 401 | .height(Length::Fill) 402 | .center_x() 403 | .center_y() 404 | .padding(10) 405 | .style(style); 406 | 407 | Container::new(result_container).padding(4).width(Length::Fill).height(Length::Fill).into() 408 | } 409 | } 410 | 411 | #[derive(Default)] 412 | struct LogPane { 413 | theme: style::Theme, 414 | scroll_state: scrollable::State, 415 | log: String, 416 | } 417 | 418 | impl LogPane { 419 | fn view(&mut self) -> Element { 420 | let log = Text::new(&self.log); 421 | 422 | let demos_scroll = Scrollable::new(&mut self.scroll_state).push(log).width(Length::Fill); 423 | 424 | let result_container = Container::new(demos_scroll) 425 | .width(Length::Fill) 426 | .height(Length::Fill) 427 | .padding(10) 428 | .style(style::ResultContainer::Ok); 429 | 430 | Container::new(result_container).padding(4).width(Length::Fill).height(Length::Fill).into() 431 | } 432 | 433 | fn log(&mut self, message: &str) { 434 | println!("{}", message); 435 | self.log.push_str(message); 436 | self.log.push('\n'); 437 | // TODO replace this by a cleaner way to scroll down once possible 438 | self.scroll_state.scroll_to( 439 | 1.0, 440 | Rectangle::new(Point::new(0.0, 0.0), Size::new(10000.0, 10000.0)), 441 | Rectangle::new(Point::new(0.0, 0.0), Size::new(100000.0, 100000.0)), 442 | ); 443 | } 444 | } 445 | 446 | impl Application for App { 447 | type Executor = executor::Default; 448 | type Message = Message; 449 | type Flags = (); 450 | 451 | fn new(_flags: ()) -> (App, Command) { 452 | let (mut pane_grid_state, demos_pane) = pane_grid::State::new(PaneState::DemoList(Default::default())); 453 | let (preview_pane, demos_preview_split) = pane_grid_state.split(Axis::Vertical, &demos_pane, PaneState::Preview(Default::default())).unwrap(); 454 | let (filters_pane, demos_filter_split) = pane_grid_state.split(Axis::Horizontal, &demos_pane, PaneState::FiltersPane(Default::default())).unwrap(); 455 | let (settings_pane, filters_settings_split) = pane_grid_state.split(Axis::Horizontal, &filters_pane, PaneState::SettingsPane(Default::default())).unwrap(); 456 | let (log_pane, preview_log_split) = pane_grid_state.split(Axis::Horizontal, &preview_pane, PaneState::LogPane(Default::default())).unwrap(); 457 | pane_grid_state.resize(&demos_preview_split, 0.397); 458 | pane_grid_state.resize(&demos_filter_split, 0.18); 459 | pane_grid_state.resize(&filters_settings_split, 0.294); 460 | pane_grid_state.resize(&preview_log_split, 0.8); 461 | ( 462 | App { 463 | busy: false, 464 | dropped_files: Default::default(), 465 | pane_grid_state, 466 | theme: Default::default(), 467 | demos_pane, 468 | preview_pane, 469 | filters_pane, 470 | settings_pane, 471 | log_pane, 472 | }, 473 | Command::none(), 474 | ) 475 | } 476 | 477 | fn title(&self) -> String { 478 | format!("Coldmaps {}", VERSION.unwrap_or_default()) 479 | } 480 | 481 | fn update(&mut self, message: Message) -> Command { 482 | match message { 483 | Message::WindowEventOccurred(iced_native::Event::Window(iced_native::window::Event::FileDropped(path))) => { 484 | if !path.is_file() { 485 | return Command::none(); 486 | } 487 | let file_name = path.file_name().unwrap().to_string_lossy().to_string(); // The path can't be .. at that point 488 | let file_name_lowercase = file_name.to_lowercase(); 489 | if file_name_lowercase.ends_with(".dem") { 490 | self.dropped_files.push(path); 491 | return Command::perform(async {}, Message::EndOfDemoFilesDrop); 492 | } else { 493 | // try to load it as an image 494 | if let Ok(reader) = Reader::open(&path) { 495 | if let Ok(image) = reader.decode() { 496 | let image = image.into_rgb8(); 497 | return Command::perform(async { image }, Message::LevelImageSet); 498 | } 499 | } 500 | } 501 | } 502 | Message::WindowEventOccurred(_) => {} 503 | Message::MapSet(map) => { 504 | return Command::perform( 505 | async move { 506 | let (x, y, scale) = match demostf::get_boundary(&map).await { 507 | Ok(boundaries) => boundaries, 508 | Err(e) => { 509 | eprintln!("Error while fetching demostf boundaries {}", e); 510 | None 511 | } 512 | }?; 513 | let image = match demostf::get_image(&map).await { 514 | Ok(image) => image, 515 | Err(e) => { 516 | eprintln!("Error while fetching demostf image {}", e); 517 | None 518 | } 519 | }?; 520 | Some((image, x, y, scale)) 521 | }, 522 | Message::DemosTFImageLoader, 523 | ); 524 | } 525 | Message::LevelImageSet(image) => { 526 | let image_with_heatmap_overlay = image.clone(); 527 | let handle = image_to_handle(&image); 528 | self.get_preview_pane_mut().heatmap_image.replace(HeatmapImage { 529 | image, 530 | image_with_heatmap_overlay, 531 | handle, 532 | }); 533 | self.get_settings_pane_mut().image_ready = true; 534 | self.try_generate_heatmap(); 535 | } 536 | Message::DemosTFImageLoader(Some((image, x, y, scale))) => { 537 | let settings_pane = self.get_settings_pane_mut(); 538 | settings_pane.x_pos = Some(x); 539 | settings_pane.x_pos_input = format!("{}", x); 540 | settings_pane.y_pos = Some(y); 541 | settings_pane.y_pos_input = format!("{}", y); 542 | settings_pane.scale = Some(scale); 543 | settings_pane.scale_input = format!("{}", scale); 544 | 545 | return Command::perform(async { image }, Message::LevelImageSet); 546 | } 547 | Message::DemosTFImageLoader(None) => {} 548 | Message::EndOfDemoFilesDrop(_) => { 549 | if !self.dropped_files.is_empty() { 550 | self.set_busy(true); 551 | let demo_count = self.dropped_files.len(); 552 | self.log(&format!("Processing {} demo{}...", demo_count, if demo_count > 1 { "s" } else { "" })); 553 | let input_paths = mem::take(&mut self.dropped_files); 554 | return Command::perform(process_demos_async(input_paths), Message::ProcessDemosDone); 555 | } 556 | } 557 | Message::PaneResized(pane_grid::ResizeEvent { split, ratio }) => { 558 | self.pane_grid_state.resize(&split, ratio); 559 | } 560 | Message::DemoRemoved(index) => { 561 | let demo_list = self.get_demo_list_pane_mut(); 562 | let removed = demo_list.demo_files.remove(index); 563 | let death_count = removed.heatmap_analysis.deaths.len(); 564 | self.log(&format!( 565 | "Removing {} with {} death{}", 566 | removed.file_name, 567 | death_count, 568 | if death_count > 1 { "s" } else { "" } 569 | )); 570 | self.show_stats(); 571 | self.try_generate_heatmap(); 572 | } 573 | Message::ChatPreview(index) => { 574 | let demo_list = self.get_demo_list_pane_mut(); 575 | let demo = &demo_list.demo_files[index]; 576 | let messages = format_chat_messages(&demo.heatmap_analysis); 577 | let header_message = format!("Chat log of {}:", demo.file_name); 578 | self.log(""); 579 | self.log(&header_message); 580 | self.log("======================"); 581 | for message in messages { 582 | self.log(&message); 583 | } 584 | self.log("======================"); 585 | } 586 | Message::ThemeChanged(theme) => { 587 | self.theme = theme; 588 | self.get_demo_list_pane_mut().theme = theme; 589 | self.get_filters_pane_mut().theme = theme; 590 | self.get_settings_pane_mut().theme = theme; 591 | self.get_preview_pane_mut().theme = theme; 592 | self.get_log_pane_mut().theme = theme; 593 | } 594 | Message::CoordsTypeChanged(coords_type) => { 595 | self.get_settings_pane_mut().coords_type = coords_type; 596 | self.try_generate_heatmap(); 597 | } 598 | Message::HeatmapTypeChanged(heatmap_type) => { 599 | self.get_settings_pane_mut().heatmap_type = heatmap_type; 600 | self.try_generate_heatmap(); 601 | } 602 | Message::XPosInputChanged(input) => { 603 | let settings_pane = self.get_settings_pane_mut(); 604 | settings_pane.x_pos = input.parse().ok(); 605 | if let Some(x_pos) = settings_pane.x_pos { 606 | if !x_pos.is_normal() && x_pos != 0.0 { 607 | settings_pane.x_pos = None; 608 | } 609 | } 610 | settings_pane.x_pos_input = input; 611 | self.try_generate_heatmap(); 612 | } 613 | Message::YPosInputChanged(input) => { 614 | let settings_pane = self.get_settings_pane_mut(); 615 | settings_pane.y_pos = input.parse().ok(); 616 | if let Some(y_pos) = settings_pane.y_pos { 617 | if !y_pos.is_normal() && y_pos != 0.0 { 618 | settings_pane.y_pos = None; 619 | } 620 | } 621 | settings_pane.y_pos_input = input; 622 | self.try_generate_heatmap(); 623 | } 624 | Message::ScaleInputChanged(input) => { 625 | let settings_pane = self.get_settings_pane_mut(); 626 | settings_pane.scale = input.parse().ok(); 627 | if let Some(scale) = settings_pane.scale { 628 | if !scale.is_normal() { 629 | settings_pane.scale = None; 630 | } 631 | } 632 | settings_pane.scale_input = input; 633 | self.try_generate_heatmap(); 634 | } 635 | Message::AutoIntensityCheckboxToggled(auto_intensity) => { 636 | let settings_pane = self.get_settings_pane_mut(); 637 | settings_pane.auto_intensity = auto_intensity; 638 | self.try_generate_heatmap(); 639 | } 640 | Message::UseSentryPositionCheckboxToggled(use_sentry_position) => { 641 | let settings_pane = self.get_settings_pane_mut(); 642 | settings_pane.use_sentry_position = use_sentry_position; 643 | self.try_generate_heatmap(); 644 | } 645 | Message::IntensityChanged(intensity) => { 646 | let settings_pane = self.get_settings_pane_mut(); 647 | settings_pane.intensity = intensity; 648 | self.try_generate_heatmap(); 649 | } 650 | Message::RadiusChanged(radius) => { 651 | let settings_pane = self.get_settings_pane_mut(); 652 | settings_pane.radius = radius; 653 | self.try_generate_heatmap(); 654 | } 655 | Message::DesaturateChanged(desaturate) => { 656 | let settings_pane = self.get_settings_pane_mut(); 657 | settings_pane.desaturate = desaturate; 658 | self.try_generate_heatmap(); 659 | } 660 | Message::ProcessDemosDone(mut timed_result) => { 661 | let mut demo_count = 0; 662 | let mut death_count = 0; 663 | let demo_list = self.get_demo_list_pane_mut(); 664 | let mut errors = Vec::new(); 665 | let mut map = String::new(); 666 | for demo in timed_result.result.iter_mut() { 667 | let demo = mem::take(demo); 668 | demo_count += 1; 669 | map = demo.map.clone(); 670 | if let Some(heatmap_analysis) = demo.heatmap_analysis { 671 | death_count += heatmap_analysis.deaths.len(); 672 | let path = demo.path; 673 | let file_name = path.file_name().unwrap().to_string_lossy().to_string(); 674 | let demo_file = DemoFile { 675 | _path: path, 676 | file_name, 677 | heatmap_analysis, 678 | delete_button: Default::default(), 679 | chat_preview_button: Default::default(), 680 | }; 681 | demo_list.demo_files.push(demo_file); 682 | } 683 | if let Some(error) = demo.error { 684 | errors.push(error); 685 | } 686 | } 687 | for error in errors { 688 | self.log(&error); 689 | } 690 | self.log(&format!( 691 | "Loaded {} death{} from {} demo{} in {:.2}s", 692 | death_count, 693 | if death_count > 1 { "s" } else { "" }, 694 | demo_count, 695 | if demo_count > 1 { "s" } else { "" }, 696 | timed_result.time_elapsed 697 | )); 698 | self.show_stats(); 699 | self.try_generate_heatmap(); 700 | self.set_busy(false); 701 | return Command::perform(async { map }, Message::MapSet); 702 | } 703 | Message::ExportImagePressed => { 704 | return Command::perform(open_save_dialog(), Message::ImageNameSelected); 705 | } 706 | Message::ImageNameSelected(path) => { 707 | if let Some(mut path) = path { 708 | if path.extension().is_none() { 709 | self.log("File extension not specified, defaulting to png"); 710 | path.set_extension("png"); 711 | } 712 | match &self.get_preview_pane().heatmap_image { 713 | Some(heatmap_image) => { 714 | if let Err(err) = heatmap_image.image_with_heatmap_overlay.save(&path) { 715 | self.log(&format!("Couldn't save the image: {}", err)); 716 | } else { 717 | self.log(&format!("Image saved: {}", path.file_name().unwrap().to_string_lossy())); 718 | } 719 | } 720 | _ => unreachable!(), 721 | } 722 | } 723 | } 724 | Message::FilterSelected(index, selected) => { 725 | let filter_row = &mut self.get_filters_pane_mut().filters[index]; 726 | filter_row.selected_filter = selected; 727 | filter_row.filter = filter_row.try_generate_filter(); 728 | self.try_generate_heatmap(); 729 | } 730 | Message::AddFilter => { 731 | self.get_filters_pane_mut().filters.push(Default::default()); 732 | } 733 | Message::ClassIconClicked(index, class_index) => { 734 | let filter_row = &mut self.get_filters_pane_mut().filters[index]; 735 | let button_active = &mut filter_row.class_buttons_selected[class_index]; 736 | *button_active = !*button_active; 737 | filter_row.filter = filter_row.try_generate_filter(); 738 | self.try_generate_heatmap(); 739 | } 740 | Message::BluTeamClicked(index) => { 741 | let filter_row = &mut self.get_filters_pane_mut().filters[index]; 742 | filter_row.team_button_selected = Team::Blu; 743 | filter_row.filter = filter_row.try_generate_filter(); 744 | self.try_generate_heatmap(); 745 | } 746 | Message::RedTeamClicked(index) => { 747 | let filter_row = &mut self.get_filters_pane_mut().filters[index]; 748 | filter_row.team_button_selected = Team::Red; 749 | filter_row.filter = filter_row.try_generate_filter(); 750 | self.try_generate_heatmap(); 751 | } 752 | Message::OrderedOperatorSelected(index, selected) => { 753 | let filter_row = &mut self.get_filters_pane_mut().filters[index]; 754 | filter_row.selected_ordered_operator = selected; 755 | filter_row.filter = filter_row.try_generate_filter(); 756 | self.try_generate_heatmap(); 757 | } 758 | Message::PropertyOperatorSelected(index, selected) => { 759 | let filter_row = &mut self.get_filters_pane_mut().filters[index]; 760 | filter_row.selected_property_operator = selected; 761 | filter_row.filter = filter_row.try_generate_filter(); 762 | self.try_generate_heatmap(); 763 | } 764 | Message::PropertySelected(index, selected) => { 765 | let filter_row = &mut self.get_filters_pane_mut().filters[index]; 766 | filter_row.selected_property = selected; 767 | filter_row.filter = filter_row.try_generate_filter(); 768 | self.try_generate_heatmap(); 769 | } 770 | Message::FilterTextInputChanged(index, text_input) => { 771 | let filter_row = &mut self.get_filters_pane_mut().filters[index]; 772 | filter_row.text_input = text_input; 773 | filter_row.filter = filter_row.try_generate_filter(); 774 | self.try_generate_heatmap(); 775 | } 776 | Message::FilterRemoved(index) => { 777 | self.get_filters_pane_mut().filters.remove(index); 778 | self.try_generate_heatmap(); 779 | } 780 | }; 781 | 782 | Command::none() 783 | } 784 | 785 | fn subscription(&self) -> Subscription { 786 | iced_native::subscription::events().map(Message::WindowEventOccurred) 787 | } 788 | 789 | fn view(&mut self) -> Element { 790 | let pane_grid: pane_grid::PaneGrid = pane_grid::PaneGrid::new(&mut self.pane_grid_state, |_pane, state| state.view().into()).on_resize(10, Message::PaneResized); 791 | 792 | let content = Column::new().align_items(iced::Alignment::Center).spacing(20).push(pane_grid); 793 | 794 | Container::new(content) 795 | .width(Length::Fill) 796 | .height(Length::Fill) 797 | .center_x() 798 | .center_y() 799 | .padding(4) 800 | .style(self.theme) 801 | .into() 802 | } 803 | } 804 | 805 | impl App { 806 | fn get_demo_list_pane(&self) -> &DemoList { 807 | if let PaneState::DemoList(pane) = self.pane_grid_state.get(&self.demos_pane).unwrap() { 808 | pane 809 | } else { 810 | unreachable!() 811 | } 812 | } 813 | fn get_filters_pane(&self) -> &FiltersPane { 814 | if let PaneState::FiltersPane(pane) = self.pane_grid_state.get(&self.filters_pane).unwrap() { 815 | pane 816 | } else { 817 | unreachable!() 818 | } 819 | } 820 | fn get_settings_pane(&self) -> &SettingsPane { 821 | if let PaneState::SettingsPane(pane) = self.pane_grid_state.get(&self.settings_pane).unwrap() { 822 | pane 823 | } else { 824 | unreachable!() 825 | } 826 | } 827 | fn get_preview_pane(&self) -> &Preview { 828 | if let PaneState::Preview(pane) = self.pane_grid_state.get(&self.preview_pane).unwrap() { 829 | pane 830 | } else { 831 | unreachable!() 832 | } 833 | } 834 | fn _get_log_pane(&self) -> &LogPane { 835 | if let PaneState::LogPane(pane) = self.pane_grid_state.get(&self.log_pane).unwrap() { 836 | pane 837 | } else { 838 | unreachable!() 839 | } 840 | } 841 | fn get_demo_list_pane_mut(&mut self) -> &mut DemoList { 842 | if let PaneState::DemoList(pane) = self.pane_grid_state.get_mut(&self.demos_pane).unwrap() { 843 | pane 844 | } else { 845 | unreachable!() 846 | } 847 | } 848 | fn get_filters_pane_mut(&mut self) -> &mut FiltersPane { 849 | if let PaneState::FiltersPane(pane) = self.pane_grid_state.get_mut(&self.filters_pane).unwrap() { 850 | pane 851 | } else { 852 | unreachable!() 853 | } 854 | } 855 | fn get_settings_pane_mut(&mut self) -> &mut SettingsPane { 856 | if let PaneState::SettingsPane(pane) = self.pane_grid_state.get_mut(&self.settings_pane).unwrap() { 857 | pane 858 | } else { 859 | unreachable!() 860 | } 861 | } 862 | fn get_preview_pane_mut(&mut self) -> &mut Preview { 863 | if let PaneState::Preview(pane) = self.pane_grid_state.get_mut(&self.preview_pane).unwrap() { 864 | pane 865 | } else { 866 | unreachable!() 867 | } 868 | } 869 | fn get_log_pane_mut(&mut self) -> &mut LogPane { 870 | if let PaneState::LogPane(pane) = self.pane_grid_state.get_mut(&self.log_pane).unwrap() { 871 | pane 872 | } else { 873 | unreachable!() 874 | } 875 | } 876 | fn log(&mut self, message: &str) { 877 | self.get_log_pane_mut().log(message); 878 | } 879 | fn set_busy(&mut self, busy: bool) { 880 | self.busy = busy; 881 | self.get_demo_list_pane_mut().busy = busy; 882 | self.get_filters_pane_mut().busy = busy; 883 | self.get_settings_pane_mut().busy = busy; 884 | // self.get_preview_pane().busy = busy; 885 | // self.get_log_pane().busy = busy; 886 | } 887 | fn show_stats(&mut self) { 888 | let demo_list = self.get_demo_list_pane(); 889 | let death_count: usize = demo_list.demo_files.iter().map(|demo_file| demo_file.heatmap_analysis.deaths.len()).sum(); 890 | let round_count: usize = demo_list.demo_files.iter().map(|demo_file| demo_file.heatmap_analysis.rounds.len()).sum(); 891 | let blu_wins: usize = demo_list.demo_files.iter().map(|demo_file| demo_file.heatmap_analysis.rounds.iter().filter(|round| round.winner == Team::Blu).count()).sum(); 892 | let red_wins: usize = demo_list.demo_files.iter().map(|demo_file| demo_file.heatmap_analysis.rounds.iter().filter(|round| round.winner == Team::Red).count()).sum(); 893 | let demo_count = demo_list.demo_files.len(); 894 | self.log(&format!( 895 | "Stats: {} death{}, {} demo{}\nRound count: {}, Blu wins: {} ({:.2}%), Red wins: {} ({:.2}%)", 896 | death_count, 897 | if death_count > 1 { "s" } else { "" }, 898 | demo_count, 899 | if demo_count > 1 { "s" } else { "" }, 900 | round_count, 901 | blu_wins, 902 | blu_wins as f32 * 100.0 / round_count as f32, 903 | red_wins, 904 | red_wins as f32 * 100.0 / round_count as f32, 905 | )); 906 | } 907 | fn try_generate_heatmap(&mut self) { 908 | let preview_pane = self.get_preview_pane(); 909 | let settings_pane = self.get_settings_pane(); 910 | let image = match &preview_pane.heatmap_image { 911 | Some(image) => apply_image_transformations(&image.image, settings_pane.desaturate), 912 | None => return, 913 | }; 914 | if let (Some(pos_x), Some(pos_y), Some(scale)) = (settings_pane.x_pos, settings_pane.y_pos, settings_pane.scale) { 915 | let coords_type = settings_pane.coords_type; 916 | let heatmap_type = settings_pane.heatmap_type; 917 | let radius = settings_pane.radius; 918 | let intensity = if settings_pane.auto_intensity { None } else { Some(settings_pane.intensity) }; 919 | let use_sentry_position = settings_pane.use_sentry_position; 920 | let screen_width = image.width(); 921 | let screen_height = image.height(); 922 | let filters: Vec<_> = self.get_filters_pane().filters.iter().filter_map(|filter_row| filter_row.filter.as_ref()).collect(); 923 | let demo_list = self.get_demo_list_pane(); 924 | let deaths = demo_list 925 | .demo_files 926 | .iter() 927 | .map(|demo_file| demo_file.heatmap_analysis.deaths.iter()) 928 | .flatten() 929 | .filter(|death| filters.iter().all(|filter| filter.apply(death))); 930 | let heatmap_generation_output = coldmaps::generate_heatmap( 931 | heatmap_type, 932 | deaths, 933 | image, 934 | screen_width, 935 | screen_height, 936 | pos_x, 937 | pos_y, 938 | scale, 939 | coords_type, 940 | radius, 941 | intensity, 942 | use_sentry_position, 943 | ); 944 | match &mut self.get_preview_pane_mut().heatmap_image { 945 | Some(heatmap_image) => { 946 | heatmap_image.handle = image_to_handle(&heatmap_generation_output); 947 | heatmap_image.image_with_heatmap_overlay = heatmap_generation_output; 948 | } 949 | _ => unreachable!(), 950 | }; 951 | } else { 952 | // We can't generate the heatmap yet but we should still apply the desaturation on the level overview 953 | match &mut self.get_preview_pane_mut().heatmap_image { 954 | Some(heatmap_image) => { 955 | heatmap_image.handle = image_to_handle(&image); 956 | heatmap_image.image_with_heatmap_overlay = image; 957 | } 958 | _ => unreachable!(), 959 | }; 960 | } 961 | } 962 | } 963 | 964 | // just desaturate for now 965 | fn apply_image_transformations(image: &ImageBuffer, Vec>, desaturate: f32) -> ImageBuffer, Vec> { 966 | let desaturate = desaturate / 100.0; 967 | let mut output_image = ImageBuffer::new(image.width(), image.height()); 968 | 969 | for (x, y, pixel) in image.enumerate_pixels() { 970 | let Rgb(data) = *pixel; 971 | 972 | // Convert the pixel to grayscale 973 | let gray_value = (0.3 * data[0] as f32 + 0.59 * data[1] as f32 + 0.11 * data[2] as f32) as u8; 974 | 975 | // Linearly interpolate between the original pixel and the grayscale value 976 | let new_pixel = Rgb([ 977 | ((1.0 - desaturate) * data[0] as f32 + desaturate * gray_value as f32) as u8, 978 | ((1.0 - desaturate) * data[1] as f32 + desaturate * gray_value as f32) as u8, 979 | ((1.0 - desaturate) * data[2] as f32 + desaturate * gray_value as f32) as u8, 980 | ]); 981 | 982 | output_image.put_pixel(x, y, new_pixel); 983 | } 984 | 985 | output_image 986 | } 987 | 988 | fn image_to_handle(image: &ImageBuffer, Vec>) -> Handle { 989 | Handle::from_pixels( 990 | image.width(), 991 | image.height(), 992 | image.pixels().fold(Vec::with_capacity((image.width() * image.height() * 4) as usize), |mut acc, pixel| { 993 | if let [r, g, b] = pixel.channels() { 994 | acc.push(*b); 995 | acc.push(*g); 996 | acc.push(*r); 997 | acc.push(255); 998 | acc 999 | } else { 1000 | unreachable!() 1001 | } 1002 | }), 1003 | ) 1004 | } 1005 | 1006 | async fn process_demos_async<'a>(inputs: Vec) -> TimedResult> { 1007 | let chrono = Instant::now(); 1008 | let result = tokio::task::spawn_blocking(move || coldmaps::process_demos(inputs)).await.unwrap(); 1009 | let time_elapsed = chrono.elapsed().as_secs_f32(); 1010 | TimedResult { result, time_elapsed } 1011 | } 1012 | 1013 | async fn open_save_dialog() -> Option { 1014 | AsyncFileDialog::new().add_filter("image", &["png"]).save_file().await.map(|handle| handle.path().into()) 1015 | } 1016 | -------------------------------------------------------------------------------- /src/style.rs: -------------------------------------------------------------------------------- 1 | use iced::{button, checkbox, container, progress_bar, radio, scrollable, slider, text_input}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 4 | pub enum Theme { 5 | Dark, 6 | Light, 7 | } 8 | 9 | impl Theme { 10 | pub const ALL: [Theme; 2] = [Theme::Dark, Theme::Light]; 11 | } 12 | 13 | impl Default for Theme { 14 | fn default() -> Theme { 15 | Theme::Dark 16 | } 17 | } 18 | 19 | impl<'a> From for Box { 20 | fn from(theme: Theme) -> Self { 21 | match theme { 22 | Theme::Light => Default::default(), 23 | Theme::Dark => dark::Container.into(), 24 | } 25 | } 26 | } 27 | 28 | impl<'a> From for Box { 29 | fn from(theme: Theme) -> Self { 30 | match theme { 31 | Theme::Light => Default::default(), 32 | Theme::Dark => dark::Radio.into(), 33 | } 34 | } 35 | } 36 | 37 | impl<'a> From for Box { 38 | fn from(theme: Theme) -> Self { 39 | match theme { 40 | Theme::Light => Default::default(), 41 | Theme::Dark => dark::TextInput.into(), 42 | } 43 | } 44 | } 45 | 46 | impl<'a> From for Box { 47 | fn from(theme: Theme) -> Self { 48 | match theme { 49 | Theme::Light => light::Button.into(), 50 | Theme::Dark => dark::Button.into(), 51 | } 52 | } 53 | } 54 | 55 | impl<'a> From for Box { 56 | fn from(theme: Theme) -> Self { 57 | match theme { 58 | Theme::Light => Default::default(), 59 | Theme::Dark => dark::Scrollable.into(), 60 | } 61 | } 62 | } 63 | 64 | impl<'a> From for Box { 65 | fn from(theme: Theme) -> Self { 66 | match theme { 67 | Theme::Light => Default::default(), 68 | Theme::Dark => dark::Slider.into(), 69 | } 70 | } 71 | } 72 | 73 | impl<'a> From for Box { 74 | fn from(theme: Theme) -> Self { 75 | match theme { 76 | Theme::Light => Default::default(), 77 | Theme::Dark => dark::ProgressBar.into(), 78 | } 79 | } 80 | } 81 | 82 | impl<'a> From for Box { 83 | fn from(theme: Theme) -> Self { 84 | match theme { 85 | Theme::Light => Default::default(), 86 | Theme::Dark => dark::Checkbox.into(), 87 | } 88 | } 89 | } 90 | 91 | mod light { 92 | use iced::{button, Background, Color, Vector}; 93 | 94 | pub struct Button; 95 | 96 | impl button::StyleSheet for Button { 97 | fn active(&self) -> button::Style { 98 | button::Style { 99 | background: Some(Background::Color(Color::from_rgb(0.11, 0.42, 0.87))), 100 | border_radius: 3.0, 101 | shadow_offset: Vector::new(1.0, 1.0), 102 | text_color: Color::from_rgb8(0xEE, 0xEE, 0xEE), 103 | ..button::Style::default() 104 | } 105 | } 106 | 107 | fn hovered(&self) -> button::Style { 108 | button::Style { 109 | text_color: Color::WHITE, 110 | shadow_offset: Vector::new(1.0, 2.0), 111 | ..self.active() 112 | } 113 | } 114 | } 115 | } 116 | 117 | mod dark { 118 | use iced::{button, checkbox, container, progress_bar, radio, scrollable, slider, text_input, Background, Color}; 119 | 120 | const SURFACE: Color = Color::from_rgb(0x40 as f32 / 255.0, 0x44 as f32 / 255.0, 0x4B as f32 / 255.0); 121 | 122 | const ACCENT: Color = Color::from_rgb(0x6F as f32 / 255.0, 0xFF as f32 / 255.0, 0xE9 as f32 / 255.0); 123 | 124 | const ACTIVE: Color = Color::from_rgb(0x72 as f32 / 255.0, 0x89 as f32 / 255.0, 0xDA as f32 / 255.0); 125 | 126 | const HOVERED: Color = Color::from_rgb(0x67 as f32 / 255.0, 0x7B as f32 / 255.0, 0xC4 as f32 / 255.0); 127 | 128 | pub struct Container; 129 | 130 | impl container::StyleSheet for Container { 131 | fn style(&self) -> container::Style { 132 | container::Style { 133 | background: Some(Background::Color(Color::from_rgb8(0x36, 0x39, 0x3F))), 134 | text_color: Some(Color::WHITE), 135 | ..container::Style::default() 136 | } 137 | } 138 | } 139 | 140 | pub struct Radio; 141 | 142 | impl radio::StyleSheet for Radio { 143 | fn active(&self) -> radio::Style { 144 | radio::Style { 145 | text_color: None, 146 | background: Background::Color(SURFACE), 147 | dot_color: ACTIVE, 148 | border_width: 1.0, 149 | border_color: ACTIVE, 150 | } 151 | } 152 | 153 | fn hovered(&self) -> radio::Style { 154 | radio::Style { 155 | background: Background::Color(Color { a: 0.5, ..SURFACE }), 156 | ..self.active() 157 | } 158 | } 159 | } 160 | 161 | pub struct TextInput; 162 | 163 | impl text_input::StyleSheet for TextInput { 164 | fn active(&self) -> text_input::Style { 165 | text_input::Style { 166 | background: Background::Color(SURFACE), 167 | border_radius: 2.0, 168 | border_width: 0.0, 169 | border_color: Color::TRANSPARENT, 170 | } 171 | } 172 | 173 | fn focused(&self) -> text_input::Style { 174 | text_input::Style { 175 | border_width: 1.0, 176 | border_color: ACCENT, 177 | ..self.active() 178 | } 179 | } 180 | 181 | fn hovered(&self) -> text_input::Style { 182 | text_input::Style { 183 | border_width: 1.0, 184 | border_color: Color { a: 0.3, ..ACCENT }, 185 | ..self.focused() 186 | } 187 | } 188 | 189 | fn placeholder_color(&self) -> Color { 190 | Color::from_rgb(0.4, 0.4, 0.4) 191 | } 192 | 193 | fn value_color(&self) -> Color { 194 | Color::WHITE 195 | } 196 | 197 | fn selection_color(&self) -> Color { 198 | ACTIVE 199 | } 200 | } 201 | 202 | pub struct Button; 203 | 204 | impl button::StyleSheet for Button { 205 | fn active(&self) -> button::Style { 206 | button::Style { 207 | background: Some(Background::Color(ACTIVE)), 208 | border_radius: 3.0, 209 | text_color: Color::WHITE, 210 | ..button::Style::default() 211 | } 212 | } 213 | 214 | fn hovered(&self) -> button::Style { 215 | button::Style { 216 | background: Some(Background::Color(HOVERED)), 217 | text_color: Color::WHITE, 218 | ..self.active() 219 | } 220 | } 221 | 222 | fn pressed(&self) -> button::Style { 223 | button::Style { 224 | border_width: 1.0, 225 | border_color: Color::WHITE, 226 | ..self.hovered() 227 | } 228 | } 229 | } 230 | 231 | pub struct Scrollable; 232 | 233 | impl scrollable::StyleSheet for Scrollable { 234 | fn active(&self) -> scrollable::Scrollbar { 235 | scrollable::Scrollbar { 236 | background: Some(Background::Color(SURFACE)), 237 | border_radius: 2.0, 238 | border_width: 0.0, 239 | border_color: Color::TRANSPARENT, 240 | scroller: scrollable::Scroller { 241 | color: ACTIVE, 242 | border_radius: 2.0, 243 | border_width: 0.0, 244 | border_color: Color::TRANSPARENT, 245 | }, 246 | } 247 | } 248 | 249 | fn hovered(&self) -> scrollable::Scrollbar { 250 | let active = self.active(); 251 | 252 | scrollable::Scrollbar { 253 | background: Some(Background::Color(Color { a: 0.5, ..SURFACE })), 254 | scroller: scrollable::Scroller { 255 | color: HOVERED, 256 | ..active.scroller 257 | }, 258 | ..active 259 | } 260 | } 261 | 262 | fn dragging(&self) -> scrollable::Scrollbar { 263 | let hovered = self.hovered(); 264 | 265 | scrollable::Scrollbar { 266 | scroller: scrollable::Scroller { 267 | color: Color::from_rgb(0.85, 0.85, 0.85), 268 | ..hovered.scroller 269 | }, 270 | ..hovered 271 | } 272 | } 273 | } 274 | 275 | pub struct Slider; 276 | 277 | impl slider::StyleSheet for Slider { 278 | fn active(&self) -> slider::Style { 279 | slider::Style { 280 | rail_colors: (ACTIVE, Color { a: 0.1, ..ACTIVE }), 281 | handle: slider::Handle { 282 | shape: slider::HandleShape::Circle { radius: 9.0 }, 283 | color: ACTIVE, 284 | border_width: 0.0, 285 | border_color: Color::TRANSPARENT, 286 | }, 287 | } 288 | } 289 | 290 | fn hovered(&self) -> slider::Style { 291 | let active = self.active(); 292 | 293 | slider::Style { 294 | handle: slider::Handle { color: HOVERED, ..active.handle }, 295 | ..active 296 | } 297 | } 298 | 299 | fn dragging(&self) -> slider::Style { 300 | let active = self.active(); 301 | 302 | slider::Style { 303 | handle: slider::Handle { 304 | color: Color::from_rgb(0.85, 0.85, 0.85), 305 | ..active.handle 306 | }, 307 | ..active 308 | } 309 | } 310 | } 311 | 312 | pub struct ProgressBar; 313 | 314 | impl progress_bar::StyleSheet for ProgressBar { 315 | fn style(&self) -> progress_bar::Style { 316 | progress_bar::Style { 317 | background: Background::Color(SURFACE), 318 | bar: Background::Color(ACTIVE), 319 | border_radius: 10.0, 320 | } 321 | } 322 | } 323 | 324 | pub struct Checkbox; 325 | 326 | impl checkbox::StyleSheet for Checkbox { 327 | fn active(&self, is_checked: bool) -> checkbox::Style { 328 | checkbox::Style { 329 | text_color: None, 330 | background: Background::Color(if is_checked { ACTIVE } else { SURFACE }), 331 | checkmark_color: Color::WHITE, 332 | border_radius: 2.0, 333 | border_width: 1.0, 334 | border_color: ACTIVE, 335 | } 336 | } 337 | 338 | fn hovered(&self, is_checked: bool) -> checkbox::Style { 339 | checkbox::Style { 340 | background: Background::Color(Color { 341 | a: 0.8, 342 | ..if is_checked { ACTIVE } else { SURFACE } 343 | }), 344 | ..self.active(is_checked) 345 | } 346 | } 347 | } 348 | } 349 | 350 | pub enum ResultContainer { 351 | Ok, 352 | Error, 353 | } 354 | 355 | impl<'a> From for Box { 356 | fn from(result: ResultContainer) -> Self { 357 | match result { 358 | ResultContainer::Ok => ok::Container.into(), 359 | ResultContainer::Error => error::Container.into(), 360 | } 361 | } 362 | } 363 | 364 | mod ok { 365 | use iced::{container, Color}; 366 | 367 | pub struct Container; 368 | 369 | impl container::StyleSheet for Container { 370 | fn style(&self) -> container::Style { 371 | container::Style { 372 | border_radius: 2.0, 373 | border_width: 2.0, 374 | border_color: Color::from_rgb(0.0, 0.75, 0.75), 375 | ..container::Style::default() 376 | } 377 | } 378 | } 379 | } 380 | 381 | mod error { 382 | use iced::{container, Color}; 383 | 384 | pub struct Container; 385 | 386 | impl container::StyleSheet for Container { 387 | fn style(&self) -> container::Style { 388 | container::Style { 389 | border_radius: 2.0, 390 | border_width: 2.0, 391 | border_color: Color::from_rgb(0.75, 0.0, 0.0), 392 | ..container::Style::default() 393 | } 394 | } 395 | } 396 | } 397 | 398 | pub enum ActiveButtonHighlight { 399 | Highlighted, 400 | NotHighlighted, 401 | } 402 | 403 | impl<'a> From for Box { 404 | fn from(highlight: ActiveButtonHighlight) -> Self { 405 | match highlight { 406 | ActiveButtonHighlight::Highlighted => highlighted::Button.into(), 407 | ActiveButtonHighlight::NotHighlighted => not_highlighted::Button.into(), 408 | } 409 | } 410 | } 411 | 412 | mod highlighted { 413 | use iced::{button, Background, Color}; 414 | 415 | const ACTIVE: Color = Color::from_rgb(0x72 as f32 / 255.0, 0x89 as f32 / 255.0, 0xDA as f32 / 255.0); 416 | 417 | pub struct Button; 418 | impl button::StyleSheet for Button { 419 | fn active(&self) -> button::Style { 420 | button::Style { 421 | background: Some(Background::Color(ACTIVE)), 422 | border_radius: 3.0, 423 | text_color: Color::WHITE, 424 | ..button::Style::default() 425 | } 426 | } 427 | } 428 | } 429 | 430 | mod not_highlighted { 431 | use iced::{button, Background, Color}; 432 | 433 | const INACTIVE: Color = Color::from_rgb(0x80 as f32 / 255.0, 0x80 as f32 / 255.0, 0x80 as f32 / 255.0); 434 | 435 | pub struct Button; 436 | impl button::StyleSheet for Button { 437 | fn active(&self) -> button::Style { 438 | button::Style { 439 | background: Some(Background::Color(INACTIVE)), 440 | border_radius: 3.0, 441 | text_color: Color::WHITE, 442 | ..button::Style::default() 443 | } 444 | } 445 | } 446 | } 447 | --------------------------------------------------------------------------------