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;
+}