Compare commits

...

6 commits

Author SHA1 Message Date
Tipragot d15bb20183 COMMIT DES COPAINS
Some checks failed
Rust Checks / checks (push) Has been cancelled
Co-authored-by: CoCoSol <CoCoSol007@users.noreply.github.com>
2024-04-28 14:43:53 +02:00
Tipragot 95c1890a1e Fixed mon reuf
Some checks failed
Rust Checks / checks (push) Has been cancelled
2024-04-12 20:52:20 +02:00
Tipragot 27e0aad29d Avancement relatif
Some checks are pending
Rust Checks / checks (push) Waiting to run
2024-04-12 04:36:23 +02:00
Tipragot 01924cdcb8 JE DECEDE NOW
Some checks failed
Rust Checks / checks (push) Has been cancelled
Co-authored-by: CoCoSol <CoCoSol007@users.noreply.github.com>
2024-04-10 23:29:53 +02:00
Tipragot 63afb174cf DADA
Some checks failed
Rust Checks / checks (push) Has been cancelled
Co-authored-by: CoCoSol <CoCoSol007@users.noreply.github.com>
2024-04-08 19:53:21 +02:00
Tipragot 1363883dd5 Remove last system
Some checks are pending
Rust Checks / checks (push) Waiting to run
Co-authored-by: CoCoSol <CoCoSol007@users.noreply.github.com>
2024-04-08 17:45:18 +02:00
50 changed files with 866 additions and 6610 deletions

View file

@ -1,2 +0,0 @@
[build]
rustflags = ["-Z", "threads=8"]

4275
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
[package]
name = "bevnet"
version = "0.2.0"
edition = "2021"
license = "GPL-3.0-or-later"
description = "A library for networking in Bevy."
authors = ["Tipragot <contact@tipragot.fr>"]
keywords = ["bevy", "network", "game"]
categories = ["network-programming", "game-development"]
[lints]
workspace = true
[dependencies]
relay-client = { path = "../relay-client" }
serde = "1.0.196"
bincode = "1.3.3"
dashmap = "5.5.3"
bevy = "0.12.1"
uuid = "1.7.0"

View file

@ -1,152 +0,0 @@
//! A networking library for Bevy.
use std::borrow::Cow;
use std::collections::LinkedList;
use bevy::prelude::*;
use dashmap::DashMap;
use serde::de::DeserializeOwned;
use serde::Serialize;
pub use uuid::Uuid;
/// A connection to a relay server.
#[derive(Resource)]
pub struct Connection(relay_client::Connection);
/// A resource that stores the received messages.
#[derive(Resource)]
pub struct ReceivedMessages(DashMap<u16, LinkedList<(Uuid, Vec<u8>)>>);
impl Connection {
/// Returns the identifier of the connection.
pub const fn identifier(&self) -> Option<Uuid> {
self.0.identifier()
}
}
/// A bevy plugin to make multiplayer game using a relay server.
pub struct NetworkPlugin(String);
impl NetworkPlugin {
/// Create a new [NetworkPlugin] plugin with the given domain for the relay
/// server.
pub fn new<'a>(domain: impl Into<Cow<'a, str>>) -> Self {
Self(domain.into().into_owned())
}
}
/// Update the relay connection.
fn update_connection(mut connection: ResMut<Connection>, received_messages: Res<ReceivedMessages>) {
let messages = connection.0.update();
for (sender, mut message) in messages {
if message.len() < 2 {
error!("message too short received");
continue;
}
let id_start = message.len() - 2;
let event_id = u16::from_be_bytes([message[id_start], message[id_start + 1]]);
message.truncate(id_start);
received_messages
.0
.entry(event_id)
.or_default()
.push_back((sender, message));
}
}
/// A system that clear the received messages.
fn clear_received_messages(received_messages: Res<ReceivedMessages>) {
received_messages.0.clear();
}
impl Plugin for NetworkPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(Connection(
relay_client::Connection::new(&self.0).expect("could not create connection"),
))
.insert_resource(ReceivedMessages(DashMap::new()))
.add_systems(PreUpdate, update_connection)
.add_systems(PreUpdate, clear_received_messages.after(update_connection));
}
}
/// A resource that store the last event id used to register an [Event].
///
/// This is used to give an unique id to each event.
#[derive(Resource, Default)]
struct LastEventId(u16);
/// An [Event] used to send an [Event] to another client on the relay server.
#[derive(Event)]
pub struct SendTo<T: Event + DeserializeOwned + Serialize>(pub Uuid, pub T);
/// An [Event] used to receive an [Event] from another client on the relay
/// server.
#[derive(Event)]
pub struct Receive<T: Event + DeserializeOwned + Serialize>(pub Uuid, pub T);
/// A trait that extends a bevy [App] to add multiplayer support.
pub trait NetworkAppExt {
/// Setup the application to manage network events of type `T`.
fn add_network_event<T: Event + DeserializeOwned + Serialize>(&mut self) -> &mut Self;
}
impl NetworkAppExt for App {
fn add_network_event<T: Event + DeserializeOwned + Serialize>(&mut self) -> &mut Self {
// Get a new event id.
let mut event_id = self.world.get_resource_or_insert_with(LastEventId::default);
event_id.0 += 1;
let event_id = event_id.0;
// Register the event.
self.add_event::<SendTo<T>>()
.add_event::<Receive<T>>()
.add_systems(
PreUpdate,
(move |mut events: EventReader<SendTo<T>>, connection: Res<Connection>| {
for event in events.read() {
// Get the size of the serialized event.
let size = match bincode::serialized_size(&event.1) {
Ok(size) => size,
Err(e) => {
error!("failed to serialize event: {}", e);
continue;
}
};
// Serialize the event we add 18 here because we will add the event id (2
// bytes) at the end and after that, the relay client will add the target id
// at the end (16 bytes).
let mut data = Vec::with_capacity(size as usize + 18);
if let Err(e) = bincode::serialize_into(&mut data, &event.1) {
error!("failed to serialize event: {}", e);
continue;
}
// Add the event id.
data.extend_from_slice(&event_id.to_be_bytes());
// Send the event.
connection.0.send(event.0, data);
}
})
.before(update_connection),
)
.add_systems(
PreUpdate,
(move |mut writer: EventWriter<Receive<T>>,
received_messages: Res<ReceivedMessages>| {
if let Some(mut messages) = received_messages.0.get_mut(&event_id) {
while let Some((sender, message)) = messages.pop_front() {
match bincode::deserialize(&message) {
Ok(event) => writer.send(Receive(sender, event)),
Err(e) => error!("failed to deserialize event: {}", e),
}
}
}
})
.before(clear_received_messages)
.after(update_connection),
)
}
}

View file

@ -1,20 +0,0 @@
[package]
name = "border-wars"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"
description = "An online turn based game."
repository = "https://git.tipragot.fr/corentin/border-wars.git"
authors = ["CoCoSol"]
[lints]
workspace = true
[dependencies]
bevy = "0.12.1"
bevy_egui = "0.24.0"
noise = "0.8.2"
paste = "1.0.14"
bevnet = { path = "../bevnet" }
serde = "1.0.197"
rand = "0.8.5"

View file

@ -1,28 +0,0 @@
Hexagon Kit (2.0)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 23-01-2024 11:58
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You can use this content for personal, educational, and commercial purposes.
Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)
------------------------------
• Website : www.kenney.nl
• Donate : www.kenney.nl/donate
• Patreon : patreon.com/kenney
Follow on social media for updates:
• Twitter: twitter.com/KenneyNL
• Instagram: instagram.com/kenney_nl
• Mastodon: mastodon.gamedev.place/@kenney

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View file

@ -1,159 +0,0 @@
//! This module contains the camera systems responsible for movement and
//! scaling.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use crate::CurrentScene;
/// The speed of camera movement.
#[derive(Resource)]
struct CameraSpeedMouvement(f32);
/// The speed of camera scaling.
#[derive(Resource)]
struct CameraSpeedScale(f32);
/// The minimum scale of the camera.
#[derive(Resource)]
struct MinimumScale(f32);
/// The maximum scale of the camera.
#[derive(Resource)]
struct MaximumScale(f32);
/// Key settings for camera movement.
#[derive(Resource)]
pub struct KeysMovementSettings {
/// Key to move the camera up.
pub up: KeyCode,
/// Key to move the camera down.
pub down: KeyCode,
/// Key to move the camera right.
pub right: KeyCode,
/// Key to move the camera left.
pub left: KeyCode,
}
/// A Bevy plugin for the camera.
/// Allows camera movement with the keyboard and scaling with the mouse.
pub struct CameraPlugin;
impl Plugin for CameraPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, init_camera)
.add_systems(Startup, init_resources_for_camera)
.add_systems(
Update,
(keyboard_movement_system, mouse_movement_system)
.run_if(in_state(CurrentScene::Game)),
)
.add_systems(Update, scale_system.run_if(in_state(CurrentScene::Game)));
}
}
/// Initializes the camera.
fn init_camera(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
}
/// Initializes the resources related to the camera.
///
/// - [KeysMovementSettings]: The key settings for camera movement.
/// - [CameraSpeedMouvement]: The speed of camera movement.
/// - [CameraSpeedScale]: The speed of camera scaling.
/// - [MinimumScale]: The minimum scale of the camera.
/// - [MaximumScale]: The maximum scale of the camera.
fn init_resources_for_camera(mut commands: Commands) {
commands.insert_resource(KeysMovementSettings {
up: KeyCode::Z,
down: KeyCode::S,
right: KeyCode::D,
left: KeyCode::Q,
});
commands.insert_resource(CameraSpeedMouvement(400.0));
commands.insert_resource(CameraSpeedScale(0.1));
commands.insert_resource(MinimumScale(0.1));
commands.insert_resource(MaximumScale(10.0));
}
/// Moves the camera with keyboard input.
fn keyboard_movement_system(
mut query: Query<&mut Transform, With<Camera>>,
keys: Res<Input<KeyCode>>,
keys_settings: Res<KeysMovementSettings>,
movement_speed: Res<CameraSpeedMouvement>,
delta_time: Res<Time>,
) {
for mut transform in query.iter_mut() {
let mut dx = 0.0;
let mut dy = 0.0;
for key in keys.get_pressed() {
match *key {
up if up == keys_settings.up => dy += movement_speed.0,
down if down == keys_settings.down => dy -= movement_speed.0,
right if right == keys_settings.right => dx += movement_speed.0,
left if left == keys_settings.left => dx -= movement_speed.0,
_ => continue,
}
}
transform.translation.x += dx * delta_time.delta_seconds();
transform.translation.y += dy * delta_time.delta_seconds();
}
}
/// Moves the camera with mouse input.
fn mouse_movement_system(
mouse_button_input: Res<Input<MouseButton>>,
mut query: Query<(&mut Transform, &OrthographicProjection), With<Camera>>,
windows: Query<&Window>,
mut last_position: Local<Option<Vec2>>,
) {
let window = windows.get_single().expect("Main window not found");
let Some(position) = window.cursor_position() else {
return;
};
if mouse_button_input.just_pressed(MouseButton::Right) {
*last_position = Some(position);
}
if mouse_button_input.just_released(MouseButton::Right) {
*last_position = None;
}
if let Some(old_position) = *last_position {
for (mut transform, projection) in query.iter_mut() {
let offset = (old_position - position).extend(0.0) * Vec3::new(1., -1., 1.);
transform.translation += offset * projection.scale;
}
*last_position = Some(position);
}
}
/// Scales the view with mouse input.
fn scale_system(
mut scroll_event: EventReader<MouseWheel>,
mut query: Query<&mut OrthographicProjection, With<Camera>>,
min_scale: Res<MinimumScale>,
max_scale: Res<MaximumScale>,
scale_speed: Res<CameraSpeedScale>,
) {
for event in scroll_event.read() {
for mut projection in query.iter_mut() {
if event.unit != MouseScrollUnit::Line {
return;
}
let future_scale = event.y.mul_add(-scale_speed.0, projection.scale);
if min_scale.0 < future_scale && future_scale < max_scale.0 {
projection.scale = future_scale;
}
}
}
}

