1250 lines
32 KiB
Rust
1250 lines
32 KiB
Rust
use axum::{
|
|
extract::{
|
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
|
State,
|
|
},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use rand::Rng;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::HashMap,
|
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
sync::Arc,
|
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
|
};
|
|
use tokio::{
|
|
net::{TcpListener, UdpSocket},
|
|
sync::{mpsc, RwLock},
|
|
time,
|
|
};
|
|
use tower_http::cors::CorsLayer;
|
|
use uuid::Uuid;
|
|
|
|
const HTTP_PORT: u16 = 47888;
|
|
const DISCOVERY_PORT: u16 = 47889;
|
|
const PROTOCOL_VERSION: u8 = 1;
|
|
const MAGIC: &str = "JE_SKIN_LAN_GAME_V1";
|
|
const TICK_RATE: u64 = 30;
|
|
|
|
const FIELD_HALF_W: f32 = 46.0;
|
|
const FIELD_HALF_H: f32 = 62.0;
|
|
const PADDLE_Y: f32 = -53.0;
|
|
const PADDLE_W: f32 = 16.0;
|
|
const PADDLE_H: f32 = 2.6;
|
|
const PADDLE_SPEED: f32 = 74.0;
|
|
const BALL_RADIUS: f32 = 1.45;
|
|
const BASE_BALL_SPEED: f32 = 58.0;
|
|
|
|
const BRICK_COLS: usize = 12;
|
|
const BRICK_ROWS: usize = 7;
|
|
const BRICK_W: f32 = 6.2;
|
|
const BRICK_H: f32 = 2.9;
|
|
const BRICK_GAP_X: f32 = 0.8;
|
|
const BRICK_GAP_Y: f32 = 0.9;
|
|
const BRICK_TOP: f32 = 44.0;
|
|
|
|
#[derive(Clone)]
|
|
struct AppState {
|
|
inner: Arc<RwLock<Inner>>,
|
|
lan_ip: IpAddr,
|
|
}
|
|
|
|
struct Inner {
|
|
room: Option<Room>,
|
|
discovered: HashMap<String, DiscoveredRoom>,
|
|
}
|
|
|
|
struct Room {
|
|
room_id: String,
|
|
pairing_code: String,
|
|
players: HashMap<String, Player>,
|
|
tickets: HashMap<String, String>,
|
|
phase: GamePhase,
|
|
game: GameState,
|
|
}
|
|
|
|
struct Player {
|
|
id: String,
|
|
name: String,
|
|
role: PlayerRole,
|
|
ready: bool,
|
|
wants_restart: bool,
|
|
connected: bool,
|
|
tx: Option<mpsc::UnboundedSender<ServerMessage>>,
|
|
input: PlayerInput,
|
|
prev_pause: bool,
|
|
paddle_x: f32,
|
|
score: i32,
|
|
lives: i32,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct PlayerInput {
|
|
axis: f32,
|
|
launch: bool,
|
|
pause: bool,
|
|
}
|
|
|
|
struct GameState {
|
|
tick: u64,
|
|
ball_x: f32,
|
|
ball_y: f32,
|
|
ball_vx: f32,
|
|
ball_vy: f32,
|
|
ball_launched: bool,
|
|
bricks: Vec<bool>,
|
|
last_pause_toggle: Instant,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum GamePhase {
|
|
Lobby,
|
|
Running,
|
|
Paused,
|
|
Finished,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct DiscoveredRoom {
|
|
room: LanRoom,
|
|
http_url: String,
|
|
seen_at: Instant,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LanRoom {
|
|
room_id: String,
|
|
pairing_code: String,
|
|
host_name: String,
|
|
address: String,
|
|
port: u16,
|
|
players: usize,
|
|
max_players: usize,
|
|
protocol_version: u8,
|
|
app_version: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum PlayerRole {
|
|
Host,
|
|
Guest,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CreateRoomRequest {
|
|
player_name: String,
|
|
protocol_version: u8,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct JoinRoomRequest {
|
|
pairing_code: String,
|
|
player_name: String,
|
|
protocol_version: u8,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct JoinRoomResponse {
|
|
room_id: String,
|
|
player_id: String,
|
|
ticket: String,
|
|
ws_url: String,
|
|
pairing_code: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RoomsResponse {
|
|
rooms: Vec<LanRoom>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct Beacon {
|
|
magic: String,
|
|
protocol_version: u8,
|
|
room_id: String,
|
|
pairing_code: String,
|
|
host_name: String,
|
|
http_url: String,
|
|
players: usize,
|
|
max_players: usize,
|
|
app_version: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
|
enum ClientMessage {
|
|
Hello {
|
|
protocol_version: u8,
|
|
room_id: String,
|
|
player_id: String,
|
|
ticket: String,
|
|
player_name: String,
|
|
},
|
|
Ready {
|
|
ready: bool,
|
|
},
|
|
Restart,
|
|
Input {
|
|
seq: u64,
|
|
client_time: f64,
|
|
axis: f32,
|
|
launch: bool,
|
|
pause: bool,
|
|
},
|
|
Ping {
|
|
client_time: f64,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
|
enum ServerMessage {
|
|
Welcome {
|
|
room_id: String,
|
|
player_id: String,
|
|
role: PlayerRole,
|
|
tick_rate: u32,
|
|
seed: u64,
|
|
},
|
|
Lobby {
|
|
players: Vec<LobbyPlayer>,
|
|
},
|
|
Snapshot {
|
|
tick: u64,
|
|
server_time: f64,
|
|
phase: String,
|
|
players: Vec<SnapshotPlayer>,
|
|
ball: BallState,
|
|
bricks: String,
|
|
},
|
|
Pong {
|
|
client_time: f64,
|
|
server_time: f64,
|
|
},
|
|
Error {
|
|
code: String,
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct LobbyPlayer {
|
|
id: String,
|
|
name: String,
|
|
ready: bool,
|
|
role: PlayerRole,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SnapshotPlayer {
|
|
id: String,
|
|
name: String,
|
|
role: PlayerRole,
|
|
ready: bool,
|
|
paddle_x: f32,
|
|
score: i32,
|
|
lives: i32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct BallState {
|
|
x: f32,
|
|
y: f32,
|
|
vx: f32,
|
|
vy: f32,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ErrorBody {
|
|
message: String,
|
|
}
|
|
|
|
struct ApiError {
|
|
status: StatusCode,
|
|
message: String,
|
|
}
|
|
|
|
impl ApiError {
|
|
fn bad_request(message: impl Into<String>) -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_REQUEST,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn not_found(message: impl Into<String>) -> Self {
|
|
Self {
|
|
status: StatusCode::NOT_FOUND,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
fn upstream(message: impl Into<String>) -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_GATEWAY,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
(
|
|
self.status,
|
|
Json(ErrorBody {
|
|
message: self.message,
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
pub async fn serve() -> anyhow::Result<()> {
|
|
let state = AppState {
|
|
inner: Arc::new(RwLock::new(Inner {
|
|
room: None,
|
|
discovered: HashMap::new(),
|
|
})),
|
|
lan_ip: guess_lan_ip(),
|
|
};
|
|
|
|
tokio::spawn(discovery_recv(state.clone()));
|
|
tokio::spawn(discovery_broadcast(state.clone()));
|
|
tokio::spawn(game_loop(state.clone()));
|
|
|
|
let app = Router::new()
|
|
.route("/lan/rooms", get(list_rooms).post(create_room))
|
|
.route("/lan/join", post(join_room))
|
|
.route("/game", get(ws_handler))
|
|
.layer(CorsLayer::permissive())
|
|
.with_state(state);
|
|
|
|
let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], HTTP_PORT))).await?;
|
|
log::info!("LAN game server listening on 0.0.0.0:{HTTP_PORT}");
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn list_rooms(State(state): State<AppState>) -> Json<RoomsResponse> {
|
|
let mut inner = state.inner.write().await;
|
|
inner
|
|
.discovered
|
|
.retain(|_, room| room.seen_at.elapsed() < Duration::from_secs(4));
|
|
|
|
Json(RoomsResponse {
|
|
rooms: inner
|
|
.discovered
|
|
.values()
|
|
.map(|room| room.room.clone())
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
async fn create_room(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<CreateRoomRequest>,
|
|
) -> Result<Json<JoinRoomResponse>, ApiError> {
|
|
ensure_protocol(req.protocol_version)?;
|
|
|
|
let room_id = Uuid::new_v4().to_string();
|
|
let player_id = Uuid::new_v4().to_string();
|
|
let ticket = Uuid::new_v4().to_string();
|
|
let pairing_code = format!("{:06}", rand::thread_rng().gen_range(0..1_000_000));
|
|
|
|
let mut players = HashMap::new();
|
|
players.insert(
|
|
player_id.clone(),
|
|
Player {
|
|
id: player_id.clone(),
|
|
name: sanitize_name(&req.player_name),
|
|
role: PlayerRole::Host,
|
|
ready: false,
|
|
wants_restart: false,
|
|
connected: false,
|
|
tx: None,
|
|
input: PlayerInput::default(),
|
|
prev_pause: false,
|
|
paddle_x: 0.0,
|
|
score: 0,
|
|
lives: 3,
|
|
},
|
|
);
|
|
|
|
let mut tickets = HashMap::new();
|
|
tickets.insert(player_id.clone(), ticket.clone());
|
|
|
|
let room = Room {
|
|
room_id: room_id.clone(),
|
|
pairing_code: pairing_code.clone(),
|
|
players,
|
|
tickets,
|
|
phase: GamePhase::Lobby,
|
|
game: GameState::new(),
|
|
};
|
|
|
|
state.inner.write().await.room = Some(room);
|
|
|
|
Ok(Json(JoinRoomResponse {
|
|
room_id,
|
|
player_id,
|
|
ticket,
|
|
ws_url: format!("ws://{}:{HTTP_PORT}/game", state.lan_ip),
|
|
pairing_code: Some(pairing_code),
|
|
}))
|
|
}
|
|
|
|
async fn join_room(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<JoinRoomRequest>,
|
|
) -> Result<Json<JoinRoomResponse>, ApiError> {
|
|
ensure_protocol(req.protocol_version)?;
|
|
|
|
if let Some(response) = try_join_local_room(&state, &req).await? {
|
|
return Ok(Json(response));
|
|
}
|
|
|
|
let remote = {
|
|
let inner = state.inner.read().await;
|
|
inner.discovered.get(&req.pairing_code).cloned()
|
|
}
|
|
.ok_or_else(|| ApiError::not_found("pairing code not found"))?;
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(3))
|
|
.build()
|
|
.map_err(|error| ApiError::upstream(error.to_string()))?;
|
|
|
|
let response = client
|
|
.post(format!("{}/lan/join", remote.http_url))
|
|
.json(&req)
|
|
.send()
|
|
.await
|
|
.map_err(|error| ApiError::upstream(error.to_string()))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(ApiError::upstream(format!(
|
|
"remote join failed: {}",
|
|
response.status()
|
|
)));
|
|
}
|
|
|
|
let body = response
|
|
.json::<JoinRoomResponse>()
|
|
.await
|
|
.map_err(|error| ApiError::upstream(error.to_string()))?;
|
|
|
|
Ok(Json(body))
|
|
}
|
|
|
|
async fn try_join_local_room(
|
|
state: &AppState,
|
|
req: &JoinRoomRequest,
|
|
) -> Result<Option<JoinRoomResponse>, ApiError> {
|
|
let mut inner = state.inner.write().await;
|
|
let Some(room) = inner.room.as_mut() else {
|
|
return Ok(None);
|
|
};
|
|
|
|
if room.pairing_code != req.pairing_code {
|
|
return Ok(None);
|
|
}
|
|
|
|
if room.players.len() >= 2 {
|
|
return Err(ApiError::bad_request("room is full"));
|
|
}
|
|
|
|
let player_id = Uuid::new_v4().to_string();
|
|
let ticket = Uuid::new_v4().to_string();
|
|
|
|
room.players.insert(
|
|
player_id.clone(),
|
|
Player {
|
|
id: player_id.clone(),
|
|
name: sanitize_name(&req.player_name),
|
|
role: PlayerRole::Guest,
|
|
ready: false,
|
|
wants_restart: false,
|
|
connected: false,
|
|
tx: None,
|
|
input: PlayerInput::default(),
|
|
prev_pause: false,
|
|
paddle_x: 0.0,
|
|
score: 0,
|
|
lives: 3,
|
|
},
|
|
);
|
|
room.tickets.insert(player_id.clone(), ticket.clone());
|
|
|
|
Ok(Some(JoinRoomResponse {
|
|
room_id: room.room_id.clone(),
|
|
player_id,
|
|
ticket,
|
|
ws_url: format!("ws://{}:{HTTP_PORT}/game", state.lan_ip),
|
|
pairing_code: Some(room.pairing_code.clone()),
|
|
}))
|
|
}
|
|
|
|
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
|
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
|
}
|
|
|
|
async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
|
let Some(Ok(Message::Text(text))) = socket.recv().await else {
|
|
return;
|
|
};
|
|
|
|
let Ok(ClientMessage::Hello {
|
|
protocol_version,
|
|
room_id,
|
|
player_id,
|
|
ticket,
|
|
player_name,
|
|
}) = serde_json::from_str::<ClientMessage>(&text)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if protocol_version != PROTOCOL_VERSION {
|
|
send_socket_error(
|
|
&mut socket,
|
|
"ProtocolMismatch",
|
|
"protocol version mismatch",
|
|
)
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let (tx, mut rx) = mpsc::unbounded_channel::<ServerMessage>();
|
|
|
|
let role = {
|
|
let mut inner = state.inner.write().await;
|
|
let Some(room) = inner.room.as_mut() else {
|
|
return;
|
|
};
|
|
|
|
if room.room_id != room_id || room.tickets.get(&player_id) != Some(&ticket) {
|
|
return;
|
|
}
|
|
|
|
let Some(player) = room.players.get_mut(&player_id) else {
|
|
return;
|
|
};
|
|
|
|
player.name = sanitize_name(&player_name);
|
|
player.connected = true;
|
|
player.tx = Some(tx.clone());
|
|
player.role.clone()
|
|
};
|
|
|
|
let _ = tx.send(ServerMessage::Welcome {
|
|
room_id: room_id.clone(),
|
|
player_id: player_id.clone(),
|
|
role,
|
|
tick_rate: TICK_RATE as u32,
|
|
seed: 1,
|
|
});
|
|
|
|
broadcast_lobby(&state).await;
|
|
|
|
let (mut sender, mut receiver) = socket.split();
|
|
|
|
tokio::spawn(async move {
|
|
while let Some(message) = rx.recv().await {
|
|
let Ok(text) = serde_json::to_string(&message) else {
|
|
continue;
|
|
};
|
|
|
|
if sender.send(Message::Text(text.into())).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
while let Some(Ok(message)) = receiver.next().await {
|
|
let Message::Text(text) = message else {
|
|
continue;
|
|
};
|
|
|
|
let Ok(message) = serde_json::from_str::<ClientMessage>(&text) else {
|
|
continue;
|
|
};
|
|
|
|
match message {
|
|
ClientMessage::Ready { ready } => {
|
|
set_player_ready(&state, &player_id, ready).await;
|
|
broadcast_lobby(&state).await;
|
|
}
|
|
ClientMessage::Restart => {
|
|
set_player_wants_restart(&state, &player_id).await;
|
|
broadcast_lobby(&state).await;
|
|
}
|
|
ClientMessage::Input {
|
|
seq,
|
|
client_time,
|
|
axis,
|
|
launch,
|
|
pause,
|
|
} => {
|
|
let _input_timing = (seq, client_time);
|
|
set_player_input(&state, &player_id, axis, launch, pause).await;
|
|
}
|
|
ClientMessage::Ping { client_time } => {
|
|
let _ = tx.send(ServerMessage::Pong {
|
|
client_time,
|
|
server_time: now_ms(),
|
|
});
|
|
}
|
|
ClientMessage::Hello { .. } => {}
|
|
}
|
|
}
|
|
|
|
detach_player(&state, &player_id).await;
|
|
broadcast_lobby(&state).await;
|
|
}
|
|
|
|
async fn send_socket_error(socket: &mut WebSocket, code: &str, message: &str) {
|
|
let message = ServerMessage::Error {
|
|
code: code.to_string(),
|
|
message: message.to_string(),
|
|
};
|
|
|
|
if let Ok(text) = serde_json::to_string(&message) {
|
|
let _ = socket.send(Message::Text(text.into())).await;
|
|
}
|
|
}
|
|
|
|
async fn set_player_ready(state: &AppState, player_id: &str, ready: bool) {
|
|
let mut inner = state.inner.write().await;
|
|
if let Some(room) = inner.room.as_mut() {
|
|
if let Some(player) = room.players.get_mut(player_id) {
|
|
player.ready = ready;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn set_player_wants_restart(state: &AppState, player_id: &str) {
|
|
let mut inner = state.inner.write().await;
|
|
if let Some(room) = inner.room.as_mut() {
|
|
if let Some(player) = room.players.get_mut(player_id) {
|
|
player.wants_restart = true;
|
|
player.ready = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn set_player_input(
|
|
state: &AppState,
|
|
player_id: &str,
|
|
axis: f32,
|
|
launch: bool,
|
|
pause: bool,
|
|
) {
|
|
let mut inner = state.inner.write().await;
|
|
if let Some(room) = inner.room.as_mut() {
|
|
if let Some(player) = room.players.get_mut(player_id) {
|
|
player.input.axis = axis.clamp(-1.0, 1.0);
|
|
player.input.launch = launch;
|
|
player.input.pause = pause;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn detach_player(state: &AppState, player_id: &str) {
|
|
let mut inner = state.inner.write().await;
|
|
if let Some(room) = inner.room.as_mut() {
|
|
if let Some(player) = room.players.get_mut(player_id) {
|
|
player.connected = false;
|
|
player.ready = false;
|
|
player.wants_restart = false;
|
|
player.tx = None;
|
|
}
|
|
|
|
let remove_guest = room
|
|
.players
|
|
.get(player_id)
|
|
.map(|player| player.role == PlayerRole::Guest)
|
|
.unwrap_or(false);
|
|
|
|
if remove_guest {
|
|
room.players.remove(player_id);
|
|
room.tickets.remove(player_id);
|
|
}
|
|
|
|
if room.players.values().filter(|player| player.connected).count() < 2 {
|
|
room.phase = GamePhase::Lobby;
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn broadcast_lobby(state: &AppState) {
|
|
let message = {
|
|
let inner = state.inner.read().await;
|
|
let Some(room) = inner.room.as_ref() else {
|
|
return;
|
|
};
|
|
|
|
ServerMessage::Lobby {
|
|
players: room
|
|
.players
|
|
.values()
|
|
.map(|player| LobbyPlayer {
|
|
id: player.id.clone(),
|
|
name: player.name.clone(),
|
|
ready: player.ready,
|
|
role: player.role.clone(),
|
|
})
|
|
.collect(),
|
|
}
|
|
};
|
|
|
|
broadcast(state, message).await;
|
|
}
|
|
|
|
async fn broadcast(state: &AppState, message: ServerMessage) {
|
|
let senders = {
|
|
let inner = state.inner.read().await;
|
|
let Some(room) = inner.room.as_ref() else {
|
|
return;
|
|
};
|
|
|
|
room.players
|
|
.values()
|
|
.filter_map(|player| player.tx.clone())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
for tx in senders {
|
|
let _ = tx.send(message.clone());
|
|
}
|
|
}
|
|
|
|
async fn game_loop(state: AppState) {
|
|
let mut ticker = time::interval(Duration::from_millis(1000 / TICK_RATE));
|
|
|
|
loop {
|
|
ticker.tick().await;
|
|
|
|
let should_broadcast_lobby = {
|
|
let mut inner = state.inner.write().await;
|
|
let Some(room) = inner.room.as_mut() else {
|
|
continue;
|
|
};
|
|
|
|
let snapshot = tick_room(room, 1.0 / TICK_RATE as f32);
|
|
|
|
if let Some(snapshot) = snapshot {
|
|
// Broadcast snapshot below (need to drop read lock first)
|
|
// We'll collect the senders here
|
|
let senders: Vec<_> = room
|
|
.players
|
|
.values()
|
|
.filter_map(|player| player.tx.clone())
|
|
.collect();
|
|
|
|
let snapshot_msg = snapshot;
|
|
drop(inner);
|
|
|
|
for tx in senders {
|
|
let _ = tx.send(snapshot_msg.clone());
|
|
}
|
|
}
|
|
|
|
false
|
|
};
|
|
|
|
if should_broadcast_lobby {
|
|
broadcast_lobby(&state).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn tick_room(room: &mut Room, dt: f32) -> Option<ServerMessage> {
|
|
// Handle Finished phase: check if both players want restart
|
|
if room.phase == GamePhase::Finished {
|
|
let connected_count = room
|
|
.players
|
|
.values()
|
|
.filter(|player| player.connected)
|
|
.count();
|
|
let all_want_restart = connected_count >= 2
|
|
&& room
|
|
.players
|
|
.values()
|
|
.filter(|player| player.connected)
|
|
.all(|player| player.wants_restart);
|
|
|
|
if all_want_restart {
|
|
// Reset to lobby, everyone needs to re-ready
|
|
room.phase = GamePhase::Lobby;
|
|
room.game = GameState::new();
|
|
for player in room.players.values_mut() {
|
|
player.ready = false;
|
|
player.wants_restart = false;
|
|
player.paddle_x = 0.0;
|
|
player.score = 0;
|
|
player.lives = 3;
|
|
player.prev_pause = false;
|
|
player.input = PlayerInput::default();
|
|
}
|
|
return None; // Lobby snapshots are handled by broadcast_lobby
|
|
}
|
|
|
|
// Still finished - keep sending snapshots so frontend shows game over state
|
|
return Some(build_snapshot(room));
|
|
}
|
|
|
|
let connected_count = room
|
|
.players
|
|
.values()
|
|
.filter(|player| player.connected)
|
|
.count();
|
|
let all_ready = connected_count >= 2
|
|
&& room
|
|
.players
|
|
.values()
|
|
.filter(|player| player.connected)
|
|
.all(|player| player.ready);
|
|
|
|
if room.phase == GamePhase::Lobby && all_ready {
|
|
start_match(room);
|
|
}
|
|
|
|
if room.phase == GamePhase::Lobby {
|
|
return None;
|
|
}
|
|
|
|
let pause_pressed = room.players.values().any(|player| {
|
|
player.connected && player.input.pause && !player.prev_pause
|
|
});
|
|
|
|
for player in room.players.values_mut() {
|
|
player.prev_pause = player.input.pause;
|
|
}
|
|
|
|
if pause_pressed
|
|
&& room.game.last_pause_toggle.elapsed() > Duration::from_millis(640)
|
|
{
|
|
room.phase = match room.phase {
|
|
GamePhase::Running => GamePhase::Paused,
|
|
GamePhase::Paused => GamePhase::Running,
|
|
other => other,
|
|
};
|
|
room.game.last_pause_toggle = Instant::now();
|
|
}
|
|
|
|
if room.phase == GamePhase::Running {
|
|
update_game(room, dt);
|
|
}
|
|
|
|
Some(build_snapshot(room))
|
|
}
|
|
|
|
fn start_match(room: &mut Room) {
|
|
room.phase = GamePhase::Running;
|
|
room.game = GameState::new();
|
|
|
|
for player in room.players.values_mut() {
|
|
player.paddle_x = 0.0;
|
|
player.score = 0;
|
|
player.lives = 3;
|
|
player.ready = true;
|
|
player.wants_restart = false;
|
|
player.prev_pause = false;
|
|
player.input = PlayerInput::default();
|
|
}
|
|
}
|
|
|
|
fn update_game(room: &mut Room, dt: f32) {
|
|
for player in room.players.values_mut() {
|
|
if !player.connected {
|
|
continue;
|
|
}
|
|
|
|
player.paddle_x = (player.paddle_x + player.input.axis * PADDLE_SPEED * dt)
|
|
.clamp(
|
|
-FIELD_HALF_W + PADDLE_W * 0.5,
|
|
FIELD_HALF_W - PADDLE_W * 0.5,
|
|
);
|
|
}
|
|
|
|
// Check if any connected player has sent a launch input
|
|
if !room.game.ball_launched {
|
|
let any_launch = room
|
|
.players
|
|
.values()
|
|
.any(|player| player.connected && player.input.launch);
|
|
|
|
if any_launch {
|
|
room.game.ball_launched = true;
|
|
let mut rng = rand::thread_rng();
|
|
let direction_x: f32 = rng.gen_range(-0.72..0.72);
|
|
let len = (direction_x * direction_x + 1.0).sqrt();
|
|
room.game.ball_vx = direction_x / len * BASE_BALL_SPEED;
|
|
room.game.ball_vy = 1.0 / len * BASE_BALL_SPEED;
|
|
} else {
|
|
// Ball stays at paddle position, still send snapshots
|
|
let has_connected = room.players.values().any(|player| player.connected);
|
|
if has_connected {
|
|
room.game.tick += 1;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
room.game.ball_x += room.game.ball_vx * dt;
|
|
room.game.ball_y += room.game.ball_vy * dt;
|
|
|
|
if room.game.ball_x - BALL_RADIUS <= -FIELD_HALF_W {
|
|
room.game.ball_x = -FIELD_HALF_W + BALL_RADIUS;
|
|
room.game.ball_vx = room.game.ball_vx.abs();
|
|
} else if room.game.ball_x + BALL_RADIUS >= FIELD_HALF_W {
|
|
room.game.ball_x = FIELD_HALF_W - BALL_RADIUS;
|
|
room.game.ball_vx = -room.game.ball_vx.abs();
|
|
}
|
|
|
|
if room.game.ball_y + BALL_RADIUS >= FIELD_HALF_H {
|
|
room.game.ball_y = FIELD_HALF_H - BALL_RADIUS;
|
|
room.game.ball_vy = -room.game.ball_vy.abs();
|
|
}
|
|
|
|
resolve_paddle_collision(room);
|
|
resolve_brick_collision(room);
|
|
|
|
if room.game.ball_y - BALL_RADIUS < -FIELD_HALF_H {
|
|
for player in room
|
|
.players
|
|
.values_mut()
|
|
.filter(|player| player.connected)
|
|
{
|
|
player.lives -= 1;
|
|
}
|
|
|
|
if room
|
|
.players
|
|
.values()
|
|
.filter(|player| player.connected)
|
|
.all(|player| player.lives <= 0)
|
|
{
|
|
room.phase = GamePhase::Finished;
|
|
} else {
|
|
reset_ball(&mut room.game);
|
|
}
|
|
}
|
|
|
|
room.game.tick += 1;
|
|
}
|
|
|
|
fn resolve_paddle_collision(room: &mut Room) {
|
|
if room.game.ball_vy >= 0.0 {
|
|
return;
|
|
}
|
|
|
|
let paddle_top = PADDLE_Y + PADDLE_H * 0.5;
|
|
let paddle_bottom = PADDLE_Y - PADDLE_H * 0.5;
|
|
|
|
for player in room.players.values() {
|
|
if !player.connected {
|
|
continue;
|
|
}
|
|
|
|
let left = player.paddle_x - PADDLE_W * 0.5;
|
|
let right = player.paddle_x + PADDLE_W * 0.5;
|
|
|
|
let hit = room.game.ball_y - BALL_RADIUS <= paddle_top
|
|
&& room.game.ball_y + BALL_RADIUS >= paddle_bottom
|
|
&& room.game.ball_x >= left - BALL_RADIUS
|
|
&& room.game.ball_x <= right + BALL_RADIUS;
|
|
|
|
if !hit {
|
|
continue;
|
|
}
|
|
|
|
room.game.ball_y = paddle_top + BALL_RADIUS + 0.04;
|
|
let offset = (room.game.ball_x - player.paddle_x) / (PADDLE_W * 0.5);
|
|
room.game.ball_vx = offset * BASE_BALL_SPEED * 0.9;
|
|
room.game.ball_vy = BASE_BALL_SPEED.abs();
|
|
normalize_ball_speed(&mut room.game);
|
|
break;
|
|
}
|
|
}
|
|
|
|
fn resolve_brick_collision(room: &mut Room) {
|
|
for index in 0..room.game.bricks.len() {
|
|
if !room.game.bricks[index] {
|
|
continue;
|
|
}
|
|
|
|
let (left, right, top, bottom) = brick_bounds(index);
|
|
let closest_x = room.game.ball_x.clamp(left, right);
|
|
let closest_y = room.game.ball_y.clamp(bottom, top);
|
|
let dx = room.game.ball_x - closest_x;
|
|
let dy = room.game.ball_y - closest_y;
|
|
|
|
if dx * dx + dy * dy > BALL_RADIUS * BALL_RADIUS {
|
|
continue;
|
|
}
|
|
|
|
let overlap_x = (room.game.ball_x + BALL_RADIUS - left)
|
|
.min(right - (room.game.ball_x - BALL_RADIUS));
|
|
let overlap_y = (room.game.ball_y + BALL_RADIUS - bottom)
|
|
.min(top - (room.game.ball_y - BALL_RADIUS));
|
|
|
|
if overlap_x < overlap_y {
|
|
room.game.ball_vx *= -1.0;
|
|
} else {
|
|
room.game.ball_vy *= -1.0;
|
|
}
|
|
|
|
room.game.bricks[index] = false;
|
|
|
|
for player in room
|
|
.players
|
|
.values_mut()
|
|
.filter(|player| player.connected)
|
|
{
|
|
player.score += 40;
|
|
}
|
|
|
|
if room.game.bricks.iter().all(|alive| !alive) {
|
|
room.game.bricks = vec![true; BRICK_COLS * BRICK_ROWS];
|
|
reset_ball(&mut room.game);
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
fn brick_bounds(index: usize) -> (f32, f32, f32, f32) {
|
|
let row = index / BRICK_COLS;
|
|
let col = index % BRICK_COLS;
|
|
let total_width = BRICK_COLS as f32 * BRICK_W + (BRICK_COLS as f32 - 1.0) * BRICK_GAP_X;
|
|
let start_x = -total_width / 2.0 + BRICK_W / 2.0;
|
|
let x = start_x + col as f32 * (BRICK_W + BRICK_GAP_X);
|
|
let y = BRICK_TOP - row as f32 * (BRICK_H + BRICK_GAP_Y);
|
|
|
|
(
|
|
x - BRICK_W / 2.0,
|
|
x + BRICK_W / 2.0,
|
|
y + BRICK_H / 2.0,
|
|
y - BRICK_H / 2.0,
|
|
)
|
|
}
|
|
|
|
fn reset_ball(game: &mut GameState) {
|
|
game.ball_x = 0.0;
|
|
game.ball_y = PADDLE_Y + PADDLE_H * 0.5 + BALL_RADIUS + 0.4;
|
|
game.ball_vx = 0.0;
|
|
game.ball_vy = 0.0;
|
|
game.ball_launched = false;
|
|
}
|
|
|
|
fn normalize_ball_speed(game: &mut GameState) {
|
|
let len = (game.ball_vx * game.ball_vx + game.ball_vy * game.ball_vy).sqrt();
|
|
if len <= 0.01 {
|
|
game.ball_vx = 18.0;
|
|
game.ball_vy = BASE_BALL_SPEED;
|
|
return;
|
|
}
|
|
|
|
game.ball_vx = game.ball_vx / len * BASE_BALL_SPEED;
|
|
game.ball_vy = game.ball_vy / len * BASE_BALL_SPEED;
|
|
}
|
|
|
|
fn build_snapshot(room: &Room) -> ServerMessage {
|
|
ServerMessage::Snapshot {
|
|
tick: room.game.tick,
|
|
server_time: now_ms(),
|
|
phase: match room.phase {
|
|
GamePhase::Lobby => "lobby",
|
|
GamePhase::Running => "running",
|
|
GamePhase::Paused => "paused",
|
|
GamePhase::Finished => "finished",
|
|
}
|
|
.to_string(),
|
|
players: room
|
|
.players
|
|
.values()
|
|
.map(|player| SnapshotPlayer {
|
|
id: player.id.clone(),
|
|
name: player.name.clone(),
|
|
role: player.role.clone(),
|
|
ready: player.ready,
|
|
paddle_x: player.paddle_x,
|
|
score: player.score,
|
|
lives: player.lives,
|
|
})
|
|
.collect(),
|
|
ball: BallState {
|
|
x: room.game.ball_x,
|
|
y: room.game.ball_y,
|
|
vx: room.game.ball_vx,
|
|
vy: room.game.ball_vy,
|
|
},
|
|
bricks: room
|
|
.game
|
|
.bricks
|
|
.iter()
|
|
.map(|alive| if *alive { '1' } else { '0' })
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
async fn discovery_broadcast(state: AppState) {
|
|
let socket = UdpSocket::bind("0.0.0.0:0")
|
|
.await
|
|
.expect("bind UDP sender");
|
|
socket.set_broadcast(true).expect("enable UDP broadcast");
|
|
|
|
loop {
|
|
let beacon = {
|
|
let inner = state.inner.read().await;
|
|
inner.room.as_ref().map(|room| Beacon {
|
|
magic: MAGIC.to_string(),
|
|
protocol_version: PROTOCOL_VERSION,
|
|
room_id: room.room_id.clone(),
|
|
pairing_code: room.pairing_code.clone(),
|
|
host_name: host_name(),
|
|
http_url: format!("http://{}:{HTTP_PORT}", state.lan_ip),
|
|
players: room.players.len(),
|
|
max_players: 2,
|
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
})
|
|
};
|
|
|
|
if let Some(beacon) = beacon {
|
|
if let Ok(bytes) = serde_json::to_vec(&beacon) {
|
|
let _ = socket
|
|
.send_to(
|
|
&bytes,
|
|
SocketAddr::from(([255, 255, 255, 255], DISCOVERY_PORT)),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
time::sleep(Duration::from_millis(800)).await;
|
|
}
|
|
}
|
|
|
|
async fn discovery_recv(state: AppState) {
|
|
let socket = UdpSocket::bind(("0.0.0.0", DISCOVERY_PORT))
|
|
.await
|
|
.expect("bind UDP receiver");
|
|
|
|
let mut buf = [0u8; 2048];
|
|
|
|
loop {
|
|
let Ok((len, addr)) = socket.recv_from(&mut buf).await else {
|
|
continue;
|
|
};
|
|
|
|
let Ok(beacon) = serde_json::from_slice::<Beacon>(&buf[..len]) else {
|
|
continue;
|
|
};
|
|
|
|
if beacon.magic != MAGIC || beacon.protocol_version != PROTOCOL_VERSION {
|
|
continue;
|
|
}
|
|
|
|
let own_room_id = {
|
|
let inner = state.inner.read().await;
|
|
inner.room.as_ref().map(|room| room.room_id.clone())
|
|
};
|
|
|
|
if own_room_id.as_deref() == Some(&beacon.room_id) {
|
|
continue;
|
|
}
|
|
|
|
let room = LanRoom {
|
|
room_id: beacon.room_id,
|
|
pairing_code: beacon.pairing_code.clone(),
|
|
host_name: beacon.host_name,
|
|
address: addr.ip().to_string(),
|
|
port: HTTP_PORT,
|
|
players: beacon.players,
|
|
max_players: beacon.max_players,
|
|
protocol_version: beacon.protocol_version,
|
|
app_version: beacon.app_version,
|
|
};
|
|
|
|
state.inner.write().await.discovered.insert(
|
|
beacon.pairing_code,
|
|
DiscoveredRoom {
|
|
room,
|
|
http_url: beacon.http_url,
|
|
seen_at: Instant::now(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
impl GameState {
|
|
fn new() -> Self {
|
|
let mut game = Self {
|
|
tick: 0,
|
|
ball_x: 0.0,
|
|
ball_y: 0.0,
|
|
ball_vx: 0.0,
|
|
ball_vy: 0.0,
|
|
ball_launched: false,
|
|
bricks: vec![true; BRICK_COLS * BRICK_ROWS],
|
|
last_pause_toggle: Instant::now() - Duration::from_secs(1),
|
|
};
|
|
reset_ball(&mut game);
|
|
game
|
|
}
|
|
}
|
|
|
|
fn ensure_protocol(version: u8) -> Result<(), ApiError> {
|
|
if version == PROTOCOL_VERSION {
|
|
Ok(())
|
|
} else {
|
|
Err(ApiError::bad_request("protocol version mismatch"))
|
|
}
|
|
}
|
|
|
|
fn sanitize_name(name: &str) -> String {
|
|
let name = name.trim();
|
|
if name.is_empty() {
|
|
"Player".to_string()
|
|
} else {
|
|
name.chars().take(18).collect()
|
|
}
|
|
}
|
|
|
|
fn now_ms() -> f64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as f64
|
|
}
|
|
|
|
fn guess_lan_ip() -> IpAddr {
|
|
std::net::UdpSocket::bind("0.0.0.0:0")
|
|
.and_then(|socket| {
|
|
socket.connect("8.8.8.8:80")?;
|
|
socket.local_addr()
|
|
})
|
|
.map(|addr| addr.ip())
|
|
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
|
|
}
|
|
|
|
fn host_name() -> String {
|
|
std::env::var("COMPUTERNAME")
|
|
.or_else(|_| std::env::var("HOSTNAME"))
|
|
.unwrap_or_else(|_| "JE-Skin Host".to_string())
|
|
} |