//! Game client. //! //! ```text //! game singleplayer (persistent local world) //! game --host [PORT] singleplayer world that friends can join //! game --connect ADDR[:PORT] join a dedicated server or host //! ``` //! Common options: `--name NAME`, `--save FILE`, `--map MAP`. // Release builds on Windows run without a console window. #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] mod input; mod net; mod render; mod ui; use std::{net::SocketAddr, path::PathBuf}; use bevy::{prelude::*, window::WindowResolution}; use bevy_replicon::prelude::*; use bevy_replicon_renet::RepliconRenetPlugins; use game_core::CorePlugin; use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings}; /// How this client runs. #[derive(Clone, Debug)] pub enum Mode { /// Server logic runs in-process without networking. Singleplayer, /// Server logic runs in-process and accepts remote players. Host { port: u16 }, /// Pure client connected to a remote server. Join { addr: SocketAddr }, } /// The local player's login name, used to find our character among replicated ones. #[derive(Resource, Clone, Debug)] pub struct LocalPlayer { pub name: String, } #[derive(Resource, Clone, Debug)] pub struct ClientMode(pub Mode); fn main() -> AppExit { let (mode, name, settings) = parse_args(); let mut app = App::new(); let assets = game_core::assets_dir(); app.add_plugins(( DefaultPlugins .set(AssetPlugin { file_path: assets.to_string_lossy().into_owned(), ..default() }) .set(ImagePlugin::default_nearest()) .set(WindowPlugin { primary_window: Some(Window { title: format!("Station - {name}"), resolution: WindowResolution::new(1280, 800), ..default() }), ..default() }), RepliconPlugins, RepliconRenetPlugins, CorePlugin, )); match &mode { Mode::Singleplayer => { app.add_plugins(GameServerPlugin { settings }); } Mode::Host { port } => { app.add_plugins(( GameServerPlugin { settings }, NetworkServerPlugin { port: *port }, )); } Mode::Join { .. } => {} } app.insert_resource(LocalPlayer { name }) .insert_resource(ClientMode(mode)) .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05))) .add_plugins(( net::NetPlugin, render::RenderPlugin, input::InputPlugin, ui::UiPlugin, )) .run() } fn parse_args() -> (Mode, String, ServerSettings) { let mut mode = Mode::Singleplayer; let mut name = std::env::var("USER") .ok() .filter(|n| game_server::players::valid_name(n)) .unwrap_or_else(|| "Player".to_owned()); let mut settings = ServerSettings { save_path: Some(PathBuf::from("saves/singleplayer.rsn")), ..default() }; let mut args = std::env::args().skip(1).peekable(); while let Some(arg) = args.next() { let mut value = |what: &str| { args.next() .unwrap_or_else(|| usage(&format!("{arg} needs {what}"))) }; match arg.as_str() { "--host" => { let port = match args.peek() { Some(p) if !p.starts_with("--") => args .next() .unwrap() .parse() .unwrap_or_else(|_| usage("invalid port")), _ => game_core::DEFAULT_PORT, }; mode = Mode::Host { port }; } "--connect" => { let addr = value("an address"); let addr = if addr.contains(':') { addr } else { format!("{addr}:{}", game_core::DEFAULT_PORT) }; let addr = std::net::ToSocketAddrs::to_socket_addrs(&addr) .ok() .and_then(|mut a| a.next()) .unwrap_or_else(|| usage(&format!("can't resolve `{addr}`"))); mode = Mode::Join { addr }; } "--name" => name = value("a name"), "--save" => settings.save_path = Some(value("a file").into()), "--no-save" => settings.save_path = None, "--map" => settings.map = value("a map path"), "-h" | "--help" => usage(""), other => usage(&format!("unknown argument `{other}`")), } } if !game_server::players::valid_name(&name) { usage("names may only contain letters, digits, spaces, `_` and `-` (max 24)"); } (mode, name, settings) } fn usage(error: &str) -> ! { if !error.is_empty() { eprintln!("error: {error}\n"); } eprintln!( "usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save FILE | --no-save] [--map MAP]\n\n\ (no mode) singleplayer, world saved to saves/singleplayer.rsn\n\ --host play your world and let others join (default port {port})\n\ --connect join a server (default port {port})\n\ --name character name; characters persist per world\n\ --save world file for singleplayer/host\n\ --map Tiled map for new worlds (default {map})", port = game_core::DEFAULT_PORT, map = game_core::DEFAULT_MAP, ); std::process::exit(if error.is_empty() { 0 } else { 2 }) }