View file

@ -1,46 +0,0 @@
//! The file that contains utility functions, enums, structs for the game.
use bevnet::Uuid;
use bevy::prelude::*;
use networking::PlayerRank;
use serde::{Deserialize, Serialize};
pub mod camera;
pub mod map;
pub mod networking;
pub mod resources;
pub mod scenes;
pub mod ui;
/// A scene of the game.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Hash, States)]
pub enum Scene {
/// When we are in the main menu.
#[default]
Menu,
/// When we are in the lobby waiting for players to join the game.
Lobby,
/// When we play this wonderful game.
Game,
}
/// The current scene of the game.
pub type CurrentScene = Scene;
/// A player in the game.
#[derive(Serialize, Deserialize, Clone, Debug, Component, Resource, PartialEq, Eq, Hash)]
pub struct Player {
/// The name of the player.
pub name: String,
/// The rank of the player.
pub rank: PlayerRank,
/// The uuid of the player.
pub uuid: Uuid,
/// The color of the player.
pub color: (u8, u8, u8),
}

View file

@ -1,27 +0,0 @@
//! The main entry point of the game.
use bevy::prelude::*;
use border_wars::camera::CameraPlugin;
use border_wars::map::generation::MapGenerationPlugin;
use border_wars::map::ownership::OwnershipPlugin;
use border_wars::map::renderer::RendererPlugin;
use border_wars::map::selected_tile::SelectTilePlugin;
use border_wars::networking::NetworkingPlugin;
use border_wars::resources::ResourcesPlugin;
use border_wars::scenes::ScenesPlugin;
use border_wars::ui::UiPlugin;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(ScenesPlugin)
.add_plugins(RendererPlugin)
.add_plugins(CameraPlugin)
.add_plugins(SelectTilePlugin)
.add_plugins(NetworkingPlugin)
.add_plugins(MapGenerationPlugin)
.add_plugins(UiPlugin)
.add_plugins(OwnershipPlugin)
.add_plugins(ResourcesPlugin)
.run();
}

View file

@ -1,99 +0,0 @@
//! All functions related to the generation of the map.
use bevy::prelude::*;
use noise::{NoiseFn, Perlin};
use serde::{Deserialize, Serialize};
use super::hex::*;
use super::{Tile, TilePosition};
/// A plugin to handle the map generation.
pub struct MapGenerationPlugin;
/// The zoom of the map during the generation.
const MAP_GENERATION_SCALE: f32 = 5.;
impl Plugin for MapGenerationPlugin {
fn build(&self, app: &mut App) {
app.add_event::<StartMapGeneration>()
.add_event::<EndMapGeneration>()
.add_systems(
Update,
(delete_map, generate_map.after(delete_map))
.run_if(in_state(crate::CurrentScene::Game)),
);
}
}
/// An event to trigger the generation of the map.
#[derive(Event, Serialize, Deserialize, Clone, Copy)]
pub struct StartMapGeneration {
/// The seed used to generate the map.
pub seed: u32,
/// The radius of the map.
pub radius: u16,
}
/// An event send when the map is generated.
#[derive(Event)]
pub struct EndMapGeneration;
/// Generate each tiles of the map if the [StartMapGeneration] is received.
///
/// The map is generated using a [Perlin] noise and a [HexSpiral].
///
/// It's generated one tile at a time, until the spiral is finished.
fn generate_map(
mut start_generation_events: EventReader<StartMapGeneration>,
mut end_generation_writer: EventWriter<EndMapGeneration>,
mut commands: Commands,
mut local_noise: Local<Option<Perlin>>,
mut local_spiral: Local<Option<HexSpiral<i32>>>,
) {
// Handle map generation events and create the spiral and the noise.
for event in start_generation_events.read() {
*local_noise = Some(Perlin::new(event.seed));
*local_spiral = Some(TilePosition::new(0, 0).spiral(event.radius as usize));
}
// Check if the map is being generated.
let (Some(noise), Some(spiral)) = (local_noise.as_ref(), local_spiral.as_mut()) else {
return;
};
// Spawn a tile until the spiral is finished
// If the map is generated, we send [EndMapGeneration] and set the local
// variables to None.
if let Some(position) = spiral.next() {
commands.spawn((get_tile_type(position, noise), position as TilePosition));
} else {
end_generation_writer.send(EndMapGeneration);
*local_noise = None;
*local_spiral = None;
}
}
/// Returns the type of the [HexPosition] with the given noise.
fn get_tile_type(position: HexPosition<i32>, noise: &Perlin) -> Tile {
let pixel_position = position.to_pixel_coordinates() / MAP_GENERATION_SCALE;
let value = noise.get([pixel_position.x as f64, pixel_position.y as f64]);
match value {
v if v <= -0.4 => Tile::Hill,
v if v >= 0.4 => Tile::Forest,
_ => Tile::Grass,
}
}
/// Despawns the tiles if the event [StartMapGeneration] is received.
fn delete_map(
mut commands: Commands,
query: Query<Entity, With<Tile>>,
mut start_generation_events: EventReader<StartMapGeneration>,
) {
for _ in start_generation_events.read() {
for entity in query.iter() {
commands.entity(entity).despawn_recursive();
}
}
}

View file

