From e6da55daa54cdf528af1f3e7617ca124ffee3159 Mon Sep 17 00:00:00 2001 From: Martin Hafskjold Thoresen Date: Sat, 4 Jan 2025 14:55:12 +0100 Subject: Skeleton of gameplay loop in place --- src/main.rs | 249 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 240 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/main.rs b/src/main.rs index e078a32..0cc864f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,32 @@ -use std::net::SocketAddr; +use std::{collections::HashMap, net::SocketAddr, sync::Arc}; use anyhow::{Context, Result}; use axum::{ + body::Body, + extract::{ + ws::{Message, WebSocket}, + Path, State, WebSocketUpgrade, + }, http::StatusCode, response::{IntoResponse, Response}, - routing::get, + routing::{get, post}, Router, }; +use maud::{html, Markup, PreEscaped, DOCTYPE}; +use rand::random; +use tokio::sync::{ + broadcast::{self, Receiver, Sender}, + Mutex, +}; use tower_http::{ catch_panic::CatchPanicLayer, - trace::{self, TraceLayer}, + services::ServeDir, + trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}, }; -use tracing::{error, info, Level}; +use tracing::{error, info, trace, Level}; use tracing_subscriber::FmtSubscriber; +/// If a handler panics we run this function and return the [Response]. fn handle_panic(err: Box) -> Response { let details = if let Some(s) = err.downcast_ref::() { s.clone() @@ -27,6 +40,213 @@ fn handle_panic(err: Box) -> Response { (StatusCode::INTERNAL_SERVER_ERROR).into_response() } +struct Game { + id: u64, + + is_started: bool, + + /// Sender to broadcast things + broadcast_tx: Sender, + /// Broadcast receiver to send to WebSocket + broadcast_rx: Receiver, +} + +impl Game { + fn get_html(&self) -> Markup { + let gid = self.id; + if !self.is_started { + return html! { + h1 { "Game" } + p { "Current game id is " (gid)} + div #messages {}; + button hx-post=(format!("/game/{gid}/start")) { "Start" } + }; + } else { + return html! { + h1 { "Playing Game" } + p { "Were playing here" } + }; + } + } +} + +struct ServerState { + games: HashMap>>, +} + +impl ServerState { + fn new_game(&mut self, gid: u64) { + let (tx, rx) = broadcast::channel(16); + let g = Game { + id: gid, + is_started: false, + broadcast_rx: rx, + broadcast_tx: tx, + }; + self.games.insert(gid, Arc::new(Mutex::new(g))); + } +} + +type Server = Arc>; + +async fn route_index(State(st): State) -> Response { + let game_ids = st.lock().await.games.keys().cloned().collect::>(); + + let game_list = if game_ids.is_empty() { + html! { "No current games"} + } else { + html! { + h3 { "Games" } + ul { + @for id in &game_ids { + li { a href=(format!("/game/{id}")) { (id) } } + } + } + } + }; + + let html = html! { + h1 { "Good game "} + (game_list) + + button hx-post="/game" { "Create game" } + }; + + html_response(html! { + (DOCTYPE) + head { + meta charset="utf-8"; + title { "Good Game" }; + meta name="viewport" content="width=device-width, initial-scale=1" {}; + script src="https://unpkg.com/htmx.org@2.0.4" {}; + link rel="stylesheet" type="text/css" href="static/style.css"; + } + body { + (html) + } + }) +} + +fn html_response(html: PreEscaped) -> Response { + Response::builder() + .status(StatusCode::OK) + .body(Body::from(html.into_string())) + .expect("Failed to set body") +} + +async fn post_game(State(st): State) -> Response { + let gid = random::(); + + let _ = { + let mut s = st.lock().await; + s.new_game(gid); + }; + + Response::builder() + .header("hx-redirect", format!("/game/{gid}")) + .body(Body::empty()) + .expect("failed to build response") +} + +async fn start_game(Path(gid): Path, State(st): State) -> Response { + let lock = { + if let Some(g) = st.lock().await.games.get(&gid) { + g.clone() + } else { + return StatusCode::NOT_FOUND.into_response(); + } + }; + + let mut g = lock.lock().await; + + g.is_started = true; + let html = g.get_html(); + + g.broadcast_tx + .send(html! { section #content { (html) } }.into_string()) + .expect("failed to send"); + + StatusCode::OK.into_response() +} + +async fn get_game(Path(gid): Path, State(st): State) -> Response { + let game = { + if let Some(g) = st.lock().await.games.get(&gid) { + g.clone() + } else { + return (StatusCode::NOT_FOUND).into_response(); + } + }; + + let html = game.lock().await.get_html(); + + html_response(html! { + (DOCTYPE) + head { + meta charset="utf-8"; + title { "Good Game" }; + meta name="viewport" content="width=device-width, initial-scale=1" {}; + script src="https://unpkg.com/htmx.org@2.0.4" {}; + script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js" {}; + + link rel="stylesheet" type="text/css" href="/static/style.css"; + } + body hx-ext="ws" ws-connect=(format!("/game/{gid}/ws")) { + footer { } + section #content { + (html) + } + footer { span { "Good Game" } } + } + }) +} + +async fn game_ws(ws: WebSocketUpgrade, Path(gid): Path, State(st): State) -> Response { + let game = { + let s = st.lock().await; + if let Some(g) = s.games.get(&gid) { + g.clone() + } else { + return StatusCode::NOT_FOUND.into_response(); + } + }; + + ws.on_upgrade(move |socket| handle_socket(socket, game)) + .into_response() +} + +async fn handle_socket(mut socket: WebSocket, game: Arc>) { + if socket + .send(Message::Ping(bytes::Bytes::from("wat"))) + .await + .is_ok() + { + trace!("Pinged ws"); + } else { + println!("Could not send ping!"); + return; + } + { + game.lock() + .await + .broadcast_tx + .send( + html! { + div #messages hx-swap-oob="beforeend" { p { "Someone joined" } } + } + .into_string(), + ) + .unwrap(); + } + + let mut rx = { game.lock().await.broadcast_rx.resubscribe() }; + while let Ok(a) = rx.recv().await { + trace!(a, "send "); + let res = socket.send(a.into()).await; + trace!(?res); + } +} + #[tokio::main] async fn main() -> Result<()> { let subscriber = FmtSubscriber::builder() @@ -35,16 +255,27 @@ async fn main() -> Result<()> { tracing::subscriber::set_global_default(subscriber) .expect("set global default subscriber failed"); + let mut server_state = ServerState { + games: HashMap::new(), + }; + server_state.new_game(123); + let app = Router::new() - .route("/", get(|| async { "wat" })) + .route("/", get(route_index)) + .route("/game", post(post_game)) + .route("/game/{gid}", get(get_game)) + .route("/game/{gid}/start", post(start_game)) + .route("/game/{gid}/ws", get(game_ws)) + .nest_service("/static", ServeDir::new("static")) .layer(CatchPanicLayer::custom(handle_panic)) .layer( TraceLayer::new_for_http() - .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) - .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), - ); + .make_span_with(DefaultMakeSpan::new().level(Level::INFO)) + .on_response(DefaultOnResponse::new().level(Level::INFO)), + ) + .with_state(Arc::new(Mutex::new(server_state))); - let addr = "0.0.0.0:4800"; + let addr = "192.168.0.106:4800"; info!("Listening on {addr}"); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve( -- cgit v1.2.3