basic buzzer working with websocket comms

This commit is contained in:
Joey Hines 2025-05-11 16:16:43 -06:00
parent 0fd244f3d4
commit 3bb58b5719
Signed by: joeyahines
GPG Key ID: 38BA6F25C94C9382
17 changed files with 1137 additions and 83 deletions

View File

@ -23,6 +23,9 @@ mist = ">= 4.0.7 and < 5.0.0"
argv = ">= 1.0.2 and < 2.0.0" argv = ">= 1.0.2 and < 2.0.0"
gleam_erlang = ">= 0.34.0 and < 1.0.0" gleam_erlang = ">= 0.34.0 and < 1.0.0"
gleam_http = ">= 4.0.0 and < 5.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] [dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0" gleeunit = ">= 1.0.0 and < 2.0.0"

View File

@ -34,8 +34,11 @@ packages = [
[requirements] [requirements]
argv = { version = ">= 1.0.2 and < 2.0.0" } 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_erlang = { version = ">= 0.34.0 and < 1.0.0" }
gleam_http = { version = ">= 4.0.0 and < 5.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" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
glint = { version = ">= 1.2.1 and < 2.0.0" } glint = { version = ">= 1.2.1 and < 2.0.0" }

View File

@ -1,67 +1,47 @@
import app/web import app/web
import gleam/bit_array
import gleam/bytes_tree 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/request.{type Request}
import gleam/http/response.{type Response} 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/result
import gleam/string import gleam/string
import mist.{type Connection, type ResponseData} import mist.{type Connection, type ResponseData}
pub fn handle_request( pub fn handle_request(
request: Request(Connection), req: Request(Connection),
ctx: web.Context, ctx: web.Context,
) -> Response(ResponseData) { ) -> Response(ResponseData) {
case request.path_segments(request) { io.println("Got request: " <> req.path)
[] -> serve_frontend() case request.path_segments(req) {
["static", "frontend.wasm"] -> serve_wasm(ctx) [] -> 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())) _ -> response.set_body(response.new(404), mist.Bytes(bytes_tree.new()))
} }
} }
const game_body = " fn serve_wasm(
<html lang=\"en\"> ctx: web.Context,
file: String,
<head> mimetype: String,
<meta charset=\"utf-8\"> ) -> Response(ResponseData) {
<title>TITLE</title> let wasm_path = string.join([ctx.config.static_path, file], "/")
<style>
html,
body,
canvas {
margin: 0px;
padding: 0px;
width: 100%;
height: 100%;
overflow: hidden;
position: absolute;
background: black;
z-index: 0;
}
</style>
</head>
<body>
<canvas id=\"glcanvas\" tabindex='1'></canvas>
<!-- Minified and statically hosted version of https://github.com/not-fl3/macroquad/blob/master/js/mq_js_bundle.js -->
<script src=\"https://not-fl3.github.io/miniquad-samples/mq_js_bundle.js\"></script>
<script>load(\"static/frontend.wasm\");</script>
</body>
</html>
"
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"], "/")
mist.send_file(wasm_path, offset: 0, limit: None) mist.send_file(wasm_path, offset: 0, limit: None)
|> result.map(fn(file) { |> result.map(fn(file) {
response.new(200) response.new(200)
|> response.prepend_header("content-type", "application/wasm") |> response.prepend_header("content-type", mimetype)
|> response.set_body(file) |> response.set_body(file)
}) })
|> result.lazy_unwrap(fn() { |> result.lazy_unwrap(fn() {
@ -69,3 +49,107 @@ fn serve_wasm(ctx: web.Context) -> Response(ResponseData) {
|> response.set_body(mist.Bytes(bytes_tree.new())) |> 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)
}
}
}

View File

@ -1,7 +1,70 @@
import config import config
import gleam/dynamic/decode
import gleam/erlang/process
import gleam/json
import session import session
import storail import storail
pub type Context { pub type ClientMessage {
Context(config: config.Config, sessions: storail.Collection(session.Session)) 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),
)
} }

View File

@ -49,7 +49,11 @@ pub fn run_server() -> glint.Command(Result(Nil, Error)) {
let storail_cfg = storail.Config(cfg.storage_path) let storail_cfg = storail.Config(cfg.storage_path)
let ctx = 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) let handler = router.handle_request(_, ctx)

1
frontend/.gitignore vendored
View File

@ -1 +1,2 @@
/target /target
/dist

470
frontend/Cargo.lock generated
View File

