aboutsummaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
authormetamuffin <yvchraiqi@protonmail.com>2022-06-09 09:46:00 +0200
committermetamuffin <yvchraiqi@protonmail.com>2022-06-09 09:46:00 +0200
commit7a0d09e5cd0075e2a0d3db4505d7ec77dff35ae0 (patch)
tree5586745b7a9b871b31512cba9f964dabda4651f0 /client
downloadtwclient-7a0d09e5cd0075e2a0d3db4505d7ec77dff35ae0.tar
twclient-7a0d09e5cd0075e2a0d3db4505d7ec77dff35ae0.tar.bz2
twclient-7a0d09e5cd0075e2a0d3db4505d7ec77dff35ae0.tar.zst
(reset git)
Diffstat (limited to 'client')
-rw-r--r--client/Cargo.toml48
-rw-r--r--client/src/client/helper.rs87
-rw-r--r--client/src/client/mod.rs463
-rw-r--r--client/src/lib.rs21
-rw-r--r--client/src/main.rs41
-rw-r--r--client/src/world/map.rs135
-rw-r--r--client/src/world/mod.rs132
7 files changed, 927 insertions, 0 deletions
diff --git a/client/Cargo.toml b/client/Cargo.toml
new file mode 100644
index 0000000..5f14df4
--- /dev/null
+++ b/client/Cargo.toml
@@ -0,0 +1,48 @@
+[package]
+name = "twclient"
+version = "0.1.0"
+edition = "2021"
+authors = [
+ "metamuffin <metamuffin@disroot.org>",
+ "heinrich5991 <heinrich5991@gmail.com>",
+]
+license = "AGPL-3.0-only"
+
+[dependencies]
+socket = { git = "https://github.com/heinrich5991/libtw2" }
+datafile = { git = "https://github.com/heinrich5991/libtw2" }
+map = { git = "https://github.com/heinrich5991/libtw2" }
+net = { git = "https://github.com/heinrich5991/libtw2" }
+common = { git = "https://github.com/heinrich5991/libtw2" }
+event_loop = { git = "https://github.com/heinrich5991/libtw2" }
+gamenet_teeworlds_0_5 = { git = "https://github.com/heinrich5991/libtw2", optional = true }
+gamenet_teeworlds_0_6 = { git = "https://github.com/heinrich5991/libtw2", optional = true }
+gamenet_teeworlds_0_7 = { git = "https://github.com/heinrich5991/libtw2", optional = true }
+gamenet_ddnet = { git = "https://github.com/heinrich5991/libtw2", optional = true }
+logger = { git = "https://github.com/heinrich5991/libtw2" }
+packer = { git = "https://github.com/heinrich5991/libtw2" }
+# snapshot = { git = "https://github.com/heinrich5991/libtw2" }
+snapshot = { path = "../snapshot" }
+
+ndarray = "0.9.1"
+arrayvec = "0.5.2"
+clap = "2.31.2"
+hexdump = "0.1.1"
+itertools = ">=0.3.0,<0.5.0"
+log = "0.3.1"
+rand = "0.8.3"
+tempfile = "2.0.0"
+warn = ">=0.1.1,<0.3.0"
+env_logger = "0.9.0"
+signal-hook = "0.3.14"
+lazy_static = "1.4.0"
+anyhow = "1.0.57"
+crossbeam-channel = "0.5.4"
+
+
+[features]
+default = ["gamenet_ddnet_0_6"]
+gamenet_0_5 = ["dep:gamenet_teeworlds_0_5", "snapshot/gamenet_teeworlds_0_5"]
+gamenet_0_6 = ["dep:gamenet_teeworlds_0_6", "snapshot/gamenet_teeworlds_0_6"]
+gamenet_0_7 = ["dep:gamenet_teeworlds_0_7", "snapshot/gamenet_teeworlds_0_7"]
+gamenet_ddnet_0_6 = ["dep:gamenet_ddnet", "snapshot/gamenet_ddnet_0_6"]
diff --git a/client/src/client/helper.rs b/client/src/client/helper.rs
new file mode 100644
index 0000000..af19301
--- /dev/null
+++ b/client/src/client/helper.rs
@@ -0,0 +1,87 @@
+use arrayvec::ArrayVec;
+use event_loop::{Chunk, Loop, PeerId};
+use gamenet::msg::{Game, System};
+use hexdump::hexdump_iter;
+use itertools::Itertools;
+use log::LogLevel;
+use log::{log, log_enabled, warn};
+use packer::with_packer;
+use snapshot::format::Item as SnapItem;
+use std::{fmt, path::PathBuf};
+
+pub trait LoopExt: Loop {
+ fn send_system<'a, S: Into<System<'a>>>(&mut self, pid: PeerId, msg: S) {
+ fn inner<L: Loop + ?Sized>(msg: System, pid: PeerId, evloop: &mut L) {
+ let mut buf: ArrayVec<[u8; 2048]> = ArrayVec::new();
+ with_packer(&mut buf, |p| msg.encode(p).unwrap());
+ evloop.send(Chunk {
+ pid,
+ vital: true,
+ data: &buf,
+ })
+ }
+ inner(msg.into(), pid, self)
+ }
+ fn send_game<'a, G: Into<Game<'a>>>(&mut self, pid: PeerId, msg: G) {
+ fn inner<L: Loop + ?Sized>(msg: Game, pid: PeerId, evloop: &mut L) {
+ let mut buf: ArrayVec<[u8; 2048]> = ArrayVec::new();
+ with_packer(&mut buf, |p| msg.encode(p).unwrap());
+ evloop.send(Chunk {
+ pid,
+ vital: true,
+ data: &buf,
+ })
+ }
+ inner(msg.into(), pid, self)
+ }
+}
+impl<L: Loop> LoopExt for L {}
+
+pub fn need_file(crc: i32, name: &str) -> bool {
+ let mut path = PathBuf::new();
+ path.push("/tmp/maps");
+ path.push(format!("{}_{:08x}.map", name, crc));
+ !path.exists()
+}
+
+pub fn hexdump(level: LogLevel, data: &[u8]) {
+ if log_enabled!(level) {
+ hexdump_iter(data).foreach(|s| log!(level, "{}", s));
+ }
+}
+pub struct Warn<'a>(pub &'a [u8]);
+
+impl<'a, W: fmt::Debug> warn::Warn<W> for Warn<'a> {
+ fn warn(&mut self, w: W) {
+ warn!("{:?}", w);
+ hexdump(LogLevel::Warn, self.0);
+ }
+}
+
+#[derive(Debug)]
+pub struct WarnSnap<'a>(pub SnapItem<'a>);
+
+impl<'a, W: fmt::Debug> warn::Warn<W> for WarnSnap<'a> {
+ fn warn(&mut self, w: W) {
+ warn!("{:?} for {:?}", w, self.0);
+ }
+}
+
+pub fn check_dummy_map(name: &[u8], crc: u32, size: i32) -> bool {
+ if name != b"dummy" {
+ return false;
+ }
+ match (crc, size) {
+ (0xbeae0b9f, 549) => {}
+ (0x6c760ac4, 306) => {}
+ _ => warn!("unknown dummy map, crc={}, size={}", crc, size),
+ }
+ true
+}
+
+pub fn get_map_path(name: &str, crc: i32) -> PathBuf {
+ let mut path = PathBuf::new();
+ path.push("/tmp/maps");
+ path.push(format!("{}_{:08x}.map", name, crc));
+ path
+}
diff --git a/client/src/client/mod.rs b/client/src/client/mod.rs
new file mode 100644
index 0000000..c72cc0b
--- /dev/null
+++ b/client/src/client/mod.rs
@@ -0,0 +1,463 @@
+pub mod helper;
+
+use self::helper::get_map_path;
+
+use super::gamenet::{
+ enums::{Team, VERSION},
+ msg::{
+ self,
+ game::{ClSetTeam, ClStartInfo},
+ system::{EnterGame, Info, Input, MapChange, MapData},
+ system::{Ready, RequestMapData},
+ Game, System, SystemOrGame,
+ },
+ snap_obj::{obj_size, PlayerInput},
+ SnapObj,
+};
+use crate::client::helper::{check_dummy_map, hexdump, WarnSnap};
+use crate::{
+ client::helper::{need_file, LoopExt, Warn},
+ SHOULD_EXIT,
+};
+use common::{num::Cast, pretty};
+use crossbeam_channel::{Receiver, Sender};
+use event_loop::{Addr, Application, Chunk, ConnlessChunk, Loop, PeerId, SocketLoop, Timeout};
+use log::LogLevel;
+use log::{debug, error, info, log, warn};
+use packer::{IntUnpacker, Unpacker};
+use std::{
+ borrow::{Borrow, Cow},
+ collections::HashSet,
+ fs,
+ io::{self, Write},
+ mem,
+ net::IpAddr,
+ sync::atomic::Ordering,
+ u32,
+};
+use tempfile::{NamedTempFile, NamedTempFileOptions};
+use warn::Log;
+
+pub struct ClientConfig {
+ pub nick: String,
+ pub clan: String,
+ pub timeout: String,
+}
+
+pub struct Client {
+ pid: PeerId,
+ config: ClientConfig,
+
+ current_votes: HashSet<Vec<u8>>,
+ snaps: snapshot::Manager,
+ num_snaps_since_reset: u64,
+ dummy_map: bool,
+ state: ClientState,
+ download: Option<Download>,
+
+ _interface_receive: Receiver<ClientMesgIn>,
+ interface_send: Sender<ClientMesgOut>,
+}
+
+pub struct ClientInterface {
+ pub send: Sender<ClientMesgIn>,
+ pub receive: Receiver<ClientMesgOut>,
+}
+
+pub enum ClientMesgIn {}
+pub enum ClientMesgOut {
+ MapChange { name: String, crc: i32 },
+ Snaps(Vec<(u16, SnapObj)>),
+}
+
+#[derive(Clone, Copy, Debug)]
+enum ClientState {
+ Connection,
+ MapChange,
+ MapData(i32, i32),
+ ConReady,
+ ReadyToEnter,
+}
+
+impl Default for ClientState {
+ fn default() -> ClientState {
+ ClientState::Connection
+ }
+}
+
+impl Client {
+ pub fn new_evloop() -> SocketLoop {
+ SocketLoop::client()
+ }
+
+ pub fn new(
+ evloop: &mut SocketLoop,
+ ip: IpAddr,
+ port: u16,
+ config: ClientConfig,
+ ) -> (Self, ClientInterface) {
+ fs::create_dir_all("/tmp/maps").unwrap();
+ fs::create_dir_all("/tmp/downloading").unwrap();
+ let (a, b) = crossbeam_channel::unbounded();
+ let (c, d) = crossbeam_channel::unbounded();
+ (
+ Client {
+ pid: evloop.connect(Addr { ip, port }),
+ config,
+ current_votes: HashSet::new(),
+ snaps: snapshot::Manager::new(),
+ num_snaps_since_reset: 0,
+ dummy_map: false,
+ state: ClientState::Connection,
+ download: None,
+ _interface_receive: b,
+ interface_send: c,
+ },
+ ClientInterface {
+ receive: d,
+ send: a,
+ },
+ )
+ }
+ pub fn run(self, evloop: SocketLoop) {
+ evloop.run(self)
+ }
+}
+
+impl<'a, L: Loop> Application<L> for Client {
+ fn needs_tick(&mut self) -> Timeout {
+ Timeout::inactive()
+ }
+ fn on_tick(&mut self, evloop: &mut L) {
+ if SHOULD_EXIT.load(Ordering::Relaxed) {
+ warn!("exiting peer {}", self.pid);
+ evloop.disconnect(self.pid, b"error");
+ }
+ }
+ fn on_packet(&mut self, evloop: &mut L, chunk: Chunk) {
+ let pid = chunk.pid;
+
+ let msg = match msg::decode(&mut Warn(chunk.data), &mut Unpacker::new(chunk.data)) {
+ Ok(m) => m,
+ Err(err) => {
+ warn!("decode error {:?}:", err);
+ hexdump(LogLevel::Warn, chunk.data);
+ return;
+ }
+ };
+ debug!("{:?}", msg);
+ match msg {
+ SystemOrGame::Game(Game::SvMotd(..))
+ | SystemOrGame::Game(Game::SvKillMsg(..))
+ | SystemOrGame::Game(Game::SvTuneParams(..))
+ | SystemOrGame::Game(Game::SvWeaponPickup(..))
+ | SystemOrGame::System(System::InputTiming(..))
+ | SystemOrGame::Game(Game::SvExtraProjectile(..)) => {}
+ SystemOrGame::Game(Game::SvChat(chat)) => {
+ if chat.client_id == -1 {
+ info!("[server]: {}", pretty::AlmostString::new(chat.message));
+ } else {
+ info!(
+ "[team {}]: {}",
+ chat.team,
+ pretty::AlmostString::new(chat.message)
+ )
+ }
+ }
+ SystemOrGame::Game(Game::SvBroadcast(broadcast)) => {
+ info!(
+ "broadcast: {}",
+ pretty::AlmostString::new(broadcast.message)
+ );
+ }
+ _ => {}
+ }
+
+ match msg {
+ SystemOrGame::System(ref msg) => match *msg {
+ System::MapChange(MapChange { crc, size, name }) => {
+ if let Some(_) = size.try_usize() {
+ if name.iter().any(|&b| b == b'/' || b == b'\\') {
+ error!("invalid map name");
+ evloop.disconnect(pid, b"error");
+ return;
+ }
+ match self.state {
+ ClientState::MapChange => {}
+ ClientState::ReadyToEnter if self.dummy_map => {}
+ _ => warn!("map change from state {:?}", self.state),
+ }
+ self.dummy_map = check_dummy_map(name, crc as u32, size);
+ self.current_votes.clear();
+ self.num_snaps_since_reset = 0;
+ self.snaps.reset();
+ info!("map change: {}", pretty::AlmostString::new(name));
+ let name = String::from_utf8_lossy(name);
+ if let Cow::Owned(..) = name {
+ warn!("weird characters in map name");
+ }
+ let mut start_download = false;
+ if need_file(crc, &name) {
+ if let Err(e) = self.open_download_file(crc, name.borrow()) {
+ error!("error opening file {:?}", e);
+ } else {
+ start_download = true;
+ }
+ }
+ if start_download {
+ info!("download starting");
+ evloop.send_system(pid, RequestMapData { chunk: 0 });
+ self.state = ClientState::MapData(crc, 0);
+ } else {
+ self.state = ClientState::ConReady;
+ evloop.send_system(pid, Ready);
+
+ self.interface_send
+ .send(ClientMesgOut::MapChange {
+ name: name.to_string(),
+ crc,
+ })
+ .unwrap();
+ }
+ } else {
+ error!("invalid map size");
+ evloop.disconnect(pid, b"error");
+ return;
+ }
+ }
+ System::Snap(_) | System::SnapEmpty(_) | System::SnapSingle(_) => {
+ self.num_snaps_since_reset += 1;
+ let mut input = PlayerInput::default();
+ {
+ let res = match *msg {
+ System::Snap(s) => self.snaps.snap(&mut Log, obj_size, s),
+ System::SnapEmpty(s) => self.snaps.snap_empty(&mut Log, obj_size, s),
+ System::SnapSingle(s) => self.snaps.snap_single(&mut Log, obj_size, s),
+ _ => unreachable!(),
+ };
+ match res {
+ Ok(Some(snap)) => {
+ let snaps = snap
+ .items()
+ .filter_map(|item| {
+ SnapObj::decode_obj(
+ &mut WarnSnap(item),
+ item.type_id.into(),
+ &mut IntUnpacker::new(item.data),
+ )
+ .map(|o| (item.id, o))
+ .ok()
+ })
+ .collect::<Vec<(u16, SnapObj)>>();
+
+ self.interface_send
+ .send(ClientMesgOut::Snaps(snaps.clone()))
+ .unwrap();
+
+ let client_id = snaps
+ .iter()
+ .filter_map(|(_id, i)| {
+ if let SnapObj::PlayerInfo(p) = i {
+ if p.local != 0 {
+ Some(p.client_id)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ })
+ .next()
+ .unwrap_or(1 << 15)
+ as u16;
+
+ let mut target_position = None;
+ let mut my_position = (0, 0);
+ for (id, snap) in &snaps {
+ match snap {
+ SnapObj::Character(c) => {
+ let c = c.character_core;
+ if *id == client_id {
+ my_position = (c.x, c.y)
+ } else if c.hook_state != 0 {
+ target_position = Some((c.x, c.y))
+ }
+ }
+ _ => {}
+ }
+ }
+ if let Some(target) = target_position {
+ if target.0 < my_position.0 {
+ input.direction = -1;
+ }
+ if target.0 > my_position.0 {
+ input.direction = 1;
+ }
+ if target.1 - 16 < my_position.1 {
+ input.jump = 1;
+ }
+ }
+ }
+ Ok(None) => {
+ self.num_snaps_since_reset -= 1;
+ }
+ Err(err) => warn!("snapshot error {:?}", err),
+ }
+ }
+ // DDNet needs the INPUT message as the first
+ // chunk of the packet.
+ evloop.force_flush(pid);
+ let tick = self.snaps.ack_tick().unwrap_or(-1);
+ evloop.send_system(
+ pid,
+ Input {
+ ack_snapshot: tick,
+ intended_tick: tick,
+ input_size: mem::size_of::<PlayerInput>().assert_i32(),
+ input,
+ },
+ );
+ }
+ _ => {}
+ },
+ SystemOrGame::Game(ref msg) => match *msg {
+ _ => {}
+ },
+ }
+ match self.state {
+ ClientState::Connection => unreachable!(),
+ ClientState::MapChange => {} // Handled above.
+ ClientState::MapData(cur_crc, cur_chunk) => match msg {
+ SystemOrGame::System(System::MapData(MapData {
+ last,
+ crc,
+ chunk,
+ data,
+ })) => {
+ if cur_crc == crc && cur_chunk == chunk {
+ let res = self.write_download_file(data);
+ if let Err(ref err) = res {
+ error!("error writing file {:?}", err);
+ }
+ if last != 0 || res.is_err() {
+ if !res.is_err() {
+ if let Err(err) = self.finish_download_file() {
+ error!("error finishing file {:?}", err);
+ }
+ if last != 1 {
+ warn!("weird map data packet");
+ }
+ }
+ self.state = ClientState::ConReady;
+ evloop.send_system(pid, Ready);
+
+ info!("download finished");
+ } else {
+ let cur_chunk = cur_chunk.checked_add(1).unwrap();
+ self.state = ClientState::MapData(cur_crc, cur_chunk);
+ evloop.send_system(pid, RequestMapData { chunk: cur_chunk });
+ }
+ } else {
+ if cur_crc != crc || cur_chunk < chunk {
+ warn!("unsolicited map data crc={:08x} chunk={}", crc, chunk);
+ warn!("want crc={:08x} chunk={}", cur_crc, cur_chunk);
+ }
+ }
+ }
+ _ => {}
+ },
+ ClientState::ConReady => match msg {
+ SystemOrGame::System(System::ConReady(..)) => {
+ evloop.send_game(
+ pid,
+ ClStartInfo {
+ name: self.config.nick.as_bytes(),
+ clan: self.config.clan.as_bytes(),
+ country: -1,
+ skin: b"limekittygirl",
+ use_custom_color: true,
+ color_body: 0xFF00FF,
+ color_feet: 0x550055,
+ },
+ );
+ self.state = ClientState::ReadyToEnter;
+ }
+ _ => {}
+ },
+ ClientState::ReadyToEnter => match msg {
+ SystemOrGame::Game(Game::SvReadyToEnter(..)) => {
+ evloop.send_system(pid, EnterGame);
+ evloop.send_game(pid, ClSetTeam { team: Team::Red });
+ }
+ _ => {}
+ },
+ }
+ evloop.flush(pid);
+ }
+ fn on_connless_packet(&mut self, _: &mut L, chunk: ConnlessChunk) {
+ warn!(
+ "connless packet {} {:?}",
+ chunk.addr,
+ pretty::Bytes::new(chunk.data)
+ );
+ }
+ fn on_connect(&mut self, _: &mut L, _: PeerId) {
+ unreachable!();
+ }
+ fn on_ready(&mut self, evloop: &mut L, pid: PeerId) {
+ if pid != self.pid {
+ error!("not our pid: {} vs {}", pid, self.pid);
+ }
+ self.state = ClientState::MapChange;
+ evloop.send_system(
+ pid,
+ Info {
+ version: VERSION.as_bytes(),
+ password: Some(b""),
+ },
+ );
+ evloop.flush(pid);
+ }
+ fn on_disconnect(&mut self, _: &mut L, pid: PeerId, remote: bool, reason: &[u8]) {
+ if remote {
+ error!(
+ "disconnected pid={:?} error={}",
+ pid,
+ pretty::AlmostString::new(reason)
+ );
+ }
+ }
+}
+
+struct Download {
+ file: NamedTempFile,
+ crc: i32,
+ name: String,
+}
+
+impl Client {
+ fn open_download_file(&mut self, crc: i32, name: &str) -> Result<(), io::Error> {
+ self.download = Some(Download {
+ file: NamedTempFileOptions::new()
+ .prefix(&format!("{}_{:08x}_", name, crc))
+ .suffix(".map")
+ .create_in("/tmp/downloading")?,
+ crc,
+ name: name.to_string(),
+ });
+ Ok(())
+ }
+ fn write_download_file(&mut self, data: &[u8]) -> Result<(), io::Error> {
+ self.download.as_mut().unwrap().file.write_all(data)
+ }
+ fn finish_download_file(&mut self) -> Result<(), io::Error> {
+ let download = self.download.take().unwrap();
+ let path = get_map_path(download.name.as_str(), download.crc);
+ download
+ .file
+ .persist(&path)
+ .map(|_| ())
+ .map_err(|e| e.error)?;
+ Ok(())
+ }
+}
diff --git a/client/src/lib.rs b/client/src/lib.rs
new file mode 100644
index 0000000..3c19df7
--- /dev/null
+++ b/client/src/lib.rs
@@ -0,0 +1,21 @@
+#![feature(exclusive_range_pattern)]
+
+pub mod client;
+pub mod world;
+
+#[cfg(feature = "gamenet_ddnet_0_6")]
+pub extern crate gamenet_ddnet as gamenet;
+#[cfg(feature = "gamenet_0_5")]
+pub extern crate gamenet_teeworlds_0_5 as gamenet;
+#[cfg(feature = "gamenet_0_6")]
+pub extern crate gamenet_teeworlds_0_6 as gamenet;
+#[cfg(feature = "gamenet_0_7")]
+pub extern crate gamenet_teeworlds_0_7 as gamenet;
+
+
+use std::sync::atomic::AtomicBool;
+
+use lazy_static::lazy_static;
+lazy_static! {
+ pub static ref SHOULD_EXIT: AtomicBool = AtomicBool::new(false);
+}
diff --git a/client/src/main.rs b/client/src/main.rs
new file mode 100644
index 0000000..b79f338
--- /dev/null
+++ b/client/src/main.rs
@@ -0,0 +1,41 @@
+use log::{error, log, warn};
+use signal_hook::{
+ consts::{SIGINT, SIGTERM},
+ iterator::Signals,
+ low_level::exit,
+};
+use std::{net::IpAddr, str::FromStr, sync::atomic::Ordering, thread, time::Duration};
+use twclient::{
+ client::{Client, ClientConfig},
+ SHOULD_EXIT,
+};
+
+fn main() {
+ env_logger::init();
+
+ let mut signals = Signals::new(&[SIGTERM, SIGINT]).unwrap();
+ thread::spawn(move || {
+ for sig in signals.forever() {
+ warn!("received signal {:?}", sig);
+ SHOULD_EXIT.store(true, Ordering::Relaxed);
+ thread::sleep(Duration::from_secs(3));
+ error!("exit timeout!");
+ exit(1);
+ }
+ });
+
+ let config = ClientConfig {
+ nick: String::from("metamuffin"),
+ clan: String::from("rustacean"),
+ timeout: String::from("asgefdhjikhjfhjf"),
+ };
+ let mut args = std::env::args().skip(1);
+ let ip = IpAddr::from_str(args.next().unwrap().as_str()).unwrap();
+ let port = u16::from_str(args.next().unwrap().as_str()).unwrap();
+ drop(ip);
+ drop(port);
+ drop(config);
+ drop(Client::new_evloop());
+ todo!()
+ // Client::run_thing(ip, port, config)
+}
diff --git a/client/src/world/map.rs b/client/src/world/map.rs
new file mode 100644
index 0000000..fb67918
--- /dev/null
+++ b/client/src/world/map.rs
@@ -0,0 +1,135 @@
+use ::map as mapfile;
+use anyhow::Error;
+use common::{num::Cast, vec};
+use log::{info, log};
+use ndarray::Array2;
+use std::{
+ collections::{hash_map, HashMap},
+ fs::File,
+ mem,
+};
+
+pub use mapfile::format;
+pub use mapfile::{
+ format::Tile,
+ reader::{self, Color, LayerTilemapType},
+};
+pub const TILE_NUM: u32 = 16;
+
+pub struct Layer {
+ pub color: Color,
+ pub image: Option<usize>,
+ pub tiles: Array2<Tile>,
+ pub kind: LayerTilemapType,
+}
+
+pub struct Map {
+ pub layers: Vec<Layer>,
+ pub tilesets: HashMap<Option<usize>, Array2<Color>>,
+}
+
+impl Map {
+ pub fn empty() -> Self {
+ Self {
+ layers: Vec::new(),
+ tilesets: HashMap::new(),
+ }
+ }
+
+ pub fn load(file: File) -> Result<Self, Error> {
+ info!("loading map");
+ let datafile = datafile::Reader::new(file).unwrap();
+ let mut map = mapfile::Reader::from_datafile(datafile);
+
+ let mut layers = vec![];
+ for group_idx in map.group_indices() {
+ let group = map.group(group_idx).unwrap();
+
+ if group.parallax_x != 100
+ || group.parallax_y != 100
+ || group.offset_x != 0
+ || group.offset_y != 0
+ || group.clipping.is_some()
+ {
+ continue;
+ }
+
+ for layer_idx in group.layer_indices {
+ let layer = map.layer(layer_idx).unwrap();
+
+ let tilemap = if let reader::LayerType::Tilemap(t) = layer.t {
+ t
+ } else {
+ continue;
+ };
+ let normal = if let Some(n) = tilemap.type_.to_normal() {
+ n
+ } else {
+ continue;
+ };
+ let tiles = map.layer_tiles(tilemap.tiles(normal.data)).unwrap();
+ layers.push(Layer {
+ color: normal.color,
+ image: normal.image,
+ kind: tilemap.type_,
+ tiles,
+ });
+ }
+ }
+
+ let mut tilesets = HashMap::new();
+ for layer in &layers {
+ match tilesets.entry(layer.image) {
+ hash_map::Entry::Occupied(_) => {}
+ hash_map::Entry::Vacant(v) => {
+ let data = match layer.image {
+ None => Array2::from_elem(
+ (1, 1),
+ Color {
+ alpha: 255,
+ red: 255,
+ blue: 255,
+ green: 0,
+ },
+ ),
+ Some(image_idx) => {
+ let image = map.image(image_idx).unwrap();
+ let height = image.height.usize();
+ let width = image.width.usize();
+ match image.data {
+ Some(d) => {
+ let data = map.image_data(d).unwrap();
+ if data.len() % mem::size_of::<Color>() != 0 {
+ panic!("image shape invalid");
+ }
+ let data: Vec<Color> = unsafe { vec::transmute(data) };
+ Array2::from_shape_vec((height, width), data).unwrap()
+ }
+ None => {
+ continue;
+ // let image_name = map.image_name(image.name)?;
+ // // WARN? Unknown external image
+ // // WARN! Wrong dimensions
+ // str::from_utf8(&image_name).ok()
+ // .and_then(sanitize)
+ // .map(&mut external_tileset_loader)
+ // .transpose()?
+ // .unwrap_or(None)
+ // .unwrap_or_else(|| Array2::from_elem((1, 1), Color::white()))
+ }
+ }
+ }
+ };
+ v.insert(data);
+ }
+ }
+ }
+
+ info!(
+ "{} layers + {} tilesets loaded",
+ layers.len(),
+ tilesets.len()
+ );
+ Ok(Self { tilesets, layers })
+ }
+}
diff --git a/client/src/world/mod.rs b/client/src/world/mod.rs
new file mode 100644
index 0000000..ae985a6
--- /dev/null
+++ b/client/src/world/mod.rs
@@ -0,0 +1,132 @@
+use gamenet::{
+ enums::{Emote, Team, Weapon},
+ SnapObj,
+};
+
+use self::map::Map;
+use crate::client::{helper::get_map_path, ClientMesgOut};
+use std::{collections::BTreeMap, fs::File};
+
+pub mod map;
+
+pub use gamenet::enums;
+
+#[derive(Debug)]
+pub struct Tee {
+ pub local: bool,
+ pub latency: i32,
+ pub score: i32,
+
+ pub team: Team,
+ pub weapon: Weapon,
+ pub armor: i32,
+ pub ammo: i32,
+ pub emote: Emote,
+ pub attack_tick: i32,
+
+ pub tick: i32,
+ pub angle: i32,
+ pub x: i32,
+ pub y: i32,
+ pub vel_x: i32,
+ pub vel_y: i32,
+ pub hook_x: i32,
+ pub hook_y: i32,
+ pub hook_dx: i32,
+ pub hook_dy: i32,
+ pub hook_player: i32,
+ pub hook_state: i32,
+}
+
+impl Default for Tee {
+ fn default() -> Self {
+ Self {
+ x: Default::default(),
+ y: Default::default(),
+ local: false,
+ team: Team::Spectators,
+ latency: Default::default(),
+ score: Default::default(),
+ weapon: Weapon::Shotgun,
+ armor: Default::default(),
+ ammo: Default::default(),
+ attack_tick: Default::default(),
+ emote: Emote::Normal,
+ tick: Default::default(),
+ angle: Default::default(),
+ vel_x: Default::default(),
+ vel_y: Default::default(),
+ hook_x: Default::default(),
+ hook_y: Default::default(),
+ hook_dx: Default::default(),
+ hook_dy: Default::default(),
+ hook_player: Default::default(),
+ hook_state: Default::default(),
+ }
+ }
+}
+
+pub struct World {
+ pub map: Map,
+ pub tees: BTreeMap<u16, Tee>,
+}
+
+impl World {
+ pub fn new() -> Self {
+ Self {
+ map: Map::empty(),
+ tees: BTreeMap::new(),
+ }
+ }
+
+ pub fn update(&mut self, m: &ClientMesgOut) {
+ match m {
+ ClientMesgOut::MapChange { name, crc } => {
+ let file = File::open(get_map_path(name.as_str(), *crc)).unwrap();
+ self.map = Map::load(file).unwrap();
+ }
+ ClientMesgOut::Snaps(s) => {
+ self.tees.clear();
+ for (id, o) in s {
+ match o {
+ SnapObj::ClientInfo(_o) => {
+ // TODO
+ }
+ SnapObj::PlayerInfo(o) => {
+ let e = self.tees.entry(*id).or_default();
+ e.local = o.local == 1;
+ e.team = o.team;
+ e.latency = o.latency;
+ e.score = o.score;
+ }
+ SnapObj::Character(c) => {
+ let e = self.tees.entry(*id).or_default();
+ e.ammo = c.ammo_count;
+ e.weapon = c.weapon;
+ e.emote = c.emote;
+ e.attack_tick = c.attack_tick;
+
+ e.x = c.character_core.x;
+ e.y = c.character_core.y;
+ e.vel_x = c.character_core.vel_x;
+ e.vel_y = c.character_core.vel_y;
+
+ e.tick = c.character_core.tick;
+ e.hook_x = c.character_core.hook_x;
+ e.hook_y = c.character_core.hook_y;
+ e.hook_player = c.character_core.hooked_player;
+ e.hook_dx = c.character_core.hook_dx;
+ e.hook_dy = c.character_core.hook_dy;
+ e.hook_state = c.character_core.hook_state;
+ }
+ _ => (),
+ }
+ }
+ }
+ }
+ }
+
+ pub fn local_tee(&self) -> Option<&Tee> {
+ self.tees.values().find(|e| e.local)
+ }
+}