@ -1,383 +0,0 @@
//! All functions related to calculations in a hexagonal grid.
use std::ops::{
Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Rem, RemAssign, Sub, SubAssign,
};
use bevy::prelude::*;
use paste::paste;
/// Represents a number that can be used in calculations for hexagonal grids.
pub trait Number:
Copy
+ PartialEq
+ PartialOrd
+ Add<Output = Self>
+ Sub<Output = Self>
+ Mul<Output = Self>
+ Div<Output = Self>
+ Rem<Output = Self>
+ Neg<Output = Self>
+ AddAssign
+ SubAssign
+ MulAssign
+ DivAssign
+ RemAssign
+ std::fmt::Debug
{
/// The number -2.
const MINUS_TWO: Self;
/// The number -1.
const MINUS_ONE: Self;
/// The number 0.
const ZERO: Self;
/// The number 1.
const ONE: Self;
/// The number 2.
const TWO: Self;
/// Returns the maximum of `self` and `other`.
fn max(self, other: Self) -> Self {
if self > other { self } else { other }
}
/// Returns the minimum of `self` and `other`.
fn min(self, other: Self) -> Self {
if self < other { self } else { other }
}
/// Returns the absolute value of `self`.
fn abs(self) -> Self {
if self < Self::ZERO { -self } else { self }
}
/// Converts an `usize` to `Self`.
fn from_usize(value: usize) -> Self;
/// Converts `self` to an `f32`.
fn to_f32(self) -> f32;
/// Converts an `f32` to `Self`.
fn from_f32(value: f32) -> Self;
}
/// Implements the `Number` trait for the given types.
macro_rules! number_impl {
($($t:ty,)*) => {paste!{$(
impl Number for $t {
const MINUS_ONE: Self = - [< 1 $t >];
const MINUS_TWO: Self = - [< 2 $t >];
const ZERO: Self = [< 0 $t >];
const ONE: Self = [< 1 $t >];
const TWO: Self = [< 2 $t >];
fn from_usize(value: usize) -> Self {
value as $t
}
fn to_f32(self) -> f32 {
self as f32
}
fn from_f32(value: f32) -> Self {
value as $t
}
}
)*}};
}
number_impl! {
i8, i16, i32, i64, i128, isize,
f32, f64,
}
/// Represents a position in a hexagonal grid.
/// We use the axial coordinate system explained in this
/// [documentation](https://www.redblobgames.com/grids/hexagons/#coordinates).
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Component)]
pub struct HexPosition<T: Number>(pub T, pub T);
/// All possible directions in a hexagonal grid.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum HexDirection {
/// The direction right.
Right,
/// The direction up-right.
UpRight,
/// The direction up-left.
UpLeft,
/// The direction left.
Left,
/// The direction down-left.
DownLeft,
/// The direction down-right.
DownRight,
}
impl HexDirection {
/// Returns the vector ([HexPosition]) of the direction.
///
/// # Example
///
/// ```no_run
/// use border_wars::map::hex::{HexDirection, HexPosition};
///
/// let direction = HexDirection::Right;
/// assert_eq!(direction.to_vector(), HexPosition(1, 0));
/// ```
pub const fn to_vector<T: Number>(self) -> HexPosition<T> {
match self {
Self::Right => HexPosition(T::ONE, T::ZERO),
Self::UpRight => HexPosition(T::ONE, T::MINUS_ONE),
Self::UpLeft => HexPosition(T::ZERO, T::MINUS_ONE),
Self::Left => HexPosition(T::MINUS_ONE, T::ZERO),
Self::DownLeft => HexPosition(T::MINUS_ONE, T::ONE),
Self::DownRight => HexPosition(T::ZERO, T::ONE),
}
}
}
/// A hexagonal ring iterator.
pub struct HexRing<T: Number> {
/// The current position in the ring.
current: HexPosition<T>,
/// The direction of the current position to the next in the ring.
direction: HexDirection,
/// The radius of the ring.
radius: usize,
/// The index of the current position in the ring.
index: usize,
}
impl<T: Number> Iterator for HexRing<T> {
type Item = HexPosition<T>;
fn next(&mut self) -> Option<Self::Item> {
if self.index >= self.radius {
self.direction = match self.direction {
HexDirection::Right => HexDirection::UpRight,
HexDirection::UpRight => HexDirection::UpLeft,
HexDirection::UpLeft => HexDirection::Left,
HexDirection::Left => HexDirection::DownLeft,
HexDirection::DownLeft => HexDirection::DownRight,
HexDirection::DownRight => return None,
};
self.index = 0;
}
let result = self.current;
self.current += self.direction.to_vector();
self.index += 1;
Some(result)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = match self.direction {
HexDirection::Right => self.radius * 6,
HexDirection::UpRight => self.radius * 5,
HexDirection::UpLeft => self.radius * 4,
HexDirection::Left => self.radius * 3,
HexDirection::DownLeft => self.radius * 2,
HexDirection::DownRight => self.radius,
} - self.index;
(remaining, Some(remaining))
}
}
/// A hexagonal spiral iterator.
pub struct HexSpiral<T: Number> {
/// The origin of the spiral.
origin: HexPosition<T>,
/// The current ring of the spiral.
current: HexRing<T>,
/// The radius of the spiral.
radius: usize,
/// The index of the current ring in the spiral.
index: usize,
}
impl<T: Number> Iterator for HexSpiral<T> {
type Item = HexPosition<T>;
fn next(&mut self) -> Option<Self::Item> {
// The origin of the spiral.
if self.index == 0 {
self.index += 1;
return Some(self.origin);
}
if self.index > self.radius {
return None;
}
let mut result = self.current.next();
if result.is_none() && self.index < self.radius {
self.index += 1;
self.current = self.origin.ring(self.index);
result = self.current.next();
}
result
}
}
impl<T: Number> HexPosition<T> {
/// Creates a new [HexPosition].
pub const fn new(x: T, y: T) -> Self {
Self(x, y)
}
/// Converts the current [HexPosition] into a pixel coordinate.
///
/// If you want to learn more about pixel coordinates conversion,
/// you can check the
/// [documentation](https://www.redblobgames.com/grids/hexagons/#hex-to-pixel).
///
/// # Example
///
/// ```no_run
/// use bevy::math::Vec2;
/// use border_wars::map::hex::HexPosition;
///
/// let position = HexPosition(1, 0);
/// assert_eq!(position.to_pixel_coordinates(), (3f32.sqrt(), 0.0).into());
/// ```
pub fn to_pixel_coordinates(&self) -> Vec2 {
Vec2::new(
3f32.sqrt()
.mul_add(T::to_f32(self.0), 3f32.sqrt() / 2.0 * T::to_f32(self.1)),
3.0 / 2.0 * T::to_f32(self.1),
)
}
/// Returns the distance between two [HexPosition]s.
///
/// # How it works
///
/// In the hexagonal grid, using the
/// [cube coordinate system](https://www.redblobgames.com/grids/hexagons/#coordinates),
/// it's akin to a cube in 3D space.
/// The Manhattan distance between two positions is equal to half of
/// the sum of abs(dx) + abs(dy) + abs(dz).
/// However, in hexagonal grids, z is defined as -q - r.
///
/// # Example
///
/// ```no_run
/// use border_wars::map::hex::HexPosition;
///
/// let a = HexPosition(0, 0);
/// let b = HexPosition(1, 1);
///
/// assert_eq!(a.distance(b), 2);
/// ```
pub fn distance(self, other: Self) -> T {
let Self(x, y) = self - other;
x.abs() + y.abs() + (x + y).abs() / T::TWO
}
/// Returns the hexagonal ring of the given radius.
/// If you want to learn more about hexagonal grids, check the
/// [documentation](https://www.redblobgames.com/grids/hexagons/#rings)
///
/// # Example
///
/// ```no_run
/// use border_wars::map::hex::HexPosition;
///
/// let position = HexPosition(0, 0);
/// let radius = 1;
///
/// for ring_position in position.ring(radius) {
/// println!("{:?}", ring_position);
/// }
/// ```
pub fn ring(self, radius: usize) -> HexRing<T> {
HexRing {
current: self + HexDirection::DownLeft.to_vector() * T::from_usize(radius),
direction: HexDirection::Right,
radius,
index: 0,
}
}
/// Returns the hexagonal spiral of the given radius.
/// If you want to learn more about hexagonal grids, check the
/// [documentation](https://www.redblobgames.com/grids/hexagons/#rings-spiral)
///
/// # Example
///
/// ```no_run
/// use border_wars::map::hex::HexPosition;
///
/// let position = HexPosition(0, 0);
/// let radius = 1;
///
/// for spiral_position in position.spiral(radius) {
/// println!("{:?}", spiral_position);
/// }
/// ```
pub fn spiral(self, radius: usize) -> HexSpiral<T> {
HexSpiral {
origin: self,
current: self.ring(1),
radius,
index: 0,
}
}
}
/// Implementation of the arithmetic operators for hexagonal positions.
macro_rules! impl_ops {
($(($t:ty, $n:ident),)*) => {paste!{$(
impl<T: Number> $t for HexPosition<T> {
type Output = Self;
fn $n(self, rhs: Self) -> Self {
Self(self.0.$n(rhs.0), self.1.$n(rhs.1))
}
}
impl<T: Number> $t<T> for HexPosition<T> {
type Output = Self;
fn $n(self, rhs: T) -> Self {
Self(self.0.$n(rhs), self.1.$n(rhs))
}
}
impl<T: Number> [< $t Assign >] for HexPosition<T> {
fn [< $n _assign >](&mut self, rhs: Self) {
self.0.[< $n _assign >](rhs.0) ;
self.1.[< $n _assign >](rhs.1) ;
}
}
impl<T: Number> [< $t Assign >]<T> for HexPosition<T> {
fn [< $n _assign >](&mut self, rhs: T) {
self.0.[< $n _assign >](rhs);
self.1.[< $n _assign >](rhs);
}
}
)*}};
}
impl_ops! {
(Add, add),
(Sub, sub),
(Mul, mul),
(Div, div),
(Rem, rem),
}

View file

@ -1,70 +0,0 @@
//! Contains all the logic related to the map.
pub mod generation;
pub mod hex;
pub mod ownership;
pub mod renderer;
pub mod selected_tile;
use bevy::prelude::*;
use self::hex::*;
/// The position of a tile in a hexagonal map.
pub type TilePosition = HexPosition<i32>;
/// The tile of the map.
#[derive(Component, Debug)]
pub enum Tile {
/// The breeding tile.
Breeding,
/// The Casern tile.
Casern,
/// The castle tile.
Castle,
/// The hill tile.
Hill,
/// The grass tile.
Grass,
/// The forest tile.
Forest,
/// The mine tile.
Mine,
/// The outpost tile
Outpost,
/// The sawmill tile
Sawmill,
/// The tower tile
Tower,
/// The wall tile
Wall,
}
impl Tile {
/// Returns the text representation of the tile.
pub fn to_text(&self) -> String {
match self {
Self::Breeding => "breeding".to_string(),
Self::Casern => "casern".to_string(),
Self::Castle => "castle".to_string(),
Self::Forest => "forest".to_string(),
Self::Grass => "grass".to_string(),
Self::Hill => "hill".to_string(),
Self::Mine => "mine".to_string(),
Self::Outpost => "outpost".to_string(),
Self::Sawmill => "sawmill".to_string(),
Self::Tower => "tower".to_string(),
Self::Wall => "wall".to_string(),
}
}
}

View file

@ -1,56 +0,0 @@
//! All code related to the ownership of the tiles.
use bevy::prelude::*;
use crate::Player;
/// The owner of a tile.
#[derive(Component, Clone)]
pub struct Owner(pub Player);
/// The plugin to render the ownership of the tiles.
pub struct OwnershipPlugin;
impl Plugin for OwnershipPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, render_ownership);
app.add_systems(Startup, setup_ownership_resources);
}
}
/// The contrast of the ownership colors.
///
/// The value is a number between 0 and 1.
#[derive(Resource)]
pub struct OwnershipColorContrast(pub f32);
/// Init resources related to the ownership of the tiles.
fn setup_ownership_resources(mut commands: Commands) {
commands.insert_resource(OwnershipColorContrast(0.4));
}
/// The type condition for update ownership.
type OwnershipUpdate = Or<(Changed<Owner>, Changed<Sprite>)>;
/// Render the ownership of the tiles by applying colors.
fn render_ownership(
mut query: Query<(&mut Sprite, &Owner), OwnershipUpdate>,
contrast: Res<OwnershipColorContrast>,
) {
for (mut sprite, owner) in query.iter_mut() {
let (r, g, b) = owner.0.color;
let target = mix_colors(Color::rgb_u8(r, g, b), sprite.color, 1. - contrast.0);
sprite.color = target;
}
}
/// Mixes two colors.
fn mix_colors(color1: Color, color2: Color, alpha: f32) -> Color {
let [r1, g1, b1, _] = color1.as_rgba_u8();
let [r2, g2, b2, _] = color2.as_rgba_u8();
let mixed_r = (1.0 - alpha).mul_add(r1 as f32, alpha * r2 as f32).round() as u8;
let mixed_g = (1.0 - alpha).mul_add(g1 as f32, alpha * g2 as f32).round() as u8;
let mixed_b = (1.0 - alpha).mul_add(b1 as f32, alpha * b2 as f32).round() as u8;
Color::rgb_u8(mixed_r, mixed_g, mixed_b)
}

View file

@ -1,106 +0,0 @@
//! All functions related to the rendering of the map.
use bevy::prelude::*;
use bevy::sprite::Anchor;
use crate::map::{Tile, TilePosition};
/// A plugin to render the map.
pub struct RendererPlugin;
impl Plugin for RendererPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, init_resources_for_rendering)
.add_systems(
Update,
render_map.run_if(in_state(crate::CurrentScene::Game)),
)
.insert_resource(ClearColor(Color::rgb_u8(129, 212, 250)));
}
}
/// The gap between the center of the tiles in the map.
#[derive(Resource)]
pub struct TilesGap(pub Vec2);
/// The size of the tiles in the map.
#[derive(Resource, Clone, Copy)]
struct TilesSize(Vec2);
impl Tile {
/// Returns the handle of the image of the tile.
fn get_texture(&self, asset_server: &AssetServer) -> Handle<Image> {
asset_server.load(format!("tiles/{}.png", self.to_text()))
}
/// Returns the size of the image of the tile.
///
/// TODO: we are currently using temporary images that will modify
/// this function in the future.
pub const fn get_image_size(&self) -> Vec2 {
match self {
Self::Breeding => Vec2::new(184., 158.),
Self::Casern => Vec2::new(184., 167.),
Self::Castle => Vec2::new(192., 196.),
Self::Forest => Vec2::new(184., 165.),
Self::Grass => Vec2::new(184., 138.),
Self::Hill => Vec2::new(184., 181.),
Self::Mine => Vec2::new(184., 166.),
Self::Outpost => Vec2::new(184., 208.),
Self::Sawmill => Vec2::new(184., 138.),
Self::Tower => Vec2::new(184., 218.),
Self::Wall => Vec2::new(184., 186.),
}
}
}
/// Init resources related to the rendering of the map.
fn init_resources_for_rendering(mut commands: Commands) {
commands.insert_resource(TilesGap(Vec2 { x: 70., y: 35. }));
commands.insert_resource(TilesSize(Vec2 { x: 125., y: 100. }))
}
/// Renders the map.
fn render_map(
query: Query<(Entity, &TilePosition, &Tile), Changed<Tile>>,
mut commands: Commands,
asset_server: Res<AssetServer>,
tiles_gap: Res<TilesGap>,
tiles_size: Res<TilesSize>,
) {
for (entity, position, tile) in query.iter() {
let texture = tile.get_texture(&asset_server);
let translation_2d = tiles_gap.0 * position.to_pixel_coordinates();
let translation = Vec3::new(
translation_2d.x,
translation_2d.y,
z_position_from_y(translation_2d.y),
);
let scale_2d = tiles_size.0 / tile.get_image_size();
// the y scale is the same as the x scale to keep the aspect ratio.
let scale = Vec3::new(scale_2d.x, scale_2d.x, 1.0);
commands.entity(entity).insert(SpriteBundle {
sprite: Sprite {
anchor: Anchor::BottomCenter,
..default()
},
texture,
transform: Transform {
translation,
scale,
..Default::default()
},
..default()
});
}
}
/// A simple sigmoid function to convert y position to z position.
/// The return value is between 0 and 1.
fn z_position_from_y(y: f32) -> f32 {
-1.0 / (1.0 + (-y * 110_f64.powi(-3) as f32).exp())
}

