From 3bb58b5719dc6ca60f43a4b38e6edede0e71072c Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Sun, 11 May 2025 16:16:43 -0600 Subject: [PATCH] basic buzzer working with websocket comms --- backend/gleam.toml | 3 + backend/manifest.toml | 3 + backend/src/app/router.gleam | 176 ++++++++---- backend/src/app/web.gleam | 67 ++++- backend/src/backend.gleam | 6 +- frontend/.gitignore | 1 + frontend/Cargo.lock | 470 +++++++++++++++++++++++++++++++++ frontend/Cargo.toml | 4 + frontend/build_wasm.sh | 132 +++++++++ frontend/src/context.rs | 66 +++++ frontend/src/login_screen.rs | 87 ++++++ frontend/src/main.rs | 86 +++--- frontend/src/model/buzz_in.rs | 6 + frontend/src/model/mod.rs | 28 ++ frontend/src/model/register.rs | 7 + frontend/src/player_screen.rs | 72 +++++ frontend/src/screen.rs | 6 + 17 files changed, 1137 insertions(+), 83 deletions(-) create mode 100755 frontend/build_wasm.sh create mode 100644 frontend/src/context.rs create mode 100644 frontend/src/login_screen.rs create mode 100644 frontend/src/model/buzz_in.rs create mode 100644 frontend/src/model/mod.rs create mode 100644 frontend/src/model/register.rs create mode 100644 frontend/src/player_screen.rs create mode 100644 frontend/src/screen.rs diff --git a/backend/gleam.toml b/backend/gleam.toml index 174f11d..0bf07ef 100644 --- a/backend/gleam.toml +++ b/backend/gleam.toml @@ -23,6 +23,9 @@ mist = ">= 4.0.7 and < 5.0.0" argv = ">= 1.0.2 and < 2.0.0" gleam_erlang = ">= 0.34.0 and < 1.0.0" gleam_http = ">= 4.0.0 and < 5.0.0" +gleam_otp = ">= 0.16.1 and < 1.0.0" +gleam_crypto = ">= 1.5.0 and < 2.0.0" +gleam_json = ">= 2.3.0 and < 3.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/backend/manifest.toml b/backend/manifest.toml index de758c1..fa20cb1 100644 --- a/backend/manifest.toml +++ b/backend/manifest.toml @@ -34,8 +34,11 @@ packages = [ [requirements] argv = { version = ">= 1.0.2 and < 2.0.0" } +gleam_crypto = { version = ">= 1.5.0 and < 2.0.0" } gleam_erlang = { version = ">= 0.34.0 and < 1.0.0" } gleam_http = { version = ">= 4.0.0 and < 5.0.0" } +gleam_json = { version = ">= 2.3.0 and < 3.0.0" } +gleam_otp = { version = ">= 0.16.1 and < 1.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } glint = { version = ">= 1.2.1 and < 2.0.0" } diff --git a/backend/src/app/router.gleam b/backend/src/app/router.gleam index 0c9b957..3e6440e 100644 --- a/backend/src/app/router.gleam +++ b/backend/src/app/router.gleam @@ -1,67 +1,47 @@ import app/web +import gleam/bit_array import gleam/bytes_tree +import gleam/crypto +import gleam/dynamic/decode +import gleam/erlang/process +import gleam/float import gleam/http/request.{type Request} import gleam/http/response.{type Response} -import gleam/option.{None} +import gleam/io +import gleam/json +import gleam/list +import gleam/option.{None, Some} +import gleam/otp/actor import gleam/result import gleam/string import mist.{type Connection, type ResponseData} pub fn handle_request( - request: Request(Connection), + req: Request(Connection), ctx: web.Context, ) -> Response(ResponseData) { - case request.path_segments(request) { - [] -> serve_frontend() - ["static", "frontend.wasm"] -> serve_wasm(ctx) + io.println("Got request: " <> req.path) + case request.path_segments(req) { + [] -> serve_wasm(ctx, "index.html", "text/html") + ["static", "frontend.js"] -> + serve_wasm(ctx, "frontend.js", "text/javascript") + ["static", "frontend_bg.wasm"] -> + serve_wasm(ctx, "frontend_bg.wasm", "application/wasm") + ["ws"] -> serve_websocket(ctx, req) _ -> response.set_body(response.new(404), mist.Bytes(bytes_tree.new())) } } -const game_body = " - - - - - TITLE - - - - - - - - - - - -" - -fn serve_frontend() -> Response(ResponseData) { - response.new(200) - |> response.set_body(mist.Bytes(bytes_tree.from_string(game_body))) - |> response.set_header("content-type", "text/html") -} - -fn serve_wasm(ctx: web.Context) -> Response(ResponseData) { - let wasm_path = string.join([ctx.config.static_path, "frontend.wasm"], "/") +fn serve_wasm( + ctx: web.Context, + file: String, + mimetype: String, +) -> Response(ResponseData) { + let wasm_path = string.join([ctx.config.static_path, file], "/") mist.send_file(wasm_path, offset: 0, limit: None) |> result.map(fn(file) { response.new(200) - |> response.prepend_header("content-type", "application/wasm") + |> response.prepend_header("content-type", mimetype) |> response.set_body(file) }) |> result.lazy_unwrap(fn() { @@ -69,3 +49,107 @@ fn serve_wasm(ctx: web.Context) -> Response(ResponseData) { |> response.set_body(mist.Bytes(bytes_tree.new())) }) } + +fn serve_websocket( + ctx: web.Context, + req: Request(Connection), +) -> Response(ResponseData) { + mist.websocket( + request: req, + on_init: fn(_conn) { #(ctx, Some(ctx.selector)) }, + on_close: fn(_state) { io.println("goodbye!") }, + handler: handle_ws_message, + ) +} + +fn decode_error_to_string(decode_err: decode.DecodeError) -> String { + "Path: " + <> string.join(decode_err.path, ".") + <> " Expected: " + <> decode_err.expected + <> " Got: " + <> decode_err.found +} + +fn decode_errors_to_string(decode_errs: List(decode.DecodeError)) -> String { + string.join(list.map(decode_errs, decode_error_to_string), "\n") +} + +fn handle_ws_message(state, conn, message) { + io.println("Got WS message") + case message { + mist.Text("ping") -> { + io.println("Got Ping") + let assert Ok(_) = mist.send_text_frame(conn, "pong") + actor.continue(state) + } + mist.Text(msg) -> { + echo msg + case json.parse(msg, web.client_request_decoder()) { + Error(err) -> { + let err_msg = case err { + json.UnableToDecode(decode_err) -> + "Decode Error: " <> decode_errors_to_string(decode_err) + json.UnexpectedByte(bad_byte) -> "Unexpeted byte: " <> bad_byte + json.UnexpectedEndOfInput -> "Reached EoF" + json.UnexpectedFormat(_) -> "Unexpected format" + json.UnexpectedSequence(seq_msg) -> + "Unexpected Sequence: " <> seq_msg + } + io.println("Could not parse client message: " <> err_msg) + } + Ok(req) -> { + let resp = handle_client_msg(state, req) + echo resp + let resp_str = + web.encode_game_response(resp) + |> json.to_string + + let assert Ok(_) = mist.send_text_frame(conn, resp_str) + Nil + } + } + + actor.continue(state) + } + mist.Binary(_) -> { + actor.continue(state) + } + mist.Custom(_) -> { + actor.continue(state) + } + mist.Closed | mist.Shutdown -> actor.Stop(process.Normal) + } +} + +fn handle_client_msg( + _ctx: web.Context, + req: web.ClientRequest, +) -> web.GameResponse { + case req.msg { + web.BuzzIn(time) -> { + io.println("Got buzz in @ " <> float.to_string(time)) + web.AckBuzzer + } + web.Register(username, gamecode) -> { + let secret = crypto.strong_random_bytes(16) + let token = + crypto.new_hasher(crypto.Sha512) + |> crypto.hash_chunk(secret) + |> crypto.hash_chunk(bit_array.from_string(username)) + |> crypto.hash_chunk(bit_array.from_string(gamecode)) + |> crypto.digest + + let token_hash = + crypto.new_hasher(crypto.Sha512) + |> crypto.hash_chunk(token) + |> crypto.digest + + io.println("New token: " <> bit_array.base64_encode(token_hash, True)) + + let token = bit_array.base64_encode(token, True) + + web.JoinResponse(token, username) + } + } +} diff --git a/backend/src/app/web.gleam b/backend/src/app/web.gleam index dcf2c3d..663d359 100644 --- a/backend/src/app/web.gleam +++ b/backend/src/app/web.gleam @@ -1,7 +1,70 @@ import config +import gleam/dynamic/decode +import gleam/erlang/process +import gleam/json import session import storail -pub type Context { - Context(config: config.Config, sessions: storail.Collection(session.Session)) +pub type ClientMessage { + BuzzIn(time: Float) + Register(game_code: String, username: String) +} + +fn client_message_decoder() -> decode.Decoder(ClientMessage) { + use variant <- decode.field("type", decode.string) + case variant { + "BuzzIn" -> { + use time <- decode.field("time", decode.float) + decode.success(BuzzIn(time:)) + } + "Register" -> { + use game_code <- decode.field("game_code", decode.string) + use username <- decode.field("username", decode.string) + decode.success(Register(game_code:, username:)) + } + _ -> decode.failure(BuzzIn(time: 0.0), "ClientMessage") + } +} + +pub type ClientRequest { + ClientRequest(token: String, msg: ClientMessage) +} + +pub fn client_request_decoder() -> decode.Decoder(ClientRequest) { + use token <- decode.field("token", decode.string) + use msg <- decode.field("msg", client_message_decoder()) + decode.success(ClientRequest(token:, msg:)) +} + +pub type GameResponse { + JoinResponse(username: String, token: String) + AckBuzzer + ResetBuzzer + UpdatePointTotal(score: Int) +} + +pub fn encode_game_response(game_response: GameResponse) -> json.Json { + case game_response { + JoinResponse(..) -> + json.object([ + #("type", json.string("JoinResponse")), + #("username", json.string(game_response.username)), + #("token", json.string(game_response.token)), + ]) + AckBuzzer -> json.object([#("type", json.string("AckBuzzer"))]) + ResetBuzzer -> json.object([#("type", json.string("ResetBuzzer"))]) + UpdatePointTotal(..) -> + json.object([ + #("type", json.string("UpdatePointTotal")), + #("score", json.int(game_response.score)), + ]) + } +} + +pub type Context { + Context( + config: config.Config, + sessions: storail.Collection(session.Session), + selector: process.Selector(ClientRequest), + ) } diff --git a/backend/src/backend.gleam b/backend/src/backend.gleam index 01ea2b2..c5e9048 100644 --- a/backend/src/backend.gleam +++ b/backend/src/backend.gleam @@ -49,7 +49,11 @@ pub fn run_server() -> glint.Command(Result(Nil, Error)) { let storail_cfg = storail.Config(cfg.storage_path) let ctx = - web.Context(config: cfg, sessions: setup_session_collection(storail_cfg)) + web.Context( + config: cfg, + sessions: setup_session_collection(storail_cfg), + selector: process.new_selector(), + ) let handler = router.handle_request(_, ctx) diff --git a/frontend/.gitignore b/frontend/.gitignore index ea8c4bf..4f96631 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1 +1,2 @@ /target +/dist diff --git a/frontend/Cargo.lock b/frontend/Cargo.lock index d6878bb..d7cff58 100644 --- a/frontend/Cargo.lock +++ b/frontend/Cargo.lock @@ -26,6 +26,21 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + [[package]] name = "bytemuck" version = "1.23.0" @@ -38,6 +53,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + [[package]] name = "cfg-if" version = "1.0.0" @@ -50,6 +71,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -59,12 +89,62 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "ewebsock" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "679247b4a005c82218a5f13b713239b0b6d484ec25347a719f5b7066152a748a" +dependencies = [ + "document-features", + "js-sys", + "log", + "tungstenite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -84,6 +164,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -104,7 +190,32 @@ dependencies = [ name = "frontend" version = "0.1.0" dependencies = [ + "ewebsock", "macroquad", + "serde", + "serde_json", + "web-time", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", ] [[package]] @@ -124,6 +235,23 @@ dependencies = [ "foldhash", ] +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "image" version = "0.24.9" @@ -137,12 +265,40 @@ dependencies = [ "png", ] +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + [[package]] name = "macroquad" version = "0.4.14" @@ -172,6 +328,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + [[package]] name = "miniquad" version = "0.4.8" @@ -218,6 +380,12 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "png" version = "0.17.16" @@ -231,24 +399,306 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + [[package]] name = "quad-rand" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "simd-adler32" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ttf-parser" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -270,3 +720,23 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "zerocopy" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 2d9836f..8aaebbe 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -4,4 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +ewebsock = "0.8.0" macroquad = "0.4.14" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" +web-time = "1.1.0" diff --git a/frontend/build_wasm.sh b/frontend/build_wasm.sh new file mode 100755 index 0000000..b88a540 --- /dev/null +++ b/frontend/build_wasm.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +set -e + +HELP_STRING=$( + cat <<-END + usage: build_wasm.sh PROJECT_NAME [--release] + Build script for combining a Macroquad project with wasm-bindgen, + allowing integration with the greater wasm-ecosystem. + example: ./build_wasm.sh flappy-bird + This'll go through the following steps: + 1. Build as target 'wasm32-unknown-unknown'. + 2. Create the directory 'dist' if it doesn't already exist. + 3. Run wasm-bindgen with output into the 'dist' directory. + - If the '--release' flag is provided, the build will be optimized for release. + 4. Apply patches to the output js file (detailed here: https://github.com/not-fl3/macroquad/issues/212#issuecomment-835276147). + 5. Generate coresponding 'index.html' file. + Author: Tom Solberg + Edit: Nik codes + Edit: Nobbele + Edit: profan + Edit: Nik codes + Version: 0.4 + END +) + +die() { + echo >&2 "$HELP_STRING" + echo >&2 + echo >&2 "Error: $*" + exit 1 +} + +# Parse primary commands +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + --release) + RELEASE=yes + shift + ;; + + -h | --help) + echo "$HELP_STRING" + exit 0 + ;; + + *) + POSITIONAL+=("$1") + shift + ;; + esac +done + +# Restore positionals +set -- "${POSITIONAL[@]}" +[ $# -ne 1 ] && die "too many arguments provided" + +PROJECT_NAME=$1 + +HTML=$( + cat <<-END + + + + ${PROJECT_NAME} + + + + + + +
+ +
+ + + END +) + +TARGET_DIR="target/wasm32-unknown-unknown" +# Build +if [ -n "$RELEASE" ]; then + cargo build --release --target wasm32-unknown-unknown + TARGET_DIR="$TARGET_DIR/release" +else + cargo build --target wasm32-unknown-unknown + TARGET_DIR="$TARGET_DIR/debug" +fi + +# Generate bindgen outputs +mkdir -p dist +wasm-bindgen $TARGET_DIR/"$PROJECT_NAME".wasm --out-dir dist --target web --no-typescript + +# Shim to tie the thing together +sed -i "s/import \* as __wbg_star0 from 'env';//" dist/"$PROJECT_NAME".js +sed -i "s/let wasm;/let wasm; export const set_wasm = (w) => wasm = w;/" dist/"$PROJECT_NAME".js +sed -i "s/imports\['env'\] = __wbg_star0;/return imports.wbg\;/" dist/"$PROJECT_NAME".js +sed -i "s/const imports = __wbg_get_imports();/return __wbg_get_imports();/" dist/"$PROJECT_NAME".js + +# Create index from the HTML variable +echo "$HTML" >dist/index.html diff --git a/frontend/src/context.rs b/frontend/src/context.rs new file mode 100644 index 0000000..721d7ff --- /dev/null +++ b/frontend/src/context.rs @@ -0,0 +1,66 @@ +use ewebsock::WsEvent; +use macroquad::prelude::{error, info}; + +use crate::model::{ClientMessage, ClientRequest, GameResponse}; + +pub struct Context { + pub sender: ewebsock::WsSender, + pub reciever: ewebsock::WsReceiver, + pub token: String, + pub username: String, +} + +impl Context { + pub fn new(url: &str) -> Context { + let (sender, reciever) = ewebsock::connect(url, ewebsock::Options::default()).unwrap(); + + Self { + sender, + reciever, + token: String::new(), + username: String::new(), + } + } + + pub fn send_msg(&mut self, msg: &ClientMessage) { + let request = ClientRequest { + token: self.token.clone(), + msg: msg.clone(), + }; + let msg = serde_json::to_string(&request).unwrap(); + self.sender.send(ewebsock::WsMessage::Text(msg)); + } + + pub fn recv_msg(&mut self) -> Option { + if let Some(msg) = self.reciever.try_recv() { + match msg { + WsEvent::Opened => { + info!("Web socket opened!"); + None + } + WsEvent::Message(ws_message) => match ws_message { + ewebsock::WsMessage::Text(msg) => { + let game_resp: GameResponse = serde_json::from_str(&msg).unwrap(); + + if let GameResponse::JoinResponse { username, token } = &game_resp { + self.username = username.clone(); + self.token = token.clone(); + } + + Some(game_resp) + } + _ => None, + }, + WsEvent::Error(err) => { + error!("Something hecky going on: {}", err); + None + } + WsEvent::Closed => { + panic!("Socket closed, something up..."); + } + } + } else { + None + } + } +} diff --git a/frontend/src/login_screen.rs b/frontend/src/login_screen.rs new file mode 100644 index 0000000..ee16e59 --- /dev/null +++ b/frontend/src/login_screen.rs @@ -0,0 +1,87 @@ +use crate::StateTransition; +use crate::context::Context; +use crate::model::register::Register; +use crate::model::{ClientMessage, GameResponse}; +use crate::screen::Screen; +use macroquad::hash; +use macroquad::{prelude::*, ui::root_ui}; + +#[derive(Default)] +pub struct LoginScreen { + new_username: String, + new_room_code: String, + sent_join: bool, + retry_count: usize, + error_msg: bool, +} + +impl Screen for LoginScreen { + fn handle_frame(&mut self, ctx: &mut Context) -> Option { + clear_background(WHITE); + + let group_size = Vec2::new(screen_width() * 0.25, screen_height() * 0.05); + let window_size = Vec2::new(screen_width() * 0.25, screen_height() * 0.25); + + let mut pressed_join = false; + root_ui().window( + hash!("Window"), + Vec2::new( + screen_width() * 0.5 - window_size.x * 0.5, + screen_height() * 0.5 - window_size.x * 0.5, + ), + Vec2::new(screen_width() * 0.25, screen_height() * 0.25), + |ui| { + ui.group(hash!("User Group"), group_size, |ui| { + ui.input_text(hash!("Username In"), "Username", &mut self.new_username); + }); + ui.group(hash!("Room Group"), group_size, |ui| { + ui.input_text(hash!("Room Code In"), "Room Code", &mut self.new_room_code); + }); + ui.group(hash!("Join Group"), group_size, |ui| { + if ui.button(Vec2::new(group_size.x * 0.45, 0.0), "Join") { + info!( + "User pressed joined with name={} room_code={}", + self.new_username, self.new_room_code + ); + + pressed_join = true; + } + }); + + if self.error_msg { + ui.group(hash!("Error Group"), group_size, |ui| { + ui.label(Vec2::new(0.0, 0.0), "Failed to join game, try again..."); + }); + } + }, + ); + + if pressed_join { + ctx.send_msg(&ClientMessage::Register(Register { + game_code: self.new_room_code.clone(), + username: self.new_username.clone(), + })); + + self.sent_join = true; + } + + if self.sent_join { + let msg = ctx.recv_msg(); + + if let Some(GameResponse::JoinResponse { username, token }) = msg { + return Some(StateTransition::JoinAsPlayer { username, token }); + } + + self.retry_count += 1; + + if self.retry_count > get_fps() as usize * 5 { + warn!("Join failed, try again..."); + self.retry_count = 0; + self.sent_join = false; + self.error_msg = true; + } + } + + None + } +} diff --git a/frontend/src/main.rs b/frontend/src/main.rs index ef8361c..c976b9c 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -1,41 +1,59 @@ -use macroquad::hash; -use macroquad::{prelude::*, ui::root_ui}; +use context::Context; +use login_screen::LoginScreen; +use macroquad::prelude::*; +use player_screen::PlayerScreen; +use screen::Screen; + +mod context; +mod login_screen; +mod model; +mod player_screen; +mod screen; + +pub enum State { + Init, + Login(LoginScreen), + PlayerScreen(PlayerScreen), +} + +impl State { + pub fn transition_state(&self, transition: StateTransition) -> Self { + match transition { + StateTransition::Init | StateTransition::LeaveGame => { + Self::Login(LoginScreen::default()) + } + StateTransition::JoinAsPlayer { username, token } => { + Self::PlayerScreen(PlayerScreen::new(&username, &token)) + } + } + } + + pub fn handle_frame(&mut self, ctx: &mut Context) -> Option { + match self { + Self::Init => Some(StateTransition::Init), + Self::Login(login_screen) => login_screen.handle_frame(ctx), + Self::PlayerScreen(player_screen) => player_screen.handle_frame(ctx), + } + } +} + +pub enum StateTransition { + Init, + JoinAsPlayer { username: String, token: String }, + LeaveGame, +} #[macroquad::main("Play of the Game")] async fn main() { - let mut username = String::new(); - let mut rooom_code = String::new(); + let mut context = Context::new("ws://127.0.0.1:8080/ws"); + + let mut state = State::Init; + state.transition_state(StateTransition::Init); + loop { - clear_background(WHITE); - - let group_size = Vec2::new(screen_width() * 0.25, screen_height() * 0.05); - let window_size = Vec2::new(screen_width() * 0.25, screen_height() * 0.25); - - root_ui().window( - hash!("Window"), - Vec2::new( - screen_width() * 0.5 - window_size.x * 0.5, - screen_height() * 0.5 - window_size.x * 0.5, - ), - Vec2::new(screen_width() * 0.25, screen_height() * 0.25), - |ui| { - ui.group(hash!("User Group"), group_size, |ui| { - ui.input_text(hash!("Username In"), "Username", &mut username); - }); - ui.group(hash!("Room Group"), group_size, |ui| { - ui.input_text(hash!("Room Code In"), "Room Code", &mut rooom_code); - }); - ui.group(hash!("Join Group"), group_size, |ui| { - if ui.button(Vec2::new(group_size.x * 0.45, 0.0), "Join") { - info!( - "User pressed joined with name={} room_code={}", - username, rooom_code - ); - } - }); - }, - ); - + if let Some(transition) = state.handle_frame(&mut context) { + state = state.transition_state(transition); + } next_frame().await } } diff --git a/frontend/src/model/buzz_in.rs b/frontend/src/model/buzz_in.rs new file mode 100644 index 0000000..073ae18 --- /dev/null +++ b/frontend/src/model/buzz_in.rs @@ -0,0 +1,6 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BuzzIn { + pub time: f64, +} diff --git a/frontend/src/model/mod.rs b/frontend/src/model/mod.rs new file mode 100644 index 0000000..c417ce4 --- /dev/null +++ b/frontend/src/model/mod.rs @@ -0,0 +1,28 @@ +use buzz_in::BuzzIn; +use register::Register; +use serde::{Deserialize, Serialize}; + +pub mod buzz_in; +pub mod register; + +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "type")] +pub enum ClientMessage { + BuzzIn(BuzzIn), + Register(Register), +} + +#[derive(Debug, Serialize)] +pub struct ClientRequest { + pub token: String, + pub msg: ClientMessage, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum GameResponse { + JoinResponse { username: String, token: String }, + AckBuzzer, + ResetBuzzer, + UpdatePountTotal { score: i32 }, +} diff --git a/frontend/src/model/register.rs b/frontend/src/model/register.rs new file mode 100644 index 0000000..572ab01 --- /dev/null +++ b/frontend/src/model/register.rs @@ -0,0 +1,7 @@ +use serde::Serialize; + +#[derive(Debug, Serialize, Clone)] +pub struct Register { + pub game_code: String, + pub username: String, +} diff --git a/frontend/src/player_screen.rs b/frontend/src/player_screen.rs new file mode 100644 index 0000000..ffdadf2 --- /dev/null +++ b/frontend/src/player_screen.rs @@ -0,0 +1,72 @@ +use web_time::{SystemTime, UNIX_EPOCH}; + +use crate::StateTransition; +use crate::context::Context; +use crate::model::ClientMessage; +use crate::model::buzz_in::BuzzIn; +use crate::screen::Screen; +use macroquad::prelude::*; + +pub struct PlayerScreen { + token: String, + username: String, + score: i32, + button_pressed: bool, +} + +impl PlayerScreen { + pub fn new(token: &str, username: &str) -> Self { + Self { + token: token.to_string(), + username: username.to_string(), + score: 0, + button_pressed: false, + } + } +} + +impl Screen for PlayerScreen { + fn handle_frame(&mut self, ctx: &mut Context) -> Option { + clear_background(BEIGE); + + let button_color = if self.button_pressed { + Color::from_hex(0xaa0000) + } else { + RED + }; + + let button_size = (screen_width() * 0.33).min(screen_height() * 0.20); + draw_circle( + screen_width() * 0.5, + screen_height() * 0.5, + button_size * 1.1, + DARKBROWN, + ); + draw_circle( + screen_width() * 0.5, + screen_height() * 0.5, + button_size, + button_color, + ); + + if is_mouse_button_pressed(MouseButton::Left) { + let loc = Vec2::from(mouse_position()); + let normalize = loc - Vec2::new(screen_width() * 0.5, screen_height() * 0.5); + + if normalize.length() < button_size { + let timestamp = SystemTime::now(); + let unix_timestamp = timestamp.duration_since(UNIX_EPOCH).expect("Time invalid"); + + let time = unix_timestamp.as_secs_f64(); + + info!("Button pressed @ {}", time); + self.button_pressed = true; + let buzz_in = BuzzIn { time }; + + ctx.send_msg(&ClientMessage::BuzzIn(buzz_in)); + } + } + + None + } +} diff --git a/frontend/src/screen.rs b/frontend/src/screen.rs new file mode 100644 index 0000000..e596921 --- /dev/null +++ b/frontend/src/screen.rs @@ -0,0 +1,6 @@ +use crate::{StateTransition, context::Context}; + +pub trait Screen { + fn init_frame(&mut self) {} + fn handle_frame(&mut self, ctx: &mut Context) -> Option; +}