@ -26,6 +26,21 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 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]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.23.0" version = "1.23.0"
@ -38,6 +53,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.0"
@ -50,6 +71,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.4.2" version = "1.4.2"
@ -59,12 +89,62 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "fdeflate" name = "fdeflate"
version = "0.3.7" version = "0.3.7"
@ -84,6 +164,12 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.1.5" version = "0.1.5"
@ -104,7 +190,32 @@ dependencies = [
name = "frontend" name = "frontend"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ewebsock",
"macroquad", "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]] [[package]]
@ -124,6 +235,23 @@ dependencies = [
"foldhash", "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]] [[package]]
name = "image" name = "image"
version = "0.24.9" version = "0.24.9"
@ -137,12 +265,40 @@ dependencies = [
"png", "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]] [[package]]
name = "libc" name = "libc"
version = "0.2.172" version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 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]] [[package]]
name = "macroquad" name = "macroquad"
version = "0.4.14" version = "0.4.14"
@ -172,6 +328,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]] [[package]]
name = "miniquad" name = "miniquad"
version = "0.4.8" version = "0.4.8"
@ -218,6 +380,12 @@ dependencies = [
"malloc_buf", "malloc_buf",
] ]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
@ -231,24 +399,306 @@ dependencies = [
"miniz_oxide", "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]] [[package]]
name = "quad-rand" name = "quad-rand"
version = "0.2.3" version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" 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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.7" version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 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]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.21.1" version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" 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]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@ -270,3 +720,23 @@ name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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",
]

View File

@ -4,4 +4,8 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
ewebsock = "0.8.0"
macroquad = "0.4.14" macroquad = "0.4.14"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
web-time = "1.1.0"

132
frontend/build_wasm.sh Executable file
View File

@ -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 <me@sbg.dev>
Edit: Nik codes <nik.code.things@gmail.com>
Edit: Nobbele <realnobbele@gmail.com>
Edit: profan <robinhubner@gmail.com>
Edit: Nik codes <nik.code.things@gmail.com>
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
<html lang="en">
<head>
<meta charset="utf-8">
<title>${PROJECT_NAME}</title>
<style>
html,
body,
canvas {
margin: 0px;
padding: 0px;
width: 100%;
height: 100%;
overflow: hidden;
position: absolute;
z-index: 0;
}
</style>
</head>
<body style="margin: 0; padding: 0; height: 100vh; width: 100vw;">
<canvas id="glcanvas" tabindex='1' hidden></canvas>
<script src="https://not-fl3.github.io/miniquad-samples/mq_js_bundle.js"></script>
<script type="module">
import init, { set_wasm } from "./static/${PROJECT_NAME}.js";
async function impl_run() {
let wbg = await init();
miniquad_add_plugin({
register_plugin: (a) => (a.wbg = wbg),
on_init: () => set_wasm(wasm_exports),
version: "0.0.1",
name: "wbg",
});
load("./static/${PROJECT_NAME}_bg.wasm");
}
window.run = function() {
document.getElementById("run-container").remove();
document.getElementById("glcanvas").removeAttribute("hidden");
document.getElementById("glcanvas").focus();
impl_run();
}
</script>
<div id="run-container" style="display: flex; justify-content: center; align-items: center; height: 100%; flex-direction: column;">
<button onclick="run()">Run Game</button>
</div>
</body>
</html>
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

66
frontend/src/context.rs Normal file
View File

@ -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<GameResponse> {
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
}
}
}

View File

@ -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<StateTransition> {
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
}
}

View File

@ -1,41 +1,59 @@
use macroquad::hash; use context::Context;
use macroquad::{prelude::*, ui::root_ui}; 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<StateTransition> {
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")] #[macroquad::main("Play of the Game")]
async fn main() { async fn main() {
let mut username = String::new(); let mut context = Context::new("ws://127.0.0.1:8080/ws");
let mut rooom_code = String::new();
let mut state = State::Init;
state.transition_state(StateTransition::Init);
loop { loop {
clear_background(WHITE); if let Some(transition) = state.handle_frame(&mut context) {
state = state.transition_state(transition);
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
);
}
});
},
);
next_frame().await next_frame().await
} }
} }

View File

@ -0,0 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BuzzIn {
pub time: f64,
}

28
frontend/src/model/mod.rs Normal file
View File

@ -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 },
}

View File

@ -0,0 +1,7 @@
use serde::Serialize;
#[derive(Debug, Serialize, Clone)]
pub struct Register {
pub game_code: String,
pub username: String,
}

View File

@ -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<StateTransition> {
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
}
}

6
frontend/src/screen.rs Normal file
View File

@ -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<StateTransition>;
}