View file

@ -1,131 +0,0 @@
//! All programs related to the selection of a tile.
use bevy::prelude::*;
use super::renderer::TilesGap;
use super::Tile;
/// An event that is triggered when a mouse button is clicked.
///
/// The event contains the position of the cursor in the world.
#[derive(Event)]
struct ClickOnTheWorld(Vec2);
/// A zone that can't be clicked.
/// For exemple the UI of the game.
#[derive(Component)]
pub struct ZoneNotClickable;
/// The currently selected tile.
#[derive(Resource, Default, Debug)]
pub enum SelectedTile {
/// The entity of the selected tile.
Tile(Entity),
/// Zero tile selected.
#[default]
None,
}
impl SelectedTile {
/// Returns the entity of the selected tile.
/// Returns `None` if no tile is selected.
pub const fn get_entity(&self) -> Option<Entity> {
match self {
Self::Tile(entity) => Some(*entity),
Self::None => None,
}
}
}
/// A plugin that handles the selection of tiles.
pub struct SelectTilePlugin;
impl Plugin for SelectTilePlugin {
fn build(&self, app: &mut App) {
app.add_systems(PreUpdate, mouse_handler)
.add_systems(PreUpdate, select_closest_tile)
.add_event::<ClickOnTheWorld>()
.init_resource::<SelectedTile>();
}
}
/// Handles the mouse click and gets the position of the cursor in the world.
/// Finally, it sends an event with the position of the cursor.
fn mouse_handler(
mouse_button_input: Res<Input<MouseButton>>,
windows: Query<&Window>,
cameras: Query<(&Camera, &GlobalTransform)>,
mut events_writer: EventWriter<ClickOnTheWorld>,
not_clickable_zones: Query<(&Node, &GlobalTransform), With<ZoneNotClickable>>,
ui_scale: Res<UiScale>,
) {
if !mouse_button_input.just_pressed(MouseButton::Left) {
return;
}
let window = windows.get_single().expect("Main window not found");
let cursor_position_on_screen = window.cursor_position();
let Some(cursor_position_on_screen) = cursor_position_on_screen else {
return;
};
for (node, global_transform) in not_clickable_zones.iter() {
let rect = node.physical_rect(global_transform, window.scale_factor(), ui_scale.0);
if rect.contains(cursor_position_on_screen) {
return;
}
}
let (camera, camera_transform) = cameras.get_single().expect("Camera not found");
let cursor_position_in_world = camera
.viewport_to_world(camera_transform, cursor_position_on_screen)
.expect("Failed to convert cursor position")
.origin
.truncate();
events_writer.send(ClickOnTheWorld(cursor_position_in_world));
}
/// Get the closest tile to the cursor and select it.
fn select_closest_tile(
tiles: Query<(Entity, &Transform, &Tile)>,
mut click_event_reader: EventReader<ClickOnTheWorld>,
tile_gap: Res<TilesGap>,
mut current_entity: ResMut<SelectedTile>,
) {
for click_event in click_event_reader.read() {
// The closest tile and its position.
let mut closest_entity: Option<Entity> = None;
let mut closest_position: Option<f32> = None;
// To keep the aspect ratio.
let click_position = click_event.0 / tile_gap.0;
for (tile_entity, tile_transform, tile_type) in tiles.iter() {
let tile_size = tile_type.get_image_size();
let tile_scale = tile_transform.scale.truncate();
let mut tile_position = tile_transform.translation.truncate() / tile_gap.0;
// The origine of the tile is the bottom center.
tile_position.y += (tile_size.y / 2.0) * tile_scale.y / tile_gap.0.y;
let distance_to_cursor = tile_position.distance(click_position);
if closest_position.is_none() || closest_position > Some(distance_to_cursor) {
closest_entity = Some(tile_entity);
closest_position = Some(distance_to_cursor);
}
}
if let Some(tile_entity) = closest_entity {
if current_entity.get_entity() == Some(tile_entity) {
*current_entity = SelectedTile::None;
} else {
*current_entity = SelectedTile::Tile(tile_entity);
}
}
}
}

View file

@ -1,118 +0,0 @@
//! All the code related to the check connection (check every X seconds if any
//! player is still connected).
use std::time::Instant;
use bevnet::{Connection, NetworkAppExt, Receive, SendTo};
use bevy::prelude::*;
use bevy::utils::HashMap;
use serde::{Deserialize, Serialize};
use crate::Player;
/// A plugin that check if a player is still connected.
pub struct CheckConnectionPlugin;
/// An event that is trigger when a player is disconnected.
#[derive(Event)]
pub struct PlayerDisconnected(pub Player);
/// An event that is send between all players to check if a player is still
/// connected.
#[derive(Event, Serialize, Deserialize)]
struct IAmConnected(Player);
impl Plugin for CheckConnectionPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
check_connection,
send_check_connection,
handle_disconnect_player,
),
)
.add_event::<PlayerDisconnected>()
.add_network_event::<IAmConnected>();
}
}
/// The interval to check if a player is still connected.
/// We put this into a const because we don't want to change it.
const CHECK_CONNECTION_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
/// A fonction that check if a player is still connected.
fn check_connection(
all_players_query: Query<&Player>,
mut disconnect_event: EventWriter<PlayerDisconnected>,
mut checked_players: Local<HashMap<Player, Instant>>,
mut connect_event: EventReader<Receive<IAmConnected>>,
) {
for Receive(_, IAmConnected(player)) in connect_event.read() {
checked_players.insert(player.clone(), Instant::now());
}
for player in all_players_query.iter() {
if !(*checked_players).contains_key(player) {
checked_players.insert(player.clone(), Instant::now());
}
let Some(last_seen) = (*checked_players).get_mut(player) else {
continue;
};
if last_seen.elapsed() > CHECK_CONNECTION_INTERVAL {
disconnect_event.send(PlayerDisconnected(player.clone()));
checked_players.remove(player);
}
}
}
/// A simple time/instant that implement Default.
struct Time(std::time::Instant);
impl Default for Time {
fn default() -> Self {
Self(std::time::Instant::now())
}
}
/// A fonction that send a check connection event to all players.
fn send_check_connection(
mut check_connection_event: EventWriter<SendTo<IAmConnected>>,
all_players_query: Query<&Player>,
connection: Res<Connection>,
mut timer: Local<Time>,
) {
if timer.0.elapsed() < CHECK_CONNECTION_INTERVAL / 2 {
return;
}
let Some(self_player) = all_players_query
.iter()
.find(|player| connection.identifier() == Some(player.uuid))
else {
return;
};
for player in all_players_query.iter() {
check_connection_event.send(SendTo(player.uuid, IAmConnected(self_player.clone())));
}
timer.0 = std::time::Instant::now();
}
/// A fonction that handle player disconnection.
fn handle_disconnect_player(
mut disconnect_players: EventReader<PlayerDisconnected>,
all_players_query: Query<(&Player, Entity)>,
mut commands: Commands,
) {
for PlayerDisconnected(disconnect_player) in disconnect_players.read() {
let Some((_, entity)) = all_players_query
.iter()
.find(|(player, _entity)| *player == disconnect_player)
else {
continue;
};
commands.entity(entity).despawn();
}
}

View file

@ -1,95 +0,0 @@
//! All the code related to the connection.
use bevnet::{Connection, NetworkAppExt, Receive, SendTo};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use super::PlayerRank;
use crate::{CurrentScene, Player};
/// A plugin that manage connections (add, remove).
pub struct ConnectionPlugin;
impl Plugin for ConnectionPlugin {
fn build(&self, app: &mut App) {
app.add_network_event::<RequestJoin>()
.add_network_event::<AddPlayer>()
.add_network_event::<RemovePlayer>()
.add_systems(
Update,
(accept_connection, handle_new_player, handle_remove_player),
);
}
}
/// An event that is trigger when a new player request to join a game.
#[derive(Event, Serialize, Deserialize)]
pub struct RequestJoin(pub Player);
/// An event that is trigger when a new player is added.
#[derive(Event, Serialize, Deserialize)]
pub struct AddPlayer(Player);
/// An event that is trigger when a player is removed.
#[derive(Event, Serialize, Deserialize)]
pub struct RemovePlayer(pub Player);
/// A fonction that accept new connection.
/// It add the player to the list of all players.
pub fn accept_connection(
all_players_query: Query<&Player>,
mut requests_join_event: EventReader<Receive<RequestJoin>>,
mut add_players_event: EventWriter<SendTo<AddPlayer>>,
state: Res<State<CurrentScene>>,
) {
for request_join in requests_join_event.read() {
let mut new_player = request_join.1.0.clone();
let current_state = *state.get();
if current_state == CurrentScene::Menu {
return;
} else if current_state == CurrentScene::Game {
new_player.rank = PlayerRank::Spectator;
}
add_players_event.send(SendTo(new_player.uuid, AddPlayer(new_player.clone())));
for old_player in all_players_query.iter() {
// Link all players
add_players_event.send(SendTo(old_player.uuid, AddPlayer(new_player.clone())));
add_players_event.send(SendTo(new_player.uuid, AddPlayer(old_player.clone())));
}
}
}
/// A fonction that handle new players when a events is received.
pub fn handle_new_player(mut add_players: EventReader<Receive<AddPlayer>>, mut commands: Commands) {
for add_player in add_players.read() {
commands.spawn(add_player.1.0.clone());
}
}
/// A fonction that handle remove players when a events is received.
pub fn handle_remove_player(
mut remove_players: EventReader<Receive<RemovePlayer>>,
mut commands: Commands,
all_players_query: Query<(Entity, &Player)>,
connection: Res<Connection>,
mut next_scene: ResMut<NextState<CurrentScene>>,
) {
for remove_player in remove_players.read() {
if Some(remove_player.1.0.uuid) == connection.identifier() {
next_scene.set(CurrentScene::Menu);
all_players_query.iter().for_each(|(entity, _)| {
commands.entity(entity).despawn();
});
return;
}
for (entity, player) in all_players_query.iter() {
if remove_player.1.0.uuid == player.uuid {
commands.entity(entity).despawn();
}
}
}
}

View file

@ -1,55 +0,0 @@
//! All the code related to the networking.
use bevnet::{NetworkAppExt, NetworkPlugin, Receive};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use self::check_connection::CheckConnectionPlugin;
use self::connection::ConnectionPlugin;
use crate::map::generation::StartMapGeneration;
use crate::CurrentScene;
pub mod check_connection;
pub mod connection;
/// The plugin for the networking.
pub struct NetworkingPlugin;
impl Plugin for NetworkingPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(NetworkPlugin::new("relay.cocosol.fr".to_string()))
.add_plugins(ConnectionPlugin)
.add_systems(Update, handle_start_game)
.add_network_event::<StartGame>()
.add_plugins(CheckConnectionPlugin);
}
}
/// The rank of the player.
#[derive(PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Debug, Hash)]
pub enum PlayerRank {
/// A spectator. He does not play the game, just renderer the game.
Spectator,
/// An admin. He manages the game and play the game.
Admin,
/// The player. He can join the game and play.
Player,
}
/// The event to start the game, that is send by the admin.
#[derive(Event, Serialize, Deserialize)]
pub struct StartGame(pub StartMapGeneration);
/// A fonction that handle the start of the game.
fn handle_start_game(
mut next_stats: ResMut<NextState<CurrentScene>>,
mut start_game_events: EventReader<Receive<StartGame>>,
mut start_map_generation_writer: EventWriter<StartMapGeneration>,
) {
for event in start_game_events.read() {
next_stats.set(CurrentScene::Game);
start_map_generation_writer.send(event.1.0);
}
}

View file

@ -1,52 +0,0 @@
//! All program related to the resources of the game.
use bevy::prelude::*;
/// The plugin that manage the resources.
pub struct ResourcesPlugin;
impl Plugin for ResourcesPlugin {
fn build(&self, app: &mut App) {
app.add_event::<ResetResources>()
.insert_resource(Resources::initial())
.add_systems(Update, handle_reset_resources);
}
}
/// The resources of the game.
#[derive(Resource, Default)]
pub struct Resources {
/// The stone resource.
pub stone: u32,
/// The wood resource.
pub wood: u32,
/// The food resource.
pub food: u32,
}
impl Resources {
/// Returns the initial resources of the game.
const fn initial() -> Self {
Self {
stone: 100,
wood: 100,
food: 100,
}
}
}
/// An event send to reset the resources of the game.
#[derive(Event)]
pub struct ResetResources;
/// Handles the reset resources event.
fn handle_reset_resources(
mut reset_resources_event: EventReader<ResetResources>,
mut resources: ResMut<Resources>,
) {
for _ in reset_resources_event.read() {
*resources = Resources::initial();
}
}

View file

@ -1,92 +0,0 @@
//! The lobby of the game.
use bevnet::{Connection, SendTo};
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use rand::Rng;
use crate::map::generation::StartMapGeneration;
use crate::networking::connection::RemovePlayer;
use crate::networking::{PlayerRank, StartGame};
use crate::{CurrentScene, Player};
/// The plugin for the lobby.
pub struct LobbyPlugin;
impl Plugin for LobbyPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, lobby_ui.run_if(in_state(CurrentScene::Lobby)));
}
}
/// Display the UI of the lobby.
fn lobby_ui(
mut ctx: EguiContexts,
connection: Res<Connection>,
all_players_query: Query<&Player>,
mut kick_player: EventWriter<SendTo<RemovePlayer>>,
mut map_size: Local<u32>,
mut start_game_event: EventWriter<SendTo<StartGame>>,
) {
// Get our player info.
let Some(self_player) = all_players_query
.iter()
.find(|player| connection.identifier() == Some(player.uuid))
else {
return;
};
egui::CentralPanel::default().show(ctx.ctx_mut(), |ui| {
ui.heading("Border Wars");
ui.separator();
ui.label("Game created");
ui.horizontal(|ui| {
if self_player.rank != PlayerRank::Admin {
return;
}
ui.label("Game ID: ");
ui.text_edit_singleline(&mut connection.identifier().unwrap_or_default().to_string());
});
ui.separator();
for player in all_players_query.iter() {
ui.label(player.name.to_string());
if self_player.rank == PlayerRank::Admin
&& player.rank != PlayerRank::Admin
&& ui.button("Remove").clicked()
{
for sender_id in all_players_query.iter() {
kick_player.send(SendTo(sender_id.uuid, RemovePlayer(player.clone())));
}
}
ui.separator();
}
if self_player.rank != PlayerRank::Admin {
return;
}
ui.add(egui::Slider::new(&mut (*map_size), 1..=3).text("map size"));
if !ui.button("Run the game").clicked() {
return;
}
let seed = rand::thread_rng().gen::<u32>();
let index = *map_size as u16;
let nomber_of_players = all_players_query.iter().count() as u32;
let radius = nomber_of_players as u16 * 2 * (index + 1);
// Start the game.
for player in all_players_query.iter() {
start_game_event.send(SendTo(
player.uuid,
StartGame(StartMapGeneration { seed, radius }),
));
}
});
}

View file

@ -1,79 +0,0 @@
//! The main menu of the game.
use bevnet::{Connection, SendTo, Uuid};
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::networking::connection::RequestJoin;
use crate::networking::PlayerRank;
use crate::{CurrentScene, Player};
/// The plugin for the menu.
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, menu_ui.run_if(in_state(CurrentScene::Menu)));
}
}
/// Display the UI of the menu to host a game or join one.
fn menu_ui(
mut ctx: EguiContexts,
mut connection_string: Local<String>,
mut next_scene: ResMut<NextState<CurrentScene>>,
mut request_join: EventWriter<SendTo<RequestJoin>>,
mut name: Local<String>,
connection: Res<Connection>,
mut commands: Commands,
) {
let Some(uuid) = connection.identifier() else {
return;
};
egui::CentralPanel::default().show(ctx.ctx_mut(), |ui| {
ui.heading("Border Wars");
ui.separator();
ui.label("Name");
ui.text_edit_singleline(&mut *name);
ui.separator();
ui.label("Connect to an existing game:");
ui.horizontal(|ui| {
ui.label("Game ID: ");
ui.text_edit_singleline(&mut *connection_string);
let Ok(game_id) = Uuid::parse_str(&connection_string) else {
return;
};
if ui.button("Join").clicked() {
next_scene.set(CurrentScene::Lobby);
request_join.send(SendTo(
game_id,
RequestJoin(Player {
name: name.clone(),
rank: PlayerRank::Player,
uuid,
color: rand::random::<(u8, u8, u8)>(),
}),
));
}
});
ui.separator();
if ui.button("Create new game").clicked() {
next_scene.set(CurrentScene::Lobby);
commands.spawn(Player {
name: name.clone(),
rank: PlayerRank::Admin,
uuid,
color: rand::random::<(u8, u8, u8)>(),
});
}
});
}

View file

@ -1,21 +0,0 @@
//! The file containing all scenes programs.
use bevy::prelude::*;
use bevy_egui::EguiPlugin;
use crate::CurrentScene;
pub mod lobby;
pub mod menu;
/// The plugin for all scenes.
pub struct ScenesPlugin;
impl Plugin for ScenesPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(EguiPlugin)
.add_state::<CurrentScene>()
.add_plugins(menu::MenuPlugin)
.add_plugins(lobby::LobbyPlugin);
}
}

View file

@ -1,38 +0,0 @@
//! The file that contains the hover logic.
use bevy::prelude::*;
/// The plugin for the hover system.
pub struct HoverPlugin;
impl Plugin for HoverPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, hovering);
}
}
/// A component that stores the hover texture and the original texture.
#[derive(Component, Clone)]
pub struct HoveredTexture {
/// The original texture.
pub texture: Handle<Image>,
/// The hovered texture.
pub hovered_texture: Handle<Image>,
}
/// The system that applies the hover logic by changing the texture.
fn hovering(
mut interaction_query: Query<
(&Interaction, &HoveredTexture, &mut UiImage),
Changed<Interaction>,
>,
) {
for (interaction, textures, mut image) in interaction_query.iter_mut() {
match *interaction {
Interaction::Hovered => image.texture = textures.hovered_texture.clone(),
Interaction::None => image.texture = textures.texture.clone(),
Interaction::Pressed => (),
}
}
}

View file

@ -1,19 +0,0 @@
//! The file that contains the UI logic.
pub mod hover;
pub mod responsive_scale;
use bevy::prelude::*;
use self::hover::HoverPlugin;
use self::responsive_scale::ResponsiveScalingPlugin;
/// The plugin for the UI.
pub struct UiPlugin;
impl Plugin for UiPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(HoverPlugin)
.add_plugins(ResponsiveScalingPlugin);
}
}

View file

@ -1,40 +0,0 @@
//! The file that contains the responsive scaling logic.
use bevy::prelude::*;
/// The plugin for the responsive scaling.
pub struct ResponsiveScalingPlugin;
impl Plugin for ResponsiveScalingPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, init_window_size);
app.add_systems(Update, change_scaling);
}
}
/// The default ui layout size.
#[derive(Resource)]
pub struct UILayoutSize(pub Vec2);
/// Initializes [UILayoutSize].
pub fn init_window_size(mut command: Commands) {
command.insert_resource(UILayoutSize(Vec2::new(1280., 720.)));
}
/// Calculates the ui_scale.0 depending on the [UILayoutSize]
/// in order to make the ui layout responsive.
pub fn change_scaling(
mut ui_scale: ResMut<UiScale>,
windows: Query<&Window>,
size: Res<UILayoutSize>,
) {
let window = windows.get_single().expect("Main window not found");
if window.resolution.physical_height() == 0 {
return;
};
let (a, b) = (
window.resolution.width() / size.0.x,
window.resolution.height() / size.0.y,
);
ui_scale.0 = if a < b { a } else { b } as f64
}

View file

@ -1,20 +0,0 @@
[package]
name = "relay-client"
version = "0.2.0"
edition = "2021"
license = "GPL-3.0-or-later"
description = "A client to use a relay server."
authors = ["Tipragot <contact@tipragot.fr>"]
keywords = ["bevy", "network", "game"]
categories = ["network-programming", "game-development"]
[lints]
workspace = true
[dependencies]
tungstenite = { version = "0.21.0", features = ["rustls-tls-native-roots"] }
mio = { version = "0.8.10", features = ["net", "os-poll"] }
uuid = "1.7.0"
rand = "0.8.5"
home = "0.5.9"
log = "0.4.20"

View file

