Files
JE-Skin/src-tauri/src/lan_game.rs

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())
}