@ -1,361 +0,0 @@
//! A library containing a client to use a relay server.
use std::borrow::Cow;
use std::collections::LinkedList;
use std::fs;
use std::io::{self};
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use log::warn;
use mio::net::TcpStream;
use rand::seq::SliceRandom;
use tungstenite::handshake::MidHandshake;
use tungstenite::stream::MaybeTlsStream;
use tungstenite::{ClientHandshake, HandshakeError, Message, WebSocket};
use uuid::Uuid;
/// The state of a [Connection].
#[derive(Debug)]
enum ConnectionState {
/// The [Connection] is not connected.
Disconnected,
/// The underlying [TcpStream] is connecting.
Connecting(TcpStream, Instant),
/// The underlying [TcpStream] is connected.
Connected(TcpStream),
/// The websocket handshake is in progress.
Handshaking(MidHandshake<ClientHandshake<MaybeTlsStream<TcpStream>>>),
/// The websocket handshake is finished.
Handshaked(WebSocket<MaybeTlsStream<TcpStream>>),
/// The [Connection] is registering with the relay server.
Registering(WebSocket<MaybeTlsStream<TcpStream>>),
/// The [Connection] is connected.
Active(WebSocket<MaybeTlsStream<TcpStream>>),
}
/// A connection to a relay server.
pub struct Connection {
/// The address list corresponding to the relay server.
address_list: Vec<SocketAddr>,
/// The domain of the relay server.
domain: String,
/// The path to the file where the identifier and secret key are stored.
data_path: PathBuf,
/// The identifier of the connection for the relay server.
identifier: Option<Uuid>,
/// The secret key used to authenticate with the relay server.
secret: Option<Uuid>,
/// A list of messages that needs to be sent.
to_send: Mutex<LinkedList<Message>>,
/// The state of the connection.
state: ConnectionState,
}
impl Connection {
/// Create a new [Connection].
pub fn new<'a>(domain: impl Into<Cow<'a, str>>) -> io::Result<Self> {
let domain = domain.into();
// Loads the identifier and secret key from disk.
let (data_path, identifier, secret) = {
// Find the relay data file path.
let mut path = home::home_dir().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "could not find home directory")
})?;
path.push(".relay-data");
// Check if the file exists.
match false {
true => {
// Read the file and parse the identifier and secret key.
let contents = fs::read(&path)?;
if contents.len() != 32 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid data in .relay-data",
));
}
let identifier = Uuid::from_slice(&contents[..16]).map_err(io::Error::other)?;
let secret = Uuid::from_slice(&contents[16..]).map_err(io::Error::other)?;
(path, Some(identifier), Some(secret))
}
false => (path, None, None),
}
};
// Create the connection and return it.
Ok(Self {
address_list: (domain.as_ref(), 443).to_socket_addrs()?.collect(),
domain: domain.into_owned(),
data_path,
identifier,
secret,
to_send: Mutex::new(LinkedList::new()),
state: ConnectionState::Disconnected,
})
}
/// Get the identifier of the connection.
pub const fn identifier(&self) -> Option<Uuid> {
self.identifier
}
/// Send a message to the target client.
pub fn send<'a>(&self, target_id: Uuid, message: impl Into<Cow<'a, [u8]>>) {
let mut data = message.into().into_owned();
data.extend_from_slice(target_id.as_bytes());
if let Ok(mut to_send) = self.to_send.lock() {
to_send.push_back(Message::binary(data));
}
}
/// Create a new [TcpStream] to the relay server.
fn create_stream(&mut self) -> ConnectionState {
// Take a random relay address.
let Some(address) = self.address_list.choose(&mut rand::thread_rng()) else {
warn!("no relay address available");
return ConnectionState::Disconnected;
};
// Create the new TCP stream.
match TcpStream::connect(address.to_owned()) {
Ok(stream) => ConnectionState::Connecting(stream, Instant::now()),
Err(e) => {
warn!("failed to start connection to the relay server: {e}");
ConnectionState::Disconnected
}
}
}
/// Check if the [TcpStream] of the [Connection] is connected.
fn check_connection(&mut self, stream: TcpStream, start: Instant) -> ConnectionState {
// Check for connection errors.
if let Err(e) = stream.take_error() {
warn!("failed to connect to the relay server: {e}");
return ConnectionState::Disconnected;
}
// Check if the stream is connected.
let connected = match stream.peek(&mut [0]) {
Ok(_) => true,
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => true,
Err(ref e) if e.kind() == io::ErrorKind::NotConnected => false,
Err(e) => {
warn!("failed to connect to the relay server: {e}");
return ConnectionState::Disconnected;
}
};
// Check if the connection has timed out.
let elapsed = start.elapsed();
if elapsed > Duration::from_secs(5) {
warn!("connection to the relay server timed out");
return ConnectionState::Disconnected;
}
// Update the connection state if connected.
match connected {
true => ConnectionState::Connected(stream),
false => ConnectionState::Connecting(stream, start),
}
}
/// Start the websocket handshake.
fn start_handshake(&mut self, stream: TcpStream) -> ConnectionState {
match tungstenite::client_tls(format!("wss://{}", self.domain), stream) {
Ok((socket, _)) => ConnectionState::Handshaked(socket),
Err(HandshakeError::Interrupted(handshake)) => ConnectionState::Handshaking(handshake),
Err(HandshakeError::Failure(e)) => {
warn!("handshake failed with the relay server: {e}");
ConnectionState::Disconnected
}
}
}
/// Continue the websocket handshake.
fn continue_handshake(
&mut self,
handshake: MidHandshake<ClientHandshake<MaybeTlsStream<TcpStream>>>,
) -> ConnectionState {
match handshake.handshake() {
Ok((socket, _)) => ConnectionState::Handshaked(socket),
Err(HandshakeError::Interrupted(handshake)) => ConnectionState::Handshaking(handshake),
Err(HandshakeError::Failure(e)) => {
warn!("handshake failed with the relay server: {e}");
ConnectionState::Disconnected
}
}
}
/// Start authentication with the relay server.
fn start_authentication(
&mut self,
mut socket: WebSocket<MaybeTlsStream<TcpStream>>,
) -> ConnectionState {
match (self.identifier, self.secret) {
(Some(identifier), Some(secret)) => {
// Create the authentication message.
let mut data = Vec::with_capacity(32);
data.extend(identifier.as_bytes());
data.extend(secret.as_bytes());
// Send the authentication message.
match socket.send(Message::Binary(data)) {
Ok(()) => ConnectionState::Active(socket),
Err(e) => {
warn!("failed to send authentication message: {e}");
ConnectionState::Disconnected
}
}
}
_ => {
// Send empty authentication message to request a new identifier and secret key.
match socket.send(Message::Binary(vec![])) {
Ok(()) => ConnectionState::Registering(socket),
Err(e) => {
warn!("failed to send registration message: {e}");
ConnectionState::Disconnected
}
}
}
}
}
/// Wait for the registration response.
fn get_registration_response(
&mut self,
mut socket: WebSocket<MaybeTlsStream<TcpStream>>,
) -> ConnectionState {
match socket.read() {
Ok(message) => {
// Check the message length.
let data = message.into_data();
if data.len() != 32 {
warn!("received malformed registration response");
return ConnectionState::Disconnected;
}
// Extract the client identifier and secret.
self.identifier = Some(Uuid::from_slice(&data[..16]).expect("invalid identifier"));
self.secret = Some(Uuid::from_slice(&data[16..]).expect("invalid secret"));
// Save the client identifier and secret.
fs::write(&self.data_path, data).ok();
// Activate the connection.
ConnectionState::Active(socket)
}
Err(tungstenite::Error::Io(ref e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::Interrupted =>
{
ConnectionState::Registering(socket)
}
Err(e) => {
warn!("failed to receive registration response: {e}");
ConnectionState::Disconnected
}
}
}
/// Update the [Connection] by receiving and sending messages.
fn update_connection(
&mut self,
mut socket: WebSocket<MaybeTlsStream<TcpStream>>,
messages: &mut LinkedList<(Uuid, Vec<u8>)>,
) -> ConnectionState {
// Unlock the sending list.
let Ok(mut to_send) = self.to_send.lock() else {
warn!("sending list closed");
return ConnectionState::Disconnected;
};
// Send messages from the send channel to the socket.
while let Some(message) = to_send.pop_front() {
match socket.send(message) {
Ok(()) => (),
Err(tungstenite::Error::Io(ref e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::Interrupted =>
{
break;
}
Err(e) => {
warn!("relay connection closed: {e}");
return ConnectionState::Disconnected;
}
}
}
// Receive messages from the socket and send them to the receive channel.
loop {
match socket.read() {
Ok(message) => {
// Check the message length.
let mut data = message.into_data();
if data.len() < 16 {
warn!("received malformed message with length: {}", data.len());
continue;
}
// Extract the sender ID.
let id_start = data.len() - 16;
let sender_id = Uuid::from_slice(&data[id_start..]).expect("invalid sender id");
data.truncate(id_start);
// Add the message to the message list.
messages.push_back((sender_id, data));
}
Err(tungstenite::Error::Io(ref e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::Interrupted =>
{
break;
}
Err(e) => {
warn!("relay connection closed: {e}");
return ConnectionState::Disconnected;
}
}
}
// Keep the connection connected.
ConnectionState::Active(socket)
}
/// Update the [Connection] and return the received messages.
///
/// This function will connect to the relay server if it's not already
/// connected, and will send and receive messages from the relay server
/// if it's connected.
///
/// This function will not block the current thread.
pub fn update(&mut self) -> LinkedList<(Uuid, Vec<u8>)> {
let mut messages = LinkedList::new();
self.state = match std::mem::replace(&mut self.state, ConnectionState::Disconnected) {
ConnectionState::Disconnected => self.create_stream(),
ConnectionState::Connecting(stream, start) => self.check_connection(stream, start),
ConnectionState::Connected(stream) => self.start_handshake(stream),
ConnectionState::Handshaking(handshake) => self.continue_handshake(handshake),
ConnectionState::Handshaked(socket) => self.start_authentication(socket),
ConnectionState::Registering(socket) => self.get_registration_response(socket),
ConnectionState::Active(socket) => self.update_connection(socket, &mut messages),
};
messages
}
}

View file

@ -1,22 +0,0 @@
[package]
name = "relay-server"
version = "0.2.0"
edition = "2021"
license = "GPL-3.0-or-later"
description = "A relay server for bevnet."
authors = ["Tipragot <contact@tipragot.fr>"]
keywords = ["bevy", "network", "game"]
categories = ["network-programming", "game-development"]
[lints]
workspace = true
[dependencies]
tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread"] }
axum = { version = "0.7.4", features = ["ws"] }
uuid = { version = "1.7.0", features = ["v4"] }
lazy_static = "1.4.0"
futures = "0.3.30"
dashmap = "5.5.3"
anyhow = "1.0.79"
sled = "0.34.7"

View file

@ -1,160 +0,0 @@
//! A relay server for bevnet.
use std::io;
use anyhow::bail;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::WebSocketUpgrade;
use axum::routing::get;
use axum::Router;
use dashmap::DashMap;
use futures::{SinkExt, StreamExt};
use lazy_static::lazy_static;
use sled::transaction::{ConflictableTransactionResult, TransactionalTree};
use sled::{Db, IVec};
use tokio::sync::mpsc::{channel, Receiver, Sender};
use uuid::Uuid;
lazy_static! {
static ref CLIENTS: DashMap<Uuid, Sender<Vec<u8>>> = DashMap::new();
static ref DB: Db = sled::open("/data/secrets.db").expect("unable to open the database");
}
#[tokio::main]
async fn main() {
let app = Router::new().route(
"/",
get(|ws: WebSocketUpgrade| async {
ws.on_upgrade(|socket| async {
handle(socket).await.ok();
})
}),
);
let listener = tokio::net::TcpListener::bind("0.0.0.0:80")
.await
.expect("failed to bind");
axum::serve(listener, app).await.expect("failed to serve");
}
/// Create a new client and add it to the database.
fn create_client(tx: &TransactionalTree) -> ConflictableTransactionResult<(Uuid, Uuid), io::Error> {
// Generates a new identifier for the client.
let client_id = loop {
// Generates a new random identifier.
let id = Uuid::new_v4();
// Check if the id isn't already in the database.
if tx.get(id.as_bytes())?.is_none() {
break id;
}
};
// Generate a random secret for the client.
let secret = Uuid::new_v4();
// Add the new client to the database.
tx.insert(client_id.as_bytes(), secret.as_bytes())?;
// Returns the client identifier and his secret.
Ok((client_id, secret))
}
/// Handle the websocket connection.
async fn handle(mut socket: WebSocket) -> anyhow::Result<()> {
// Receive the first request from the client.
let data = match socket.recv().await {
Some(Ok(message)) => message.into_data(),
_ => return Ok(()),
};
// If the request is empty it means that the client want a new identifier and
// secret, so we create them and send them to the client.
let client_id = if data.is_empty() {
// Generate the new client.
let (client_id, secret) = DB.transaction(create_client)?;
DB.flush_async().await?;
println!("{client_id} created");
// Send the data to the client.
let mut data = Vec::with_capacity(32);
data.extend_from_slice(client_id.as_bytes());
data.extend_from_slice(secret.as_bytes());
socket.send(Message::Binary(data)).await?;
// Returns the client identifier.
client_id
}
// Otherwise it means that the client want to reuse an identifier, so it will
// send it along with his secret to prove that he is the right client.
else {
// Check for the message length to detect malformed messages.
if data.len() != 32 {
bail!("malformed message");
}
// Get the client identifier and secret from the message.
let client_id = Uuid::from_slice(&data[..16])?;
let secret = Uuid::from_slice(&data[16..])?;
// Check with the database if the secret is correct.
if DB.get(client_id.as_bytes())? != Some(IVec::from(secret.as_bytes())) {
bail!("invalid secret")
}
// Returns the client identifier.
client_id
};
// Handle the client connection.
println!("{client_id} connected");
let (sender, receiver) = channel(128);
CLIENTS.insert(client_id, sender);
handle_client(socket, client_id, receiver).await.ok();
CLIENTS.remove(&client_id);
println!("{client_id} disconnected");
// Returns success.
Ok(())
}
/// Handle the client connection.
async fn handle_client(
socket: WebSocket,
client_id: Uuid,
mut receiver: Receiver<Vec<u8>>,
) -> anyhow::Result<()> {
// Split the socket into sender and receiver.
let (mut writer, mut reader) = socket.split();
// Handle sending messages to the client.
tokio::spawn(async move {
while let Some(message) = receiver.recv().await {
writer.send(Message::Binary(message)).await?;
}
Ok::<(), axum::Error>(())
});
// Handle messages from the client.
while let Some(Ok(message)) = reader.next().await {
// Get the target ID from the message.
let mut data = message.into_data();
if data.len() < 16 {
bail!("malformed message");
}
let id_start = data.len() - 16;
let target_id = Uuid::from_slice(&data[id_start..])?;
// Write the sender ID to the message.
for (i, &byte) in client_id.as_bytes().iter().enumerate() {
data[id_start + i] = byte;
}
// Send the message to the target client.
if let Some(sender) = CLIENTS.get(&target_id) {
sender.send(data).await?;
}
}
// Returns success.
Ok(())
}

21
crates/server/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"
description = "The server of Border Wars"
repository = "https://git.tipragot.fr/corentin/border-wars.git"
[dependencies]
tokio = { version = "1.37.0", features = ["rt-multi-thread"] }
uuid = { version = "1.8.0", features = ["serde", "v7"] }
serde = { version = "1.0.197", features = ["derive"] }
axum = { version = "0.7.5", features = ["ws"] }
ascon-hash = "0.2.0"
rand = "0.8.5"
futures = "0.3.30"
bincode = "1.3.3"
lazy_static = "1.4.0"
# [lints]
# workspace = true

View file

@ -0,0 +1,5 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub struct JoinRequest(pub String, pub Option<Uuid>);

View file

@ -0,0 +1,13 @@
use std::collections::HashMap;
use uuid::Uuid;
pub struct LobbyManager {
connections: HashMap<Uuid, Uuid>,
lobbies: HashMap<Uuid, Lobby>,
}
pub struct Lobby {
id: Uuid,
connections: HashMap<Uuid, Uuid>,
}

View file

@ -0,0 +1,112 @@
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::Arc;
use std::time::Duration;
use ascon_hash::{AsconXof, ExtendableOutput, Update, XofReader};
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{State, WebSocketUpgrade};
use axum::http::HeaderMap;
use axum::response::sse::{Event, KeepAlive};
use axum::response::{IntoResponse, Sse};
use axum::routing::get;
use axum::Router;
use futures::{SinkExt, StreamExt};
use server::JoinRequest;
use tokio::sync::mpsc::{channel, Sender};
use tokio::sync::RwLock;
use uuid::Uuid;
mod lobby;
#[derive(Clone, Default)]
struct GameManager {
lobbies: Arc<RwLock<HashMap<Uuid, HashMap<Uuid, Sender<Vec<u8>>>>>>,
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/ws", get(ws_handler))
.with_state(GameManager::default());
let listener = tokio::net::TcpListener::bind("0.0.0.0:80").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn ws_handler(
State(manager): State<GameManager>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
ws.on_upgrade(|mut socket| async move {
// Handle authentication
let Some(Ok(message)) = socket.recv().await else {
return;
};
let mut reader = AsconXof::default()
.chain(message.into_data())
.finalize_xof();
let mut hash = [0u8; 16];
reader.read(&mut hash);
let id = Uuid::from_bytes(hash);
// Get client request
let Some(Ok(message)) = socket.recv().await else {
return;
};
let Ok(JoinRequest(username, lobby_id)) = bincode::deserialize(&message.into_data()) else {
return;
};
// Check if the client is in a game, if so, connect him to the game
todo!();
// Check if the client is already in a lobby, if so, refuse the connection
let mut lobbies = manager.lobbies.write().await;
for lobby in lobbies.values() {
if lobby.contains_key(&id) {
return;
}
}
// Find or create the lobby
let (lobby_id, lobby) = match lobby_id {
Some(id) if id == Uuid::nil() => lobbies
.iter_mut()
.min_by_key(|(_, lobby)| lobby.len())
.map(|(&id, lobby)| (id, lobby))
.unwrap_or_else(|| {
let id = Uuid::now_v7();
(id, lobbies.entry(id).or_default())
}),
Some(id) => {
let Some(lobby) = lobbies.get_mut(&id) else {
return;
};
(id, lobby)
}
None => {
let id = Uuid::now_v7();
(id, lobbies.entry(id).or_default())
}
};
// Initialize the sending loop
let (sender, mut receiver) = channel(1);
tokio::spawn(async move {
while let Some(message) = receiver.recv().await {
socket.send(Message::Binary(message)).await.ok();
}
});
// Insert the client in the lobby
lobby.insert(id, sender);
drop(lobbies);
// Wait for the client to be ready
let Some(Ok(message)) = socket.recv().await else {
return;
};
})
}

View file

@ -0,0 +1,38 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub enum ClientPacket {
Disconnect,
CreateLobby {
username: String,
public: bool,
},
JoinLobby {
lobby_id: Option<Uuid>,
username: String,
},
IAmReady,
IAmNotReady,
}
#[derive(Serialize, Deserialize)]
pub enum ServerPacket {
Refused(String),
LobbyJoined(Uuid),
LobbyUpdated(Lobby),
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Lobby {
pub public: bool,
pub players: HashMap<Uuid, LobbyPlayer>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct LobbyPlayer {
pub username: String,
pub ready: bool,
}

View file

@ -0,0 +1,18 @@
use axum::http::header::Entry;
use dashmap::DashMap;
use lazy_static::lazy_static;
use scc::{HashMap, HashSet};
use server::LobbyStatus;
use uuid::Uuid;
lazy_static! {
static ref LOBBIES: DashMap<Uuid, LobbyStatus> = DashMap::new();
}
pub(crate) async fn create_lobby(
client_id: Uuid,
username: String,
public: bool,
) -> anyhow::Result<()> {
todo!()
}

View file

@ -0,0 +1,202 @@
use std::borrow::{Borrow, Cow};
use std::collections::HashMap;
use axum::extract::ws::{Message, WebSocket};
use axum::extract::WebSocketUpgrade;
use axum::routing::get;
use axum::Router;
use futures::{SinkExt, StreamExt};
use lazy_static::lazy_static;
use log::warn;
use server::{ClientPacket, Lobby, LobbyPlayer, ServerPacket};
use tokio::sync::mpsc::{channel, Sender};
use tokio::sync::RwLock;
use uuid::Uuid;
struct Client {
status: ClientStatus,
sender: Sender<Vec<u8>>,
}
enum ClientStatus {
Unauthenticated,
InLobby(Uuid),
InGame(Uuid),
}
lazy_static! {
static ref CLIENTS: RwLock<HashMap<Uuid, Client>> = RwLock::new(HashMap::new());
static ref LOBBIES: RwLock<HashMap<Uuid, Lobby>> = RwLock::new(HashMap::new());
}
pub async fn send_message<'a>(client_id: Uuid, message: impl Into<Cow<'a, [u8]>>) {
if let Some(client) = CLIENTS.read().await.get(&client_id) {
client.sender.send(message.into().into_owned()).await.ok();
}
}
pub async fn send_packet(client_id: Uuid, packet: impl Borrow<ServerPacket>) {
let message = match bincode::serialize(packet.borrow()) {
Ok(message) => message,
Err(error) => {
warn!("failed to serialize packet for {}: {}", client_id, error);
return;
}
};
send_message(client_id, message).await;
}
#[tokio::main]
async fn main() {
let app = Router::new().route(
"/",
get(|ws: WebSocketUpgrade| async { ws.on_upgrade(handle_client) }),
);
let listener = tokio::net::TcpListener::bind("0.0.0.0:80")
.await
.expect("failed to bind");
axum::serve(listener, app).await.expect("failed to serve");
}
async fn handle_client(socket: WebSocket) {
let client_id = Uuid::now_v7();
let (mut sender, mut receiver) = socket.split();
let (send_tx, mut send_rx) = channel(16);
tokio::spawn(async move {
while let Some(message) = send_rx.recv().await {
sender.send(Message::Binary(message)).await?;
}
Ok::<(), axum::Error>(())
});
CLIENTS.write().await.insert(
client_id,
Client {
status: ClientStatus::Unauthenticated,
sender: send_tx,
},
);
while let Some(Ok(message)) = receiver.next().await {
let Message::Binary(message) = message else {
continue;
};
let Ok(packet) = bincode::deserialize::<ClientPacket>(&message) else {
warn!("failed to deserialize packet from {}", client_id);
continue;
};
packet_received(client_id, packet).await;
}
packet_received(client_id, ClientPacket::Disconnect).await;
CLIENTS.write().await.remove(&client_id);
}
async fn packet_received(client_id: Uuid, packet: ClientPacket) {
let client = &CLIENTS.read().await[&client_id];
match client.status {
ClientStatus::Unauthenticated => handle_unauthenticated(client_id, packet).await,
ClientStatus::InLobby(lobby_id) => handle_in_lobby(client_id, lobby_id, packet).await,
ClientStatus::InGame(game_id) => handle_in_game(client_id, game_id, packet).await,
}
}
async fn handle_unauthenticated(client_id: Uuid, packet: ClientPacket) {
match packet {
ClientPacket::CreateLobby { username, public } => {
let lobby_id = Uuid::now_v7();
let lobby = Lobby {
public,
players: HashMap::from_iter([(
client_id,
LobbyPlayer {
username,
ready: false,
},
)]),
};
let message = bincode::serialize(&ServerPacket::LobbyUpdated(lobby.clone()))
.expect("failed to serialize lobby");
let mut lobbies = LOBBIES.write().await;
lobbies.insert(lobby_id, lobby);
CLIENTS
.write()
.await
.get_mut(&client_id)
.expect("client not found")
.status = ClientStatus::InLobby(lobby_id);
send_packet(client_id, ServerPacket::LobbyJoined(lobby_id)).await;
send_message(client_id, message).await;
}
ClientPacket::JoinLobby { lobby_id, username } => {
let mut lobbies = LOBBIES.write().await;
let (lobby_id, lobby) = match lobby_id {
Some(id) => {
let Some(lobby) = lobbies.get_mut(&id) else {
return send_packet(
client_id,
ServerPacket::Refused("lobby not found".to_string()),
)
.await;
};
(id, lobby)
}
None => {
let random_lobby = lobbies
.iter_mut()
.filter(|(_, lobby)| lobby.public)
.min_by_key(|(_, lobby)| lobby.players.len());
match random_lobby {
Some((&id, lobby)) => (id, lobby),
None => {
let id = Uuid::now_v7();
(
id,
lobbies.entry(id).or_insert(Lobby {
public: true,
players: HashMap::new(),
}),
)
}
}
}
};
lobby.players.insert(
client_id,
LobbyPlayer {
username,
ready: false,
},
);
CLIENTS
.write()
.await
.get_mut(&client_id)
.expect("client not found")
.status = ClientStatus::InLobby(lobby_id);
send_packet(client_id, ServerPacket::LobbyJoined(lobby_id)).await;
let message = bincode::serialize(&ServerPacket::LobbyUpdated(lobby.clone()))
.expect("failed to serialize lobby"); // PAS BON
for player_id in lobby.players.keys() {
send_message(*player_id, &message).await;
}
}
_ => (),
}
}
async fn handle_in_lobby(client_id: Uuid, lobby_id: Uuid, packet: ClientPacket) {
match packet {
ClientPacket::Disconnect => todo!(),
ClientPacket::IAmReady => todo!(),
ClientPacket::IAmNotReady => todo!(),
_ => (),
}
}
async fn handle_in_game(client_id: Uuid, game_id: Uuid, packet: ClientPacket) {}

37
crates/server/src/lib.rs Normal file
View file

@ -0,0 +1,37 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub enum ClientMessage {
CreateLobby {
username: String,
public: bool,
},
JoinLobby {
username: String,
lobby_id: Option<Uuid>,
},
Ready(bool),
}
#[derive(Serialize, Deserialize)]
pub enum ServerMessage {
Refused(String),
LobbyUpdate(Lobby),
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Lobby {
pub id: Uuid,
pub public: bool,
pub players: HashMap<Uuid, Player>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Player {
pub id: Uuid,
pub username: String,
pub ready: bool,
}

102
crates/server/src/main.rs Normal file
View file

@ -0,0 +1,102 @@
use std::borrow::Borrow;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::sync::Arc;
use ascon_hash::{AsconXof, ExtendableOutput, Update, XofReader};
use axum::extract::ws::Message;
use axum::extract::{State, WebSocketUpgrade};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use futures::{SinkExt, StreamExt};
use lazy_static::lazy_static;
use manager::handle_event;
use server::{ClientMessage, ServerMessage};
use tokio::sync::mpsc::{channel, Sender};
use tokio::sync::RwLock;
use uuid::Uuid;
mod manager;
lazy_static! {
pub static ref CONNECTIONS: ConnectionManager = ConnectionManager::default();
}
#[derive(Default)]
pub struct ConnectionManager(RwLock<HashMap<Uuid, Sender<Vec<u8>>>>);
impl ConnectionManager {
pub async fn send(&self, id: Uuid, message: impl Borrow<ServerMessage>) {
if let Some(sender) = self.0.read().await.get(&id) {
let Ok(message) = bincode::serialize(message.borrow()) else {
eprintln!("failed to serialize message");
return;
};
sender.send(message).await.ok();
}
}
}
pub enum ClientEvent {
Connected,
Message(ClientMessage),
Disconnected,
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/ws", get(ws_handler));
let listener = tokio::net::TcpListener::bind("0.0.0.0:80").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(|mut socket| async move {
// Handle authentication
let Some(Ok(message)) = socket.recv().await else {
return;
};
let mut reader = AsconXof::default()
.chain(message.into_data())
.finalize_xof();
let mut hash = [0u8; 16];
reader.read(&mut hash);
let id = Uuid::from_bytes(hash);
// Start the sending loop
let (mut writer, mut reader) = socket.split();
let (sender, mut receiver) = channel(1);
tokio::spawn(async move {
while let Some(message) = receiver.recv().await {
writer.send(Message::Binary(message)).await?;
}
Ok::<(), axum::Error>(())
});
// Register the client
match CONNECTIONS.0.write().await.entry(id) {
Entry::Occupied(_) => return,
Entry::Vacant(entry) => {
entry.insert(sender);
}
}
// Send the connection event
handle_event(id, ClientEvent::Connected).await;
// Handle incoming messages
while let Some(Ok(message)) = reader.next().await {
let Ok(message) = bincode::deserialize(&message.into_data()) else {
continue;
};
handle_event(id, ClientEvent::Message(message)).await;
}
// Send the disconnection event
handle_event(id, ClientEvent::Disconnected).await;
// Unregister the client
CONNECTIONS.0.write().await.remove(&id);
})
}

View file

@ -0,0 +1,182 @@
use std::collections::hash_map::{Entry, VacantEntry};
use std::collections::HashMap;
use lazy_static::lazy_static;
use server::{Lobby, Player, ServerMessage};
use tokio::sync::mpsc::Sender;
use tokio::sync::{RwLock, RwLockWriteGuard};
use uuid::Uuid;
use crate::{ClientEvent, ClientMessage, CONNECTIONS};
#[derive(Clone, Copy)]
pub enum ClientStatus {
InLobby(Uuid),
InGame(Uuid),
}
lazy_static! {
pub static ref CLIENT_STATUS: RwLock<HashMap<Uuid, ClientStatus>> = RwLock::new(HashMap::new());
pub static ref LOBBIES: RwLock<HashMap<Uuid, Lobby>> = RwLock::new(HashMap::new());
}
pub async fn handle_event(id: Uuid, event: ClientEvent) {
let status = CLIENT_STATUS.read().await.get(&id).copied();
match status {
None => handle_unauthenticated_event(id, event).await,
Some(ClientStatus::InLobby(lobby_id)) => handle_lobby_event(lobby_id, id, event).await,
Some(ClientStatus::InGame(game_id)) => handle_game_event(game_id, id, event).await,
}
}
async fn handle_unauthenticated_event(id: Uuid, event: ClientEvent) {
match event {
ClientEvent::Message(ClientMessage::CreateLobby { username, public }) => {
// Create the lobby
let mut lobbies = LOBBIES.write().await;
let lobby_id = Uuid::now_v7();
let lobby = lobbies.entry(lobby_id).or_insert(Lobby {
id: lobby_id,
public,
players: HashMap::from_iter([(
id,
Player {
id,
username,
ready: false,
},
)]),
});
// Change the client status
CLIENT_STATUS
.write()
.await
.insert(id, ClientStatus::InLobby(lobby.id));
// Send the lobby update
CONNECTIONS
.send(id, ServerMessage::LobbyUpdate(lobby.clone()))
.await;
}
ClientEvent::Message(ClientMessage::JoinLobby { username, lobby_id }) => {
// Find or create the lobby
let mut lobbies = LOBBIES.write().await;
let lobby = match lobby_id {
Some(lobby_id) => {
let Some(lobby) = lobbies.get_mut(&lobby_id) else {
CONNECTIONS
.send(id, ServerMessage::Refused("Lobby not found".to_string()))
.await;
return;
};
lobby
}
None => {
if let Some((_, lobby)) = lobbies
.iter_mut()
.min_by_key(|(_, value)| value.players.len())
{
lobby
} else {
let lobby_id = Uuid::now_v7();
lobbies.entry(lobby_id).or_insert(Lobby {
id: lobby_id,
public: true,
players: HashMap::new(),
})
}
}
};
// Change the client status
CLIENT_STATUS
.write()
.await
.insert(id, ClientStatus::InLobby(lobby.id));
// Add the player to the lobby
lobby.players.insert(
id,
Player {
id,
username,
ready: false,
},
);
// Send the lobby update to all players
let message = ServerMessage::LobbyUpdate(lobby.clone());
for player_id in lobby.players.keys() {
CONNECTIONS.send(*player_id, &message).await;
}
}
_ => (),
};
}
async fn handle_lobby_event(lobby_id: Uuid, id: Uuid, event: ClientEvent) {
match event {
ClientEvent::Message(ClientMessage::Ready(ready)) => {
// Get the lobby
let mut lobbies = LOBBIES.write().await;
let Some(lobby) = lobbies.get_mut(&lobby_id) else {
return;
};
// Get the lobby player
let Some(player) = lobby.players.get_mut(&id) else {
return;
};
// Update the player ready status
player.ready = ready;
// If everyone is ready, start the game
if lobby.players.len() >= 2 && lobby.players.values().all(|p| p.ready) {
todo!("start the game");
return;
}
// Send the lobby update to all players
let message = ServerMessage::LobbyUpdate(lobby.clone());
for player_id in lobby.players.keys() {
CONNECTIONS.send(*player_id, &message).await;
}
}
ClientEvent::Disconnected => {
// Remove the client status
CLIENT_STATUS.write().await.remove(&id);
// Remove the client from the lobby
let mut lobbies = LOBBIES.write().await;
let Some(lobby) = lobbies.get_mut(&lobby_id) else {
return;
};
lobby.players.remove(&id);
// If the lobby is empty, remove it
if lobby.players.is_empty() {
lobbies.remove(&lobby_id);
return;
}
// If everyone is ready, start the game
if lobby.players.len() >= 2 && lobby.players.values().all(|p| p.ready) {
todo!("start the game");
return;
}
// Send the lobby update to all players
let message = ServerMessage::LobbyUpdate(lobby.clone());
for player_id in lobby.players.keys() {
CONNECTIONS.send(*player_id, &message).await;
}
}
_ => (),
}
}
async fn handle_game_event(game_id: Uuid, id: Uuid, event: ClientEvent) {
todo!()
}