From 44334fc3852eb832280a335f72e6416c93a9f19f Mon Sep 17 00:00:00 2001 From: Tyler Murphy Date: Fri, 16 Jun 2023 20:38:55 -0400 Subject: ts --- .gitignore | 3 +- Cargo.lock | 957 ------------------------------------------ Cargo.toml | 11 - client/css/main.css | 21 - client/img/atlas.png | Bin 0 -> 6304 bytes client/img/atlas.png.bak | Bin 0 -> 5670 bytes client/img/dot.png | Bin 4363 -> 0 bytes client/img/pac.gif | Bin 1004 -> 0 bytes client/img/pac.png | Bin 4583 -> 0 bytes client/img/wall_atlas.png | Bin 1261 -> 0 bytes client/index.html | 8 +- client/js/game.js | 111 ----- client/js/gfx/graphics.js | 128 ------ client/js/gfx/map.js | 293 ------------- client/js/gfx/sprite.js | 71 ---- client/js/input.js | 84 ---- client/js/logic.js | 335 --------------- client/js/main.js | 168 +++----- client/js/multiplayer.js | 318 -------------- client/src/logic/items.ts | 41 ++ client/src/logic/logic.ts | 80 ++++ client/src/logic/movement.ts | 142 +++++++ client/src/logic/players.ts | 79 ++++ client/src/logic/ui.ts | 32 ++ client/src/main.ts | 75 ++++ client/src/map.ts | 151 +++++++ client/src/net/game.ts | 186 ++++++++ client/src/net/input.ts | 70 +++ client/src/net/multiplayer.ts | 303 +++++++++++++ client/src/renderer.ts | 320 ++++++++++++++ client/src/types.ts | 131 ++++++ client/tsconfig.json | 11 + server/Cargo.lock | 957 ++++++++++++++++++++++++++++++++++++++++++ server/Cargo.toml | 11 + server/src/main.rs | 22 + server/src/room/handle.rs | 106 +++++ server/src/room/messages.rs | 69 +++ server/src/room/mod.rs | 128 ++++++ server/src/room/websocket.rs | 66 +++ server/src/rooms.rs | 59 +++ server/src/routes.rs | 51 +++ src/main.rs | 22 - src/room/handle.rs | 106 ----- src/room/messages.rs | 69 --- src/room/mod.rs | 128 ------ src/room/websocket.rs | 66 --- src/rooms.rs | 59 --- src/routes.rs | 51 --- 48 files changed, 3152 insertions(+), 2947 deletions(-) delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml create mode 100644 client/img/atlas.png create mode 100644 client/img/atlas.png.bak delete mode 100644 client/img/dot.png delete mode 100644 client/img/pac.gif delete mode 100644 client/img/pac.png delete mode 100644 client/img/wall_atlas.png delete mode 100644 client/js/game.js delete mode 100644 client/js/gfx/graphics.js delete mode 100644 client/js/gfx/map.js delete mode 100644 client/js/gfx/sprite.js delete mode 100644 client/js/input.js delete mode 100644 client/js/logic.js delete mode 100644 client/js/multiplayer.js create mode 100644 client/src/logic/items.ts create mode 100644 client/src/logic/logic.ts create mode 100644 client/src/logic/movement.ts create mode 100644 client/src/logic/players.ts create mode 100644 client/src/logic/ui.ts create mode 100644 client/src/main.ts create mode 100644 client/src/map.ts create mode 100644 client/src/net/game.ts create mode 100644 client/src/net/input.ts create mode 100644 client/src/net/multiplayer.ts create mode 100644 client/src/renderer.ts create mode 100644 client/src/types.ts create mode 100644 client/tsconfig.json create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/src/main.rs create mode 100644 server/src/room/handle.rs create mode 100644 server/src/room/messages.rs create mode 100644 server/src/room/mod.rs create mode 100644 server/src/room/websocket.rs create mode 100644 server/src/rooms.rs create mode 100644 server/src/routes.rs delete mode 100644 src/main.rs delete mode 100644 src/room/handle.rs delete mode 100644 src/room/messages.rs delete mode 100644 src/room/mod.rs delete mode 100644 src/room/websocket.rs delete mode 100644 src/rooms.rs delete mode 100644 src/routes.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..f4c9b61 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/target +server/target +client/js diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 6b0a690..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,957 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "async-trait" -version = "0.1.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.13", -] - -[[package]] -name = "autocfg" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "axum" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f8ccfd9221ee7d1f3d4b33e1f8319b3a81ed8f61f2ea40b37b859794b4491" -dependencies = [ - "async-trait", - "axum-core", - "base64 0.21.0", - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "hyper", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2f958c80c248b34b9a877a643811be8dbca03ca5ba827f2b63baf3a81e5fc4e" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "mime", - "rustversion", - "tower-layer", - "tower-service", -] - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" - -[[package]] -name = "bitflags" -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 = "byteorder" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" - -[[package]] -name = "bytes" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "cpufeatures" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280a9f2d8b3a38871a3c8a46fb80db65e5e5ed97da80c4d08bf27fb63e35e181" -dependencies = [ - "libc", -] - -[[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 = "digest" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" - -[[package]] -name = "futures-sink" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" - -[[package]] -name = "futures-task" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" - -[[package]] -name = "futures-util" -version = "0.3.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" -dependencies = [ - "futures-core", - "futures-sink", - "futures-task", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[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.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "hermit-abi" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" -dependencies = [ - "libc", -] - -[[package]] -name = "http" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" - -[[package]] -name = "httparse" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" - -[[package]] -name = "httpdate" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" - -[[package]] -name = "hyper" -version = "0.14.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc5e554ff619822309ffd57d8734d77cd5ce6238bc956f037ea06c58238c9899" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "idna" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "itoa" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" - -[[package]] -name = "libc" -version = "0.2.140" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99227334921fae1a979cf0bfdfcc6b3e5ce376ef57e16fb6fb3ea2ed6095f80c" - -[[package]] -name = "log" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "matchit" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" - -[[package]] -name = "memchr" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "mio" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys", -] - -[[package]] -name = "num_cpus" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "once_cell" -version = "1.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" - -[[package]] -name = "percent-encoding" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" - -[[package]] -name = "pin-project" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "ppv-lite86" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" - -[[package]] -name = "proc-macro2" -version = "1.0.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d0dd4be24fcdcfeaa12a432d588dc59bbad6cad3510c67e74a2b6b2fc950564" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" -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 = "rollback" -version = "0.1.0" -dependencies = [ - "axum", - "serde", - "serde_json", - "tokio", - "tower-http", -] - -[[package]] -name = "rustversion" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" - -[[package]] -name = "ryu" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" - -[[package]] -name = "serde" -version = "1.0.159" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c04e8343c3daeec41f58990b9d77068df31209f2af111e059e9fe9646693065" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.159" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c614d17805b093df4b147b51339e7e44bf05ef59fba1e45d83500bcfb4d8585" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.13", -] - -[[package]] -name = "serde_json" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d721eca97ac802aa7777b701877c8004d950fc142651367300d21c1cc0194744" -dependencies = [ - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7f05c1d5476066defcdfacce1f52fc3cae3af1d3089727100c02ae92e5abbe0" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "slab" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" -dependencies = [ - "autocfg", -] - -[[package]] -name = "socket2" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c9da457c5285ac1f936ebd076af6dac17a61cfe7826f2076b4d015cf47bc8ec" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "thiserror" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.13", -] - -[[package]] -name = "tinyvec" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de47a4eecbe11f498978a9b29d792f0d2692d1dd003650c24c76510e3bc001" -dependencies = [ - "autocfg", - "bytes", - "libc", - "mio", - "num_cpus", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a573bdc87985e9d6ddeed1b3d864e8a302c847e40d647746df2f1de209d1ce" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.13", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54319c93411147bced34cb5609a80e0a8e44c5999c93903a81cd866630ec0bfd" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - -[[package]] -name = "tokio-util" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" -dependencies = [ - "bitflags", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-range-header", - "httpdate", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" - -[[package]] -name = "tower-service" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" - -[[package]] -name = "tracing" -version = "0.1.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" -dependencies = [ - "cfg-if", - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" - -[[package]] -name = "tungstenite" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ee6ab729cd4cf0fd55218530c4522ed30b7b6081752839b68fcec8d0960788" -dependencies = [ - "base64 0.13.1", - "byteorder", - "bytes", - "http", - "httparse", - "log", - "rand", - "sha1", - "thiserror", - "url", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" - -[[package]] -name = "unicase" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" -dependencies = [ - "version_check", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" - -[[package]] -name = "unicode-ident" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" - -[[package]] -name = "unicode-normalization" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "url" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "want" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" -dependencies = [ - "log", - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -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 = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 98ffd29..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "rollback" -version = "0.1.0" -edition = "2021" - -[dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -axum = { version = "0.6.12", features = ["ws"] } -tower-http = { version = "0.4.0", features = ["fs"] } diff --git a/client/css/main.css b/client/css/main.css index 86b8fd8..87c145c 100644 --- a/client/css/main.css +++ b/client/css/main.css @@ -71,24 +71,3 @@ input { padding: .25rem; margin-bottom: .215rem; } - -#container img { - image-rendering: pixelated; -} - -#canvas { - position: absolute; - width: 100%; - height: 100%; -} - -#fps { - position: absolute; - left: 0; - top: 0; - z-index: 99; - font-size: 20; - font-style: monospace; - background-color: black; - color: white; -} diff --git a/client/img/atlas.png b/client/img/atlas.png new file mode 100644 index 0000000..240705b Binary files /dev/null and b/client/img/atlas.png differ diff --git a/client/img/atlas.png.bak b/client/img/atlas.png.bak new file mode 100644 index 0000000..3cb5dfc Binary files /dev/null and b/client/img/atlas.png.bak differ diff --git a/client/img/dot.png b/client/img/dot.png deleted file mode 100644 index b911cea..0000000 Binary files a/client/img/dot.png and /dev/null differ diff --git a/client/img/pac.gif b/client/img/pac.gif deleted file mode 100644 index 4b0608b..0000000 Binary files a/client/img/pac.gif and /dev/null differ diff --git a/client/img/pac.png b/client/img/pac.png deleted file mode 100644 index 7dc0989..0000000 Binary files a/client/img/pac.png and /dev/null differ diff --git a/client/img/wall_atlas.png b/client/img/wall_atlas.png deleted file mode 100644 index bd3a5df..0000000 Binary files a/client/img/wall_atlas.png and /dev/null differ diff --git a/client/index.html b/client/index.html index fb1e66e..af15bbd 100644 --- a/client/index.html +++ b/client/index.html @@ -2,15 +2,13 @@ + - - + +
-
diff --git a/client/js/game.js b/client/js/game.js deleted file mode 100644 index 3d8f6a7..0000000 --- a/client/js/game.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @author tint - * @template Data, Input - */ -export class Game { - /** - * @param {number} history How many frames of history to keep in memory - * @param {(data: Data, input: Input, frame: number) => Data} advance The function to apply game logic. For rollback to work properly, this must be a pure function, and can't mutate inputs. - */ - constructor(history, advance) { - this.historysize = history; - this.advance = advance; - - /** @type {{data: Data, input: Input}[]} */ - this.history = []; - this.historyStart = 0; - - // the game may have inputs from the "future" - // (local input delay to make sure inputs play at the same time on all machines) - // so the "present" isn't always the latest frame - // the game loop should set this every frame - this.currentFrame = 0; - } - - startHistory(frame, data) { - this.historyStart = frame; - this.history = [{ data }]; - this.currentFrame = frame; - } - - getHistory(frame) { - return this.history[frame - this.historyStart]; - } - - getFrame() { - return this.historyStart + this.history.length - 1; - } - - getCurrentData() { - const entry = this.history[this.history.length - 1]; - return entry && entry.data; - } - - /** - * Sets the input at a specific frame. If that frame is in history, - * the game will be rewound, the input applied, and then fast-forwarded to the current head. - * If the frame is ahead of the current latest frame, the game will be run until that frame. - * @param {number} frame The time to apply the input at - * @param {Input} input The input - */ - setInput(frame, input) { - this.editFrame(frame, index => { - let past = this.history[index - 1]; - if(index === 0) { - past = { data: undefined }; - } - this.history[index] = { - input, - data: this.advance(past ? past.data : undefined, input, frame), - }; - }); - } - - setData(frame, data) { - this.editFrame(frame, index => { - this.history[index] = { - data, - input: this.history[index] && this.history[index].input, - } - }); - } - - /** - * @param {number} frame - * @param {(index: number) => void} edit - */ - editFrame(frame, edit) { - const head = this.historyStart + this.history.length; - if(frame < head) { - if(frame < this.historyStart) { - throw new Error("Tried to edit a past frame not in history:", frame); - } - - edit(frame - this.historyStart); - // fast forward back to the present with the new data - for(let i = frame + 1; i < head; i++) { - const past = this.history[i - this.historyStart - 1]; - this.history[i - this.historyStart].data = this.advance( - past ? past.data : undefined, - this.history[i - this.historyStart].input, - i - ); - } - } else { - // fast forward the inbetween frames with no input - for(let i = head; i < frame; i++) { - const entry = this.history[i - this.historyStart - 1]; - this.history[i - this.historyStart] = { - input: undefined, - data: this.advance(entry ? entry.data : undefined, undefined, i), - }; - } - edit(frame - this.historyStart); - } - - while(this.history.length > this.historysize) { - this.history.shift(); - this.historyStart++; - } - } -} diff --git a/client/js/gfx/graphics.js b/client/js/gfx/graphics.js deleted file mode 100644 index 8ee4a6f..0000000 --- a/client/js/gfx/graphics.js +++ /dev/null @@ -1,128 +0,0 @@ -import { Sprite } from './sprite.js' -import { ItemType, Rotation } from '../logic.js' - - -const draw_players = (data, players, sprites) => { - for (let id of players) { - let pos = data.players[id].pos - sprites[id].move(pos[0], pos[1]) - switch (data.players[id].move_rot) { - case Rotation.NORTH: - sprites[id].rotate(270) - break - case Rotation.EAST: - sprites[id].rotate(0) - break - case Rotation.SOUTH: - sprites[id].rotate(90) - break - case Rotation.WEST: - sprites[id].rotate(180) - break - } - - if (data.players[id].moving) { - sprites[id].set_img("img/pac.gif") - } else { - sprites[id].set_img("img/pac.png") - } - } -} - -const update_player_sprites = (data, players, sprites) => { - for (const sprite of sprites) { - if (sprite !== undefined) { - sprite.destroy() - } - } - - let new_sprites = Array(players) - new_sprites.fill(undefined) - - for (let id of players) { - let sprite = new Sprite("img/pac.png", data.map) - sprite.layer(3) - sprite.resize(1,1) - sprite.show() - new_sprites[id] = sprite - } - - return new_sprites -} - -const create_map_dot = (data, x, y) => { - let dot = new Sprite("img/dot.png", data.map) - dot.move(x, y) - dot.resize(.2,.2) - dot.show() - dot.type = ItemType.DOT - return dot -} - -const draw_sprites = (data, item_sprites) => { - let items = data.map.items - - let to_remove = [] - // remove rendered but non existing items - for (const item_key in item_sprites) { - - let sprite = item_sprites[item_key] - if (!items[item_key]) { - sprite.destroy() - to_remove.push(item_key) - } - - } - - for (const id of to_remove) { - delete item_sprites[id] - } - - // add not rendered sprites - for (const item_key in items) { - - /** @type {import('../logic.js').Item} */ - let item = items[item_key] - let sprite = item_sprites[item_key] - - if (sprite) { - if (item.type === sprite.type) - continue - sprite.destroy() - } - - switch (item.type) { - case ItemType.DOT: - sprite = create_map_dot(data, ...item.pos) - break; - } - - item_sprites[item_key] = sprite - } - -} - -export const startGraphicsUpdater = () => { - - let player_sprites = [] - let item_sprites = {} - - /** - * @type {(data: import("../logic.js").GameState) => void} - */ - return (data) => { - - if (!data.map || !data.map.visible) return - - let players = Object.keys(data.players).filter(k => data.players[k] !== undefined) - - if (player_sprites.length !== players.length) { - player_sprites = update_player_sprites(data, players, player_sprites) - console.log("updating player sprites") - } - - draw_sprites(data, item_sprites) - draw_players(data, players, player_sprites) - - } -} diff --git a/client/js/gfx/map.js b/client/js/gfx/map.js deleted file mode 100644 index a9ef0ad..0000000 --- a/client/js/gfx/map.js +++ /dev/null @@ -1,293 +0,0 @@ -import { ItemType, get_item_key } from "../logic.js"; - -const update_style = (map, style) => { - const css = ` - * { - --scale: 100; - --aspect: ${map.width/map.height}; - --scaleX: calc(var(--scale) * 1vw); - --scaleY: calc(var(--scale) * 1vh); - } - - #container { - width: calc(var(--scaleY) * var(--aspect)); - height: var(--scaleY); - margin-top: calc((100vh - var(--scaleY))/2); - margin-left: calc(50vw - var(--scaleY)*var(--aspect)/2); - position: relative; - vertical-align: top; - line-height: 0; - } - - @media (max-aspect-ratio: ${map.width}/${map.height}) { - #container { - width: var(--scaleX); - height: calc(var(--scaleX) / var(--aspect)); - margin-left: calc((100vw - var(--scaleX))/2); - margin-top: calc(50vh - var(--scaleX)/var(--aspect)/2); - } - }`; - - style.innerHTML = css -} - -const Direction = { - EMPTY: 0, - WALL_HZ: 1, - WALL_VT: 2, - TURN_Q1: 3, - TURN_Q2: 4, - TURN_Q3: 5, - TURN_Q4: 6, - TEE_NORTH: 7, - TEE_EAST: 8, - TEE_SOUTH: 9, - TEE_WEST: 10, - CROSS: 11, - DOT: 12, - WALL_END_NORTH: 13, - WALL_END_SOUTH: 14, - WALL_END_EAST: 15, - WALL_END_WEST: 16 -} - -/** - * @param {CanvasRenderingContext2D} context - */ -const draw_tile = (context, x, y, w, type) => { - - let atlas_index, rotation; - switch(type) { - case Direction.EMPTY: - return - case Direction.WALL_HZ: - atlas_index = [1, 1] - rotation = 0 - break - case Direction.WALL_VT: - atlas_index = [1, 1] - rotation = 90 - break - case Direction.TURN_Q1: - atlas_index = [2, 0] - rotation = 0 - break - case Direction.TURN_Q2: - atlas_index = [2, 0] - rotation = 270 - break - case Direction.TURN_Q3: - atlas_index = [2, 0] - rotation = 180 - break - case Direction.TURN_Q4: - atlas_index = [2, 0] - rotation = 90 - break - case Direction.TEE_NORTH: - atlas_index = [1, 0] - rotation = 180 - break - case Direction.TEE_EAST: - atlas_index = [1, 0] - rotation = 270 - break - case Direction.TEE_SOUTH: - atlas_index = [1, 0] - rotation = 0 - break - case Direction.TEE_WEST: - atlas_index = [1, 0] - rotation = 90 - break - case Direction.CROSS: - atlas_index = [0, 0] - rotation = 0 - break - case Direction.DOT: - atlas_index = [2, 1] - rotation = 0 - break - case Direction.WALL_END_NORTH: - atlas_index = [0, 1] - rotation = 0 - break; - case Direction.WALL_END_EAST: - atlas_index = [0, 1] - rotation = 90 - break; - case Direction.WALL_END_SOUTH: - atlas_index = [0, 1] - rotation = 180 - break; - case Direction.WALL_END_WEST: - atlas_index = [0, 1] - rotation = 270 - break; - } - - let atlas = document.getElementById("atlas") - context.save() - context.translate((x+.5)*w, (y+.5)*w) - context.rotate(rotation * Math.PI / 180) - context.drawImage(atlas, atlas_index[0]*w, atlas_index[1]*w, w, w, -w/2, -w/2, w, w) - context.restore() -} - -const get_point = (width, height, data, x, y) => { - if (x < 0 || x >= width || y < 0 || y >= height) { - return 0 - } else { - return data[y * width + x] - } -} - -const gen_walls = (width, height, data) => { - - let walls = Array(width * height) - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - - let north = get_point(width, height, data, x, y-1) == 1 - let south = get_point(width, height, data, x, y+1) == 1 - let east = get_point(width, height, data, x+1, y) == 1 - let west = get_point(width, height, data, x-1, y) == 1 - let current = get_point(width, height, data, x, y) == 1 - - let point = Direction.EMPTY - - if (!current) { - walls[y * width + x] = point - continue - } - - if (north && south && east && west) { - point = Direction.CROSS - } else if (east && west && north) { - point = Direction.TEE_NORTH - } else if (east && west && south) { - point = Direction.TEE_SOUTH - } else if (north && south && east) { - point = Direction.TEE_EAST - } else if (north && south && west) { - point = Direction.TEE_WEST - } else if (east && west) { - point = Direction.WALL_HZ - } else if (north && south) { - point = Direction.WALL_VT - } else if (west && south) { - point = Direction.TURN_Q1 - } else if (south && east) { - point = Direction.TURN_Q2 - } else if (east && north) { - point = Direction.TURN_Q3 - } else if (north && west) { - point = Direction.TURN_Q4 - } else if (north) { - point = Direction.WALL_END_NORTH - } else if (east) { - point = Direction.WALL_END_EAST - } else if (south) { - point = Direction.WALL_END_SOUTH - } else if (west) { - point = Direction.WALL_END_WEST - } else { - point = Direction.DOT - } - - walls[y * width + x] = point - - } - } - - return walls -} - -const update_canvas = (map, canvas) => { - let context = canvas.getContext("2d"); - for (let y = 0; y < map.height; y++) { - for (let x = 0; x < map.width; x++) { - draw_tile(context, x, y, map.tile_width, map.walls[y * map.width + x]) - } - } -} - -const gen_items = (map) => { - - let width = map.width - let height = map.height - - let items = {} - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - let tile = get_point(width, height, map.data, x, y) - if (tile != 0) continue - - let item_key = get_item_key(x, y, width) - items[item_key] = {type: ItemType.DOT, pos: [x, y]} - - let tile_south = get_point(width, height, map.data, x, y + 1) - if (tile_south == 0) { - item_key = get_item_key(x, y + .5, width) - items[item_key] = {type: ItemType.DOT, pos: [x, y + .5]} - } - - let tile_east = get_point(width, height, map.data, x + 1, y) - if (tile_east == 0) { - item_key = get_item_key(x + .5, y, width) - items[item_key] = {type: ItemType.DOT, pos: [x + .5, y]} - } - } - } - - return items -} - -export class Map { - - static data - static walls - - constructor(width, height, data) { - - this.width = width - this.height = height - this.data = data - this.walls = gen_walls(width, height, data) - this.items = gen_items(this) - this.visible = false - this.tile_width = 32 - - canvas.width = this.width * this.tile_width - canvas.height = this.height * this.tile_width - - - - } - - show() { - - let container = document.getElementById("container") - container.style.display = "" - - let canvas = document.getElementById("canvas") - canvas.style.display = "" - - let style = document.getElementById("style") - - update_canvas(this, canvas) - update_style(this, style) - - this.visible = true - } - - hide() { - - let canvas = document.getElementById("canvas") - canvas.style.display = "none" - container.style.display = "none" - - this.visible = false - } -} diff --git a/client/js/gfx/sprite.js b/client/js/gfx/sprite.js deleted file mode 100644 index 07360f1..0000000 --- a/client/js/gfx/sprite.js +++ /dev/null @@ -1,71 +0,0 @@ -export class Sprite { - - constructor(image_src, map) { - this.element = document.createElement("img") - this.element.src = image_src - this.element.className = "sprite" - document.getElementById("container").appendChild(this.element) - - this.map = map - this.x = 0 - this.y = 0 - this.w = 1 - this.h = 1 - this.z = 1 - this.d = 0 - this.hide() - } - - #update_pos() { - let width = 100 / this.map.width * this.w - let height = 100 / this.map.height * this.h - let left = 100 / this.map.width * (this.x + (1 - this.w) / 2) - let top = 100 / this.map.height * (this.y + (1 - this.h) / 2) - - this.element.style.width = `${width}%` - this.element.style.height = `${height}%` - this.element.style.left = `${left}%` - this.element.style.top = `${top}%` - this.element.style.transform = `rotate(${this.d}deg)` - this.element.style.zIndex = `${this.z}` - } - - move(x, y) { - this.x = x - this.y = y - this.#update_pos() - } - - resize(w, h) { - this.w = w - this.h = h - this.#update_pos() - } - - layer(z) { - this.z = z - this.#update_pos() - } - - set_img(src) { - this.element.src = src - } - - rotate(d) { - this.d = d - this.#update_pos() - } - - hide() { - this.element.style.display = "none" - } - - show() { - this.element.style.display = "initial" - } - - destroy() { - this.element.remove() - } - -} diff --git a/client/js/input.js b/client/js/input.js deleted file mode 100644 index 3928e91..0000000 --- a/client/js/input.js +++ /dev/null @@ -1,84 +0,0 @@ -import { Key } from "./logic.js"; - -const debug_style = document.body.appendChild(document.createElement("style")) -var debug_enabled = false - -export function startInputListener() { - let dir = 0; - let start = false; - - // document.getElementById("start").onclick = e => { - // e.preventDefault(); - // start = true; - // } - - let keymap = { - "KeyW": Key.UP, - "KeyA": Key.LEFT, - "KeyS": Key.DOWN, - "KeyD": Key.RIGHT, - }; - - document.getElementById("start").onclick = function() { - start = true - } - - window.addEventListener("keydown", ev => { - if(ev.repeat) { - return; - } - if(!(ev.code in keymap)) { - if (ev.code === "KeyB") { - debug_enabled = !debug_enabled - if (debug_enabled) { - debug_style.innerHTML = ` \ - #container img { \ - box-shadow: 0 0 1px red inset; \ - } \ - #container .sprite { \ - box-shadow: 0 0 1px white inset; \ - } \ - ` - } else { - debug_style.innerHTML = "" - } - } - return; - } - dir = keymap[ev.code]; - }); - - window.addEventListener("keyup", ev => { - if (ev.repeat) { - return; - } - if (!(ev.code in keymap)) { - return - } - if (dir == keymap[ev.code]) { - dir = Key.NOTHING - } - }) - - let last = { - dir: 0, - } - - return function() { - - if(dir === last.dir && !start) { - return; - } - - last = { - dir, - }; - - let s = start; - start = false; - return { - dir, - start: s, - } - } -} diff --git a/client/js/logic.js b/client/js/logic.js deleted file mode 100644 index 395e4f0..0000000 --- a/client/js/logic.js +++ /dev/null @@ -1,335 +0,0 @@ -import { Map } from "./gfx/map.js"; - -// enum -export const Key = { - NOTHING: undefined, - UP: 1, - DOWN: 2, - LEFT: 3, - RIGHT: 4, -} - -// enum -export const Rotation = { - NOTHING: undefined, - NORTH: 1, - EAST: 2, - SOUTH: 3, - WEST: 4 -} - -export const ItemType = { - DOT: 1 -} - -/** - * @typedef {[number, number]} Vec2 - * - * @typedef {{[key: number]: Key}} InputMap - * - * @typedef {{pos: Vec2, move_rot: Rotation, input_rot: Rotation, moving: boolean, name?: string}} Player - * @typedef {{start: boolean, key: Key, name?: string}} PlayerInput - * @typedef {{players: {[key: number]: PlayerInput}, added?: number[], removed?: number[] }} Input - * - * @typedef {{[key: number]: Player}} Players - * - * @typedef {{width: number, height: number, data: number[]}} Map - * - * @typedef {{type: ItemType, pos: Vec2}} Item - * - * @typedef {{ - * started: boolean, - * input: InputMap, - * players: Players, - * map: Map, - * items_removed: Item[] - * }} GameState - */ - -/** @type {GameState} */ -export const initState = { - started: false, - input: {}, - players: [], - map: {} -} - -let last = Date.now() -let fps_div = document.getElementById("fps") -let frameCount = 0 - -export function advance( - pastData = initState, - input = { players: {} }, - frame -) { - let data = processInput(pastData, input, frame); - - if (frameCount == 60) { - frameCount = 0 - let now = Date.now() - let fps = (now-last)/1000*60 - fps_div.innerHTML = fps.toFixed(2); - last = now - } - - frameCount++ - - return data; -} - -/** - * @param {GameState} pastData - * @param {Input} input - * @param {number} frame - */ -function processInput(pastData, input) { - - /** @type {GameState} */ - let data = structuredClone(pastData) - - let startPressed = false; - - for(const added of input.added || []) { - if (data.started || Object.keys(data.players).length >= 4) continue; - console.log("added", added); - data.input[added] ||= { - pos: [1, 1], - input_rot: Rotation.EAST, - mov_rot: Rotation.EAST, - moving: false - }; - if(!(added in data.players)) { - data.players[added] = structuredClone(data.input[added]) - } - } - - for(const id in input.players) { - if(!input.players[id]) { - continue; - } - - if(id in data.players && input.players[id].name !== undefined) { - let name = input.players[id].name; - name = name.substring(0, 16); - - data.players[id] = { - ...data.players[id], - name, - }; - - } - - startPressed ||= input.players[id].start; - data.input[id] = input.players[id].dir - } - - const player_display = document.getElementById("players") - for (const id in data.players) { - if (data.players[id] === null) continue - let name = data.players[id].name - if (name === undefined) continue - - let element_id = 'span' + id - - let element = player_display.children[element_id] - if (element === null || element === undefined) { - let span = document.createElement("span") - span.innerHTML = `[${id}] ${name}` - span.id = element_id - player_display.appendChild(span) - } - } - - if (startPressed && !data.started) { - init_map(data) - data.started ||= startPressed; - } - - if (data.started) { - update_players(data) - } - - - for(const removed of input.removed || []) { - console.log("removed", removed); - delete data.input[removed]; - delete data.players[removed]; - - let element_id = 'span' + removed - let element = document.getElementById(element_id) - if (element !== null && element !== undefined) element.remove() - } - - - return data -} - -const init_map = (data) => { - - document.getElementById("lobby").style.display = "none" - - let width = 21 - let height = 21 - let m_data = [ - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, - 1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1, - 1,0,1,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,1,0,1, - 1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1, - 1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1, - 1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1, - 1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,0,1, - 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, - 1,1,1,0,1,0,1,0,1,1,1,1,1,0,1,0,1,0,1,1,1, - 1,0,0,0,0,0,1,0,1,2,2,2,1,0,1,0,0,0,0,0,1, - 1,0,1,1,1,0,1,0,1,2,2,2,1,0,1,0,1,1,1,0,1, - 1,0,0,0,0,0,1,0,1,2,2,2,1,0,1,0,0,0,0,0,1, - 1,1,1,0,1,0,1,0,1,1,2,1,1,0,1,0,1,0,1,1,1, - 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, - 1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,0,1, - 1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1, - 1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1, - 1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1, - 1,0,1,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,1,0,1, - 1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 - ] - - data.map = new Map(width, height, m_data) - data.map.show() -} - -const MOVE_SPEED = .1 - -const round_pos = (pos) => { - return [Math.round(pos[0]), Math.round(pos[1])] -} - -const is_stable_pos = (pos) => { - let rpos = round_pos(pos) - return Math.abs(rpos[0] - pos[0]) < .05 && Math.abs(rpos[1] - pos[1]) < .05 -} - -const get_tile = (map, pos, ox, oy) => { - let x = Math.round(pos[0] + ox) - let y = Math.round(pos[1] + oy) - if (x < 0 || x >= map.width || y < 0 || y >= map.height) return 1 - return map.data[y * map.width + x] -} - -const get_tile_with_rot = (map, pos, rot) => { - let collider = 1 - switch(rot) { - case Rotation.NORTH: - collider = get_tile(map, pos, 0, -.51) - break - case Rotation.SOUTH: - collider = get_tile(map, pos, 0, .51) - break - case Rotation.WEST: - collider = get_tile(map, pos, -.51, 0) - break - case Rotation.EAST: - collider = get_tile(map, pos, .51, 0) - break - } - return collider -} - -const get_rot = (dir) => { - switch (dir) { - case Key.UP: return Rotation.NORTH - case Key.DOWN: return Rotation.SOUTH - case Key.LEFT: return Rotation.WEST - case Key.RIGHT: return Rotation.EAST - case Key.NOTHING: return Rotation.NOTHING - } -} - -const increment_pos = (pos, rot, speed) => { - switch (rot) { - case Rotation.NORTH: - pos[1] -= speed - break - case Rotation.SOUTH: - pos[1] += speed - break - case Rotation.WEST: - pos[0] -= speed - break - case Rotation.EAST: - pos[0] += speed - break - } -} - -export const get_item_key = (x, y, w) => { - let nx = Math.round(x * 2) - let ny = Math.round(y * 2) - let key = ny * w * 2 + nx - return key -} - -const ceil_half = (n) => { - return Math.ceil(n*2)/2 -} - -const floor_half = (n) => { - return Math.floor(n*2)/2 -} - -/** - * @param {GameState} data - */ -const update_players = (data) => { - - for(const id in data.input) { // move players - if(!(id in data.players)) { - console.log("what. player undefined???", id); - continue; - } - - let input_key = data.input[id] - let input_dir = get_rot(input_key) - let move_dir = data.players[id].move_rot - let current_pos = data.players[id].pos - - let tile_in_front_with_turn = get_tile_with_rot(data.map, current_pos, input_dir) - if (tile_in_front_with_turn == 1 || tile_in_front_with_turn == 2) { - input_dir = Rotation.NOTHING - } - - let turning = input_dir != Key.NOTHING && input_dir != move_dir - - data.players[id].input_rot = input_dir - - if (turning && is_stable_pos(current_pos)) { - current_pos = round_pos(current_pos) - data.players[id].move_rot = input_dir - move_dir = input_dir - } - - let move_pos = structuredClone(current_pos) - increment_pos(move_pos, move_dir, MOVE_SPEED) - - let tile_in_front = get_tile_with_rot(data.map, current_pos, move_dir) - if (tile_in_front != 1 && tile_in_front != 2) { - data.players[id].pos = move_pos - data.players[id].moving = true - } else { - data.players[id].pos = round_pos(current_pos) - data.players[id].moving = false - } - - // eat items - let pos = data.players[id].pos - for (let x = ceil_half(pos[0]-.5); x <= floor_half(pos[0]+.5); x += .5) { - for (let y = ceil_half(pos[1]-.5); y <= floor_half(pos[1]+.5); y += .5) { - let item_key = get_item_key(x, y, data.map.width) - delete data.map.items[item_key] - } - } - - } - -} diff --git a/client/js/main.js b/client/js/main.js index 1d241b7..c80f8f3 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -1,113 +1,59 @@ -import { Game } from "./game.js"; -import { startInputListener } from "./input.js"; -import { multiplayer } from "./multiplayer.js"; -import { advance, initState } from "./logic.js"; -import { startGraphicsUpdater } from "./gfx/graphics.js"; - -const join = document.getElementById("join") -const lobby = document.getElementById("lobby") -lobby.style.display = "none" - -join.onsubmit = async function(event) { - event.preventDefault() - - const room_code = this.elements.room_code.value.trim() - const player_name = this.elements.name.value.trim() - - if (room_code == '') { - alert('Please enter a room code') - return +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +import { Game } from "./net/game.js"; +import { InitialState, onLogic } from "./logic/logic.js"; +import { startGraphicsUpdater } from "./renderer.js"; +import { GameKeyMap, Key } from "./types.js"; +const join = document.getElementById("join"); +const lobby = document.getElementById("lobby"); +lobby.style.display = "none"; +join.onsubmit = function (event) { + return __awaiter(this, void 0, void 0, function* () { + event.preventDefault(); + const room_code = this.elements.room_code.value.trim(); + const player_name = this.elements.name.value.trim(); + if (room_code == '') { + alert('Please enter a room code'); + return; + } + if (player_name == '') { + alert('Please enter a player name'); + return; + } + join.style.display = "none"; + startGame(room_code, player_name); + }); +}; +const updateGraphics = startGraphicsUpdater(); +const onLoad = (startData) => { + if (startData.data.started) { + alert('Room has already started'); + return false; } - - if (player_name == '') { - alert('Please enter a player name') - return + let players = Object.values(startData.data.players).filter(p => { return p !== null && p.name !== undefined; }); + if (players.length >= 4) { + alert('Room is full'); + return false; } - - join.style.display = "none" - - startGame(room_code, player_name) -} - -function startGame(code, name) { - - const game = window.game = new Game(3000, advance); - const fps = 60; - let delay = 3; - - // set up the game up - // const ui = document.getElementById("ui"); - // ui.style.display = "block"; - - multiplayer( - game, - code, - (startFrame, latency, player, update, ping, desyncCheck) => { - // document.getElementById("desynccheck").onclick = function(e) { - // e.preventDefault(); - // this.textContent = "check for desyncs: checking..."; - // desyncCheck(game.currentFrame - 5) - // .then(res => { - // this.textContent = "check for desyncs: " + (res ? "desync" : "no desync"); - // }); - // } - console.log("started game at frame", startFrame); - window.desyncCheck = () => desyncCheck(game.currentFrame - 5); - - lobby.style.display = "" - - let startTs = performance.now() - latency; - let lastFrame = startFrame; - update({ - name, - }, startFrame + 1); - - const getInput = startInputListener(); - const updateGraphics = startGraphicsUpdater(); - - const start_data = game.getHistory(startFrame) - if (start_data.data.started) { - alert('Room has already started') - return false - } - - let players = Object.values(start_data.data.players).filter(p => { return p !== null && p.name !== undefined }) - if (players.length >= 4) { - alert('Room is full') - return false - } - - // main game loop - let lastTs = performance.now(); - function f(ts) { - const frame = Math.floor((ts - startTs) / 1000 * fps) + startFrame; - if(frame !== lastFrame) { // update input once per frame, regardless of the display refresh rate - lastFrame = frame; - - // gather input - const input = getInput(); - - // apply input - update(input, frame + delay); - } - - // set up graphics - game.currentFrame = frame; - const data = game.getHistory(frame); - updateGraphics(data ? data.data : initState); - lastTs = ts; - - requestAnimationFrame(f); - } - - requestAnimationFrame(f); - if(startFrame === -1) { - update({ - name, - }, 0); - } - - return true - } - ); -} + lobby.style.display = ""; + return true; +}; +const onFrame = (data, frame) => { + updateGraphics(data ? data.data : InitialState, frame); +}; +const startGame = (code, name) => { + const game = new Game(3000); + game.start(code, GameKeyMap, onLoad, onFrame, onLogic, { + start: false, + key: Key.NOTHING, + name + }); +}; +//# sourceMappingURL=main.js.map \ No newline at end of file diff --git a/client/js/multiplayer.js b/client/js/multiplayer.js deleted file mode 100644 index eec1c7d..0000000 --- a/client/js/multiplayer.js +++ /dev/null @@ -1,318 +0,0 @@ -/** - * @author tint - */ - -/** - * @template Input - * @typedef {{ - * added?: number[], - * removed?: number[], - * players: { - * [conn: number]: Input - * }, - * }} GameInput - */ - -/** - * @template Data, Input - * @param {import("./game.js").Game>} game - * @param {string} code - * @param {( - * startFrame: number, - * latency: number, - * connection: number, - * update: (input: Input, frame: number) => void, - * ping: () => Promise, - * desyncCheck: () => Promise, - * ) => void} onStart - * Called when the game is in a ready state with the current frame - * (or -1 if you're starting the room), an estimate of how many milliseconds have elapsed since that frame was sent, - * and your connection ID - */ -export function multiplayer(game, code, onStart) { - const url = new URL("api/join/" + encodeURIComponent(code), window.location); - url.protocol = url.protocol.replace("http", "ws"); - - const socket = new WebSocket(url); - - let requestStateTime; - let hasState = false; - let connectionId; - let cachedInputs = []; - let connections = []; - - let pingPromise; - - function send(obj) { - socket.send(JSON.stringify(obj)); - } - - function applyInput(input) { - let prev = game.getHistory(input.frame); - let newInput = prev && prev.input ? {...prev.input} : { players: {} }; - - if(input.type === "input") { - if(input.connection === undefined) { // local input - if(input.data) { - // send it to the server - send(input); - - // then apply it - newInput.players[connectionId] = input.data; - } - } else { - newInput.players[input.connection] = input.data; - } - } else if(input.type === "connections") { - if(input.added !== null) { - newInput.added = (newInput.added || []).concat([input.added]); - } - if(input.removed !== null) { - if(newInput.added) { - newInput.added = newInput.added.filter(n => n !== input.removed); - } - newInput.removed = (newInput.removed || []).concat([input.removed]); - } - } - - game.setInput(input.frame, newInput); - } - - function flushCachedInputs(latency = 0) { - for(const input of cachedInputs) { - // only care about inputs after the new state - if(input.frame <= game.historyStart) { - continue; - } - - applyInput(input); - } - cachedInputs = []; - return onStart(game.getFrame(), latency, connectionId, update, ping, desyncCheck); - } - - function update(input, frame) { - if(input === undefined) { // used to update the game locally - if(hasState) { - applyInput({ - frame, - }); - } - return; - } - const data = { - type: "input", - data: input, - frame: frame, - }; - - if(!hasState) { - cachedInputs.push(data); - } else { - applyInput(data); - } - } - - async function ping() { - send({ - type: "ping", - frame: Math.max(0, game.currentFrame), - }); - const frame = await new Promise(r => pingPromise = r); - return game.currentFrame - frame; - } - - async function desyncCheck(frame) { - const history = game.getHistory(frame); - if(!history) { - console.error("tried to check for desyncs on a frame not in memory", frame); - return true; - } - const localstate = history.data; - const proms = connections - .filter(n => n !== connectionId) - .map(connection => { - send({ - type: "requeststate", - frame, - connection, - }); - return new Promise(r => { - stateRequests[frame + "," + connection] = state => { - r({ - state, - connection, - }); - } - }); - }); - - if(!proms.length) { - return false; // this is the only connection, no check necessary - } - const states = await Promise.all(proms); - if(!states.every(({ state }) => objeq(localstate, state))) { - console.error("desync! remote states:", states, "local state:", localstate); - return true; - } - return false; - } - - let stateRequests = {}; - - socket.onmessage = message => { - const data = JSON.parse(message.data.toString()); - - switch(data.type) { - case "error": - console.error(data); - break; - case "framerequest": - send({ - type: "frame", - frame: Math.max(game.currentFrame, 1), - }); - break; - case "state": - if(data.frame + "," + data.connection in stateRequests) { - stateRequests[data.frame + "," + data.connection](data.state); - } - if(!hasState) { - game.startHistory(data.frame, data.state); - hasState = true; - - // this state is from the past - // i want to find out exactly how far in the past - // the sequence of requests looks like: - // client -[staterequest]-> server -[staterequest]-> client2 - // client2 -[state]-> server -[state]-> client - // and the time i'm concerned with is the second half, - // how long it takes the state to come from client2 - let delta = 0; - if(requestStateTime !== undefined) { - delta = performance.now() - requestStateTime; - } - if (!flushCachedInputs(delta / 2)) { - socket.close() - document.getElementById("lobby").style.display = "none" - document.getElementById("join").style.display = "" - return - } - } - break; - case "requeststate": - // wait until there's some state to send - const startTime = performance.now(); - function check() { - if(performance.now() - startTime > 5000) { - return; // give up after 5s - } - const state = game.getHistory(data.frame); - if(!state) { - return; - } - - send({ - type: "state", - frame: data.frame, - state: state.data, - }); - clearInterval(interval); - } - const interval = setInterval(check, 100); - check(); - break; - case "connections": - connections = data.connections; - if(connectionId === undefined) { - console.log("setting connection id", data.id); - connectionId = data.id; - if(data.connections.length === 1) { // no need to request state - hasState = true; - applyInput(data); - flushCachedInputs(); // just in case, also it calls onStart - break; - } - - // grab the state from another client - console.log("requesting state"); - // measure the time it takes for state to be delivered - requestStateTime = performance.now(); - send({ - type: "requeststate", - frame: data.frame, - }); - } - - if(!hasState) { - cachedInputs.push(data); - } else { - applyInput(data); - } - - break; - case "input": - if(!hasState) { - cachedInputs.push(data); - } else { - applyInput(data); - } - break; - case "pong": - if(pingPromise) { - pingPromise(data.frame); - pingPromise = undefined; - } - break; - default: - console.warn("unknown server message", data); - break; - } - } -} - -// compare two plain objects (things that can be JSON.stringified) -function objeq(a, b) { - if(typeof(a) !== typeof(b)) { - return false; - } - // array diff - if(Array.isArray(a) && Array.isArray(b)) { - if(a.length !== b.length) { - return false; - } - for(let i = 0; i < a.length; i++) { - if(!objeq(a[i], b[i])) { - return false; - } - } - return true; - } - switch(typeof(a)) { - // primitives can be compared directly - case "number": - case "boolean": - case "string": - case "undefined": return a === b; - - case "object": - // typeof(null) = "object" but null can be compared directly - if(a === null || b === null) { - return a === b; - } - // object diff - for(let k in a) { - if(!(k in b) || !objeq(a[k], b[k])) { - return false; - } - } - for(let k in b) { - if(!(k in a)) { - return false; - } - } - return true; - default: // incomparable things - return false; - } -} diff --git a/client/src/logic/items.ts b/client/src/logic/items.ts new file mode 100644 index 0000000..5f8a38e --- /dev/null +++ b/client/src/logic/items.ts @@ -0,0 +1,41 @@ +import { getMap, getItemKey } from "../map.js" +import { GameState, Map, Player } from "../types.js" + +const ceilHalf = (n: number): number => { + return Math.ceil(n*2)/2 +} + +const floorHalf = (n: number): number => { + return Math.floor(n*2)/2 +} + +const eatItems = (data: GameState, map: Map, player: Player) => { + + let pos = player.pos + + for (let x = ceilHalf(pos.x-.5); x <= floorHalf(pos.x+.5); x += .5) { + for (let y = ceilHalf(pos.y-.5); y <= floorHalf(pos.y+.5); y += .5) { + let item_key = getItemKey(x, y, map.width) + delete data.items[item_key] + } + } +} + +export const updateItems = (data: GameState) => { + + let map = getMap(data.mapId) + if (!map) return + + for(const id in data.input) { + + const player = data.players[id] + + if(!player) { + continue; + } + + eatItems(data, map, player) + + } + +} diff --git a/client/src/logic/logic.ts b/client/src/logic/logic.ts new file mode 100644 index 0000000..1cca2b7 --- /dev/null +++ b/client/src/logic/logic.ts @@ -0,0 +1,80 @@ +import { genItems, loadMap, getMap } from "../map.js"; +import { updatePlayers } from "./players.js" +import { updateUI } from "./ui.js" +import { updateMovement } from "./movement.js" +import { updateItems } from "./items.js" +import { GameState, Input } from "../types.js"; + + +export const InitialState: GameState = { + started: false, + input: {}, + players: [], + items: {}, + mapId: undefined +} + +export const onLogic = ( + pastData: GameState = InitialState, + input: Input = { players: {} }, + _frame: number +) => { + + let data = structuredClone(pastData) + + let startPressed = updatePlayers(data, input); + + if (data.started) { + updateMovement(data) + updateItems(data) + } else { + updateUI(data) + } + + if (startPressed && !data.started) { + initMap(data) + data.started = true; + } + + return data + +} + +const initMap = (data: GameState) => { + + document.getElementById("lobby").style.display = "none" + + data.mapId = 0 + + if (getMap(0)) return + + let width = 21 + let height = 21 + let m_data = [ + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1, + 1,0,1,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,1,0,1, + 1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1, + 1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1, + 1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1, + 1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,0,1, + 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, + 1,1,1,0,1,0,1,0,1,1,1,1,1,0,1,0,1,0,1,1,1, + 1,0,0,0,0,0,1,0,1,2,2,2,1,0,1,0,0,0,0,0,1, + 1,0,1,1,1,0,1,0,1,2,2,2,1,0,1,0,1,1,1,0,1, + 1,0,0,0,0,0,1,0,1,2,2,2,1,0,1,0,0,0,0,0,1, + 1,1,1,0,1,0,1,0,1,1,2,1,1,0,1,0,1,0,1,1,1, + 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, + 1,0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,0,1, + 1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1, + 1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1, + 1,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,1, + 1,0,1,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,1,0,1, + 1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 + ] + + loadMap(width, height, m_data) // cursed temp thing + data.items = genItems(getMap(0)) +} + diff --git a/client/src/logic/movement.ts b/client/src/logic/movement.ts new file mode 100644 index 0000000..40cfc3e --- /dev/null +++ b/client/src/logic/movement.ts @@ -0,0 +1,142 @@ +import { getMap } from "../map.js" +import { Vec2, Map, Rotation, Key, Player, GameState } from "../types.js" + +const MOVE_SPEED = .1 + +const roundPos = (pos: Vec2): Vec2 => { + return {x: Math.round(pos.x), y: Math.round(pos.y)} +} + +const isStablePos = (pos: Vec2): boolean => { + let rpos = roundPos(pos) + return Math.abs(rpos.x - pos.x) < .05 && Math.abs(rpos.y - pos.y) < .05 +} + +const getTile = ( + map: Map, + pos: Vec2, + ox: number, + oy: number +): number => { + let x = Math.round(pos.x + ox) + let y = Math.round(pos.y + oy) + if (x < 0 || x >= map.width || y < 0 || y >= map.height) return 1 + return map.data[y * map.width + x] +} + +const getTileFrontWithRot = ( + map: Map, + pos: Vec2, + rot: Rotation +): number => { + let collider = 1 + switch(rot) { + case Rotation.NORTH: + collider = getTile(map, pos, 0, -.51) + break + case Rotation.SOUTH: + collider = getTile(map, pos, 0, .51) + break + case Rotation.WEST: + collider = getTile(map, pos, -.51, 0) + break + case Rotation.EAST: + collider = getTile(map, pos, .51, 0) + break + } + return collider +} + +const getRot = (key: Key): Rotation => { + switch (key) { + case Key.UP: return Rotation.NORTH + case Key.DOWN: return Rotation.SOUTH + case Key.LEFT: return Rotation.WEST + case Key.RIGHT: return Rotation.EAST + case Key.NOTHING: return Rotation.NOTHING + } +} + +const incrementPos = ( + pos: Vec2, + rot: Rotation, + speed: number +): void => { + switch (rot) { + case Rotation.NORTH: + pos.y -= speed + break + case Rotation.SOUTH: + pos.y += speed + break + case Rotation.WEST: + pos.x -= speed + break + case Rotation.EAST: + pos.y += speed + break + } +} + +let i = 0 + +const updateMovementForPlayer = ( + map: Map, + player: Player, + inputKey: Key +) => { + + let inputRot = getRot(inputKey) + let moveRot = player.moveRotation + let currentPosition = player.pos + + let turningFrontTile = getTileFrontWithRot(map, currentPosition, inputRot) + if (turningFrontTile == 1 || turningFrontTile == 2) { + inputRot = Rotation.NOTHING + } + + let turning = inputRot != Rotation.NOTHING && inputRot != moveRot + + player.inputRotation = inputRot + + if (turning && isStablePos(currentPosition)) { + currentPosition = roundPos(currentPosition) + player.moveRotation = inputRot + moveRot = inputRot + } + + let movePos = structuredClone(currentPosition) + incrementPos(movePos, moveRot, MOVE_SPEED) + + let frontTile = getTileFrontWithRot(map, currentPosition, moveRot) + if (frontTile != 1 && frontTile != 2) { + player.pos = movePos + player.moving = true + } else { + player.pos = roundPos(currentPosition) + player.moving = false + } + + +} + +export const updateMovement = (data: GameState) => { + + let map = getMap(data.mapId) + if (!map) return + + for (const id in data.players) { + + const player = data.players[id] + + if(!player) { + continue + } + + let inputKey = data.input[id] + + updateMovementForPlayer(map, player, inputKey) + + } + +} diff --git a/client/src/logic/players.ts b/client/src/logic/players.ts new file mode 100644 index 0000000..ebe469f --- /dev/null +++ b/client/src/logic/players.ts @@ -0,0 +1,79 @@ +import { GameState, Input, Key, Rotation } from "../types.js" + +const canPlayerJoin = (data: GameState) => { + + // lobby has already started + if (data.started) { + return false + } + + // lobby full + if (Object.keys(data.players).length >= 4) { + return false + } + + return true + +} + +export const updatePlayers = (data: GameState, input: Input) => { + + let startPressed = false; + + for(const added of input.added || []) { + + if (!canPlayerJoin(data)) { + continue + } + + console.log("added", added); + + data.input[added] = Key.NOTHING + + data.players[added] ||= { + pos: {x: 1, y: 1}, + inputRotation: Rotation.EAST, + moveRotation: Rotation.EAST, + moving: false, + }; + + } + + for(const id in input.players) { + + if(!input.players[id]) { + continue; + } + + if(id in data.players && input.players[id].name !== undefined) { + + let name = input.players[id].name; + name = name.substring(0, 16); + + data.players[id] = { + ...data.players[id], + name, + }; + + } + + startPressed ||= input.players[id].start; + if (input.players[id].key) + data.input[id] = input.players[id].key + + } + + for(const removed of input.removed || []) { + console.log("removed", removed); + delete data.input[removed]; + delete data.players[removed]; + + let element_id = 'span' + removed + let element = document.getElementById(element_id) + if (element !== null && element !== undefined) element.remove() + } + + return startPressed + +} + diff --git a/client/src/logic/ui.ts b/client/src/logic/ui.ts new file mode 100644 index 0000000..5706843 --- /dev/null +++ b/client/src/logic/ui.ts @@ -0,0 +1,32 @@ +import { GameState } from "../types.js" + +export const updateUI = (data: GameState) => { + + const player_display = document.getElementById("players") + + for (const id in data.players) { + + const player = data.players[id] + + if (!player) { + continue + } + + let name = player.name + + if (!name) { + continue + } + + let element_id = 'span' + id + let element = player_display.children[element_id] + + if (!element) { + let span = document.createElement("span") + span.textContent = `[${id}] ${name}` + span.id = element_id + player_display.appendChild(span) + } + } + +} diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..a6cc3ba --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,75 @@ +import { Game } from "./net/game.js"; +import { InitialState, onLogic } from "./logic/logic.js"; +import { startGraphicsUpdater } from "./renderer.js"; +import { GameKeyMap, Frame, Key } from "./types.js"; + +const join = document.getElementById("join") +const lobby = document.getElementById("lobby") +lobby.style.display = "none" + +join.onsubmit = async function(event) { + event.preventDefault() + + const room_code = this.elements.room_code.value.trim() + const player_name = this.elements.name.value.trim() + + if (room_code == '') { + alert('Please enter a room code') + return + } + + if (player_name == '') { + alert('Please enter a player name') + return + } + + join.style.display = "none" + + startGame(room_code, player_name) +} + +const updateGraphics = startGraphicsUpdater() + +const onLoad = (startData: Frame) => { + + if (startData.data.started) { + alert('Room has already started') + return false + } + + let players = Object.values(startData.data.players).filter(p => { return p !== null && p.name !== undefined }) + if (players.length >= 4) { + alert('Room is full') + return false + } + + lobby.style.display = "" + + return true +} + +const onFrame = (data: Frame, frame: number) => { + + updateGraphics(data ? data.data : InitialState, frame); + +} + + +const startGame = (code: string, name: string) => { + + const game = new Game(3000) + + game.start( + code, + GameKeyMap, + onLoad, + onFrame, + onLogic, + { + start: false, + key: Key.NOTHING, + name + } + ) + +} diff --git a/client/src/map.ts b/client/src/map.ts new file mode 100644 index 0000000..e6fab9d --- /dev/null +++ b/client/src/map.ts @@ -0,0 +1,151 @@ +import { Wall, ItemType, Map, Maps, Items } from "./types.js" + +export const getItemKey = ( + x: number, + y: number, + w: number +): number => { + let nx = Math.round(x * 2) + let ny = Math.round(y * 2) + let key = ny * w * 2 + nx + return key +} + +const getPoint = ( + width: number, + height: number, + data: number[], + x: number, + y: number +): number => { + if (x < 0 || x >= width || y < 0 || y >= height) { + return 0 + } else { + return data[y * width + x] + } +} + +const genWalls = ( + width: number, + height: number, + data: number[] +): number[] => { + + let walls = Array(width * height) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + + let north = getPoint(width, height, data, x, y-1) == 1 + let south = getPoint(width, height, data, x, y+1) == 1 + let east = getPoint(width, height, data, x+1, y) == 1 + let west = getPoint(width, height, data, x-1, y) == 1 + let current = getPoint(width, height, data, x, y) == 1 + + let point = Wall.EMPTY + + if (!current) { + walls[y * width + x] = point + continue + } + + if (north && south && east && west) { + point = Wall.CROSS + } else if (east && west && north) { + point = Wall.TEE_NORTH + } else if (east && west && south) { + point = Wall.TEE_SOUTH + } else if (north && south && east) { + point = Wall.TEE_EAST + } else if (north && south && west) { + point = Wall.TEE_WEST + } else if (east && west) { + point = Wall.WALL_HZ + } else if (north && south) { + point = Wall.WALL_VT + } else if (west && south) { + point = Wall.TURN_Q1 + } else if (south && east) { + point = Wall.TURN_Q2 + } else if (east && north) { + point = Wall.TURN_Q3 + } else if (north && west) { + point = Wall.TURN_Q4 + } else if (north) { + point = Wall.WALL_END_NORTH + } else if (east) { + point = Wall.WALL_END_EAST + } else if (south) { + point = Wall.WALL_END_SOUTH + } else if (west) { + point = Wall.WALL_END_WEST + } else { + point = Wall.DOT + } + + walls[y * width + x] = point + + } + } + + return walls +} + +export const genItems = (map: Map): Items => { + + let width = map.width + let height = map.height + let data = map.data + + let items: Items = {} + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let tile = getPoint(width, height, data, x, y) + if (tile != 0) continue + + let item_key = getItemKey(x, y, width) + items[item_key] = {type: ItemType.DOT, pos: {x, y}} + + let tile_south = getPoint(width, height, data, x, y + 1) + if (tile_south == 0) { + item_key = getItemKey(x, y + .5, width) + items[item_key] = {type: ItemType.DOT, pos: {x, y: y + .5}} + } + + let tile_east = getPoint(width, height, data, x + 1, y) + if (tile_east == 0) { + item_key = getItemKey(x + .5, y, width) + items[item_key] = {type: ItemType.DOT, pos: {x: x + .5, y}} + } + } + } + + return items +} + +let mapData: Maps = {} +let id: number = 0 + +export const loadMap = ( + width: number, + height: number, + data: number[] +): number => { + + let mapId = id++ + + mapData[mapId] = { + data: structuredClone(data), + walls: genWalls(width, height, data), + width, + height, + id: mapId + } + + return mapId +} + +export const getMap = (mapId: number): Map | undefined => { + if (mapId == undefined) return undefined + return mapData[mapId] +} diff --git a/client/src/net/game.ts b/client/src/net/game.ts new file mode 100644 index 0000000..c8e5991 --- /dev/null +++ b/client/src/net/game.ts @@ -0,0 +1,186 @@ +import { Frame, GameState, Input, Key, KeyMap, PlayerInput } from "../types.js"; +import { startInputListener } from "./input.js"; +import { multiplayer } from "./multiplayer.js"; + +/** + * @author tint + * @template Data, Input + */ +export class Game { + + historysize: number + history: Frame[] + historyStart: number + currentFrame: number + advance: (pastData: GameState, input: Input, frame: number) => GameState + + constructor(history: number) { + this.historysize = history; + + this.history = []; + this.historyStart = 0; + + // the game may have inputs from the "future" + // (local input delay to make sure inputs play at the same time on all machines) + // so the "present" isn't always the latest frame + // the game loop should set this every frame + this.currentFrame = 0; + } + + startHistory(frame: number, data: GameState) { + this.historyStart = frame; + this.history = [{ data, input: { players: {} }}]; + this.currentFrame = frame; + } + + getHistory(frame: number): Frame { + return this.history[frame - this.historyStart]; + } + + getFrame(): number { + return this.historyStart + this.history.length - 1; + } + + getCurrentData(): GameState { + const entry = this.history[this.history.length - 1]; + return entry && entry.data; + } + + /** + * Sets the input at a specific frame. If that frame is in history, + * the game will be rewound, the input applied, and then fast-forwarded to the current head. + * If the frame is ahead of the current latest frame, the game will be run until that frame. + */ + setInput(frame: number, input: Input) { + console.log('input', frame, input) + this.editFrame(frame, (index: number): void => { + let past = this.history[index - 1]; + if(index === 0) { + past = { data: undefined, input: undefined }; + } + this.history[index] = { + input, + data: this.advance(past ? past.data : undefined, input, frame), + }; + }); + } + + setData(frame: number, data: GameState) { + console.log('data', frame, data) + this.editFrame(frame, (index: number): void => { + this.history[index] = { + data, + input: this.history[index] && this.history[index].input, + } + }); + } + + editFrame(frame: number, edit: (index: number) => void) { + const head = this.historyStart + this.history.length; + if(frame < head) { + if(frame < this.historyStart) { + throw new Error("Tried to edit a past frame not in history: " + frame); + } + + edit(frame - this.historyStart); + // fast forward back to the present with the new data + for(let i = frame + 1; i < head; i++) { + const past = this.history[i - this.historyStart - 1]; + this.history[i - this.historyStart].data = this.advance( + past ? past.data : undefined, + this.history[i - this.historyStart].input, + i + ); + } + } else { + // fast forward the inbetween frames with no input + for(let i = head; i < frame; i++) { + const entry = this.history[i - this.historyStart - 1]; + this.history[i - this.historyStart] = { + input: undefined, + data: this.advance(entry ? entry.data : undefined, undefined, i), + }; + } + edit(frame - this.historyStart); + } + + while(this.history.length > this.historysize) { + this.history.shift(); + this.historyStart++; + } + } + + start ( + code: string, + keymap: KeyMap, + onLoad: (startFrame: Frame) => boolean, + onFrame: (data: Frame, frame: number) => void, + onLogic: (pastData: GameState, input: Input, frame: number) => GameState, + data: PlayerInput = { start: false, key: Key.NOTHING } + ): void { + + const fps = 60; + let delay = 3; + + this.advance = onLogic + + const onStart = ( + startFrame: number, + latency: number, + _connection: number, + update: (input: PlayerInput, frame: number) => void, + _ping: () => Promise, + _desyncCheck: (frame: number) => Promise, + ) => { + console.log("started game at frame", startFrame); + // window.desyncCheck = () => desyncCheck(this.currentFrame - 5); + + let startTs = performance.now() - latency; + let lastFrame = startFrame; + update(data, startFrame + 1); + + let getInput = startInputListener(keymap) + + const startData = this.getHistory(startFrame) + + if (!onLoad(startData)) return false + + let lastTs = performance.now(); + + let loop = (ts: number) => { + + const frame = Math.floor((ts - startTs) / 1000 * fps) + startFrame; + + if(frame !== lastFrame) { // update input once per frame, regardless of the display refresh rate + lastFrame = frame; + + // gather input + const input: PlayerInput = getInput(); + + // apply input + update(input, frame + delay); + } + + this.currentFrame = frame + const data = this.getHistory(frame) + + onFrame(data, frame) + + lastTs = ts + + requestAnimationFrame(loop) + } + + requestAnimationFrame(loop) + + if(startFrame === -1) { + update(data, 0); + } + + return true + } + + multiplayer(this, code, onStart) + + } +} diff --git a/client/src/net/input.ts b/client/src/net/input.ts new file mode 100644 index 0000000..75be3e6 --- /dev/null +++ b/client/src/net/input.ts @@ -0,0 +1,70 @@ +import { Key, KeyMap, PlayerInput } from "../types.js" + +let pressed = {} + +const updateRecent = (keymap: KeyMap) => { + let max = -1 + let key = undefined + for (let code in pressed) { + let weight = pressed[code] + if (weight < max) continue + max = weight + key = keymap[code] + } + + return key +} + +export const startInputListener = (keymap: KeyMap): () => PlayerInput => { + let key: Key = Key.NOTHING; + let start = false; + + document.getElementById("start").onclick = function() { + start = true + } + + window.addEventListener("keydown", ev => { + if(ev.repeat) { + return; + } + if(!(ev.code in keymap)) { + return; + } + pressed[ev.code] = Object.keys(pressed).length + key = updateRecent(keymap) + }); + + window.addEventListener("keyup", ev => { + if (ev.repeat) { + return; + } + if (!(ev.code in keymap)) { + return + } + delete pressed[ev.code] + key = updateRecent(keymap) + }) + + let last = { + key: Key.NOTHING, + } + + return (): PlayerInput => { + + if(key === last.key && !start) { + return; + } + + last = { + key, + }; + + let s = start; + start = false; + + return { + key, + start: s, + } + } +} diff --git a/client/src/net/multiplayer.ts b/client/src/net/multiplayer.ts new file mode 100644 index 0000000..e9f3057 --- /dev/null +++ b/client/src/net/multiplayer.ts @@ -0,0 +1,303 @@ +/** + * @author tint + */ + +import { GameState, Message, PlayerInput } from "../types.js"; +import { Game } from "./game"; + +export function multiplayer( + game: Game, + code: string, + onStart: ( + startFrame: number, + latency: number, + connection: number, + update: (input: PlayerInput, frame: number) => void, + ping: () => Promise, + desyncCheck: (frame: number) => Promise, + ) => boolean +) { + const url = new URL("api/join/" + encodeURIComponent(code), window.location.toString()); + url.protocol = url.protocol.replace("http", "ws"); + + const socket = new WebSocket(url); + + let requestStateTime: number; + let hasState = false; + let connectionId: number; + let cachedInputs = []; + let connections = []; + + let pingPromise: (type: Promise) => void; + + function send(obj: any) { + socket.send(JSON.stringify(obj)); + } + + function applyInput(input: Message) { + let prev = game.getHistory(input.frame); + let newInput = prev && prev.input ? {...prev.input} : { players: {} }; + + if(input.type === "input") { + if(input.connection === undefined) { // local input + if(input.data) { + // send it to the server + send(input); + + // then apply it + newInput.players[connectionId] = input.data; + } + } else { + newInput.players[input.connection] = input.data; + } + } else if(input.type === "connections") { + if(input.added !== null) { + newInput.added = (newInput.added || []).concat([input.added]); + } + if(input.removed !== null) { + if(newInput.added) { + newInput.added = newInput.added.filter(n => n !== input.removed); + } + newInput.removed = (newInput.removed || []).concat([input.removed]); + } + } + game.setInput(input.frame, newInput); + } + + function flushCachedInputs(latency = 0): boolean { + for(const input of cachedInputs) { + // only care about inputs after the new state + if(input.frame <= game.historyStart) { + continue; + } + + applyInput(input); + } + cachedInputs = []; + return onStart(game.getFrame(), latency, connectionId, update, ping, desyncCheck); + } + + function update(input: PlayerInput, frame: number) { + if(input === undefined) { // used to update the game locally + if(hasState) { + applyInput({}) + } + return; + } + + const data = { + type: "input", + data: input, + frame: frame, + }; + + if(!hasState) { + cachedInputs.push(data); + } else { + applyInput(data); + } + } + + async function ping() { + send({ + type: "ping", + frame: Math.max(0, game.currentFrame), + }); + const frame: number = await new Promise(r => pingPromise = r); + return game.currentFrame - frame; + } + + async function desyncCheck(frame: number): Promise { + const history = game.getHistory(frame); + if(!history) { + console.error("tried to check for desyncs on a frame not in memory", frame); + return true; + } + // const localstate = history.data; + const proms = connections + .filter(n => n !== connectionId) + .map(connection => { + send({ + type: "requeststate", + frame, + connection, + }); + return new Promise(r => { + stateRequests[frame + "," + connection] = (state: GameState) => { + r({ + state, + connection, + }); + } + }); + }); + + if(!proms.length) { + return false; // this is the only connection, no check necessary + } + // const states = await Promise.all(proms); + // if(!states.every(({ state }) => objeq(localstate, state))) { + // console.error("desync! remote states:", states, "local state:", localstate); + // return true; + // } + return false; + } + + let stateRequests = {}; + + socket.onmessage = message => { + const data = JSON.parse(message.data.toString()); + + switch(data.type) { + case "error": + console.error(data); + break; + case "framerequest": + send({ + type: "frame", + frame: Math.max(game.currentFrame, 1), + }); + break; + case "state": + if(data.frame + "," + data.connection in stateRequests) { + stateRequests[data.frame + "," + data.connection](data.state); + } + if(!hasState) { + game.startHistory(data.frame, data.state); + hasState = true; + + // this state is from the past + // i want to find out exactly how far in the past + // the sequence of requests looks like: + // client -[staterequest]-> server -[staterequest]-> client2 + // client2 -[state]-> server -[state]-> client + // and the time i'm concerned with is the second half, + // how long it takes the state to come from client2 + let delta = 0; + if(requestStateTime !== undefined) { + delta = performance.now() - requestStateTime; + } + if (!flushCachedInputs(delta / 2)) { + socket.close() + document.getElementById("lobby").style.display = "none" + document.getElementById("join").style.display = "" + return + } + } + break; + case "requeststate": + // wait until there's some state to send + const startTime = performance.now(); + const check = () => { + if(performance.now() - startTime > 5000) { + return; // give up after 5s + } + const state = game.getHistory(data.frame); + if(!state) { + return; + } + + send({ + type: "state", + frame: data.frame, + state: state.data, + }); + clearInterval(interval); + } + const interval = setInterval(check, 100); + check(); + break; + case "connections": + connections = data.connections; + if(connectionId === undefined) { + console.log("setting connection id", data.id); + connectionId = data.id; + if(data.connections.length === 1) { // no need to request state + hasState = true; + applyInput(data); + flushCachedInputs(); // just in case, also it calls onStart + break; + } + + // grab the state from another client + console.log("requesting state"); + // measure the time it takes for state to be delivered + requestStateTime = performance.now(); + send({ + type: "requeststate", + frame: data.frame, + }); + } + + if(!hasState) { + cachedInputs.push(data); + } else { + applyInput(data); + } + + break; + case "input": + if(!hasState) { + cachedInputs.push(data); + } else { + applyInput(data); + } + break; + case "pong": + if(pingPromise) { + pingPromise(data.frame); + pingPromise = undefined; + } + break; + default: + console.warn("unknown server message", data); + break; + } + } +} + +// compare two plain objects (things that can be JSON.stringified) +function objeq(a: any, b: any) { + if(typeof(a) !== typeof(b)) { + return false; + } + // array diff + if(Array.isArray(a) && Array.isArray(b)) { + if(a.length !== b.length) { + return false; + } + for(let i = 0; i < a.length; i++) { + if(!objeq(a[i], b[i])) { + return false; + } + } + return true; + } + switch(typeof(a)) { + // primitives can be compared directly + case "number": + case "boolean": + case "string": + case "undefined": return a === b; + + case "object": + // typeof(null) = "object" but null can be compared directly + if(a === null || b === null) { + return a === b; + } + // object diff + for(let k in a) { + if(!(k in b) || !objeq(a[k], b[k])) { + return false; + } + } + for(let k in b) { + if(!(k in a)) { + return false; + } + } + return true; + default: // incomparable things + return false; + } +} diff --git a/client/src/renderer.ts b/client/src/renderer.ts new file mode 100644 index 0000000..c7bbbc2 --- /dev/null +++ b/client/src/renderer.ts @@ -0,0 +1,320 @@ +import { getMap } from "./map.js"; +import { Items, Players, Rotation, ItemType, Map, Wall, GameState } from "./types.js"; + +const ATLAS_TILE_WIDTH = 32 + +const update_style = (width: number, height: number) => { + + let style = document.getElementById("style") + + const css = ` + * { + --scale: 100; + --aspect: ${width/height}; + --scaleX: calc(var(--scale) * 1vw); + --scaleY: calc(var(--scale) * 1vh); + } + + #canvas { + width: calc(var(--scaleY) * var(--aspect)); + height: var(--scaleY); + margin-top: calc((100vh - var(--scaleY))/2); + margin-left: calc(50vw - var(--scaleY)*var(--aspect)/2); + position: relative; + vertical-align: top; + line-height: 0; + } + + @media (max-aspect-ratio: ${width}/${height}) { + #canvas { + width: var(--scaleX); + height: calc(var(--scaleX) / var(--aspect)); + margin-left: calc((100vw - var(--scaleX))/2); + margin-top: calc(50vh - var(--scaleX)/var(--aspect)/2); + } + }`; + + style.innerHTML = css +} + +const draw_sprite = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + atlas: CanvasImageSource, + atlas_index: [number, number], + atlas_tile_width: number, + rotation: Rotation +) => { + ctx.save() + ctx.translate( + (x + 0.5) * ATLAS_TILE_WIDTH, + (y + 0.5) * ATLAS_TILE_WIDTH + ) + ctx.rotate(rotation * Math.PI / 180) + ctx.drawImage( + atlas, + atlas_index[0] * atlas_tile_width, + atlas_index[1] * atlas_tile_width, + atlas_tile_width, + atlas_tile_width, + -width * ATLAS_TILE_WIDTH / 2, + -width * ATLAS_TILE_WIDTH / 2, + width * ATLAS_TILE_WIDTH, + width * ATLAS_TILE_WIDTH + ) + ctx.restore() +} + +const draw_players = ( + ctx: CanvasRenderingContext2D, + atlas: CanvasImageSource, + players: Players, + frame: number +) => { + + let atlas_frames: [number, number][] = [ + [0, 2], + [1, 2], + [2, 2], + [0, 3], + [1, 3], + [0, 3], + [2, 2], + [1, 2], + ] + + for (let id in players) { + + let player = players[id] + if (!player) continue + + let atlas_index = atlas_frames[0] + if (player.moving) { + atlas_index = atlas_frames[Math.floor(frame / 2) % atlas_frames.length] + } + + let rotation: number + switch (player.moveRotation) { + case Rotation.NORTH: + rotation = 270 + break + case Rotation.SOUTH: + rotation = 90 + break + case Rotation.WEST: + rotation = 180 + break + case Rotation.EAST: + default: + rotation = 0 + break + } + + draw_sprite ( + ctx, + player.pos.x, + player.pos.y, + 1, + atlas, + atlas_index, + ATLAS_TILE_WIDTH, + rotation + ) + } +} + +const draw_items = ( + ctx: CanvasRenderingContext2D, + atlas: CanvasImageSource, + items: Items +) => { + + for (let item_key in items) { + + let item = items[item_key] + if (!item) continue + + let width: number, atlas_index: [number, number] + switch (item.type) { + case ItemType.DOT: + width = .2, + atlas_index = [2, 3] + break + default: + continue + } + + draw_sprite ( + ctx, + item.pos.x, + item.pos.y, + width, + atlas, + atlas_index, + ATLAS_TILE_WIDTH, + 0 + ) + + } + +} + +const draw_map_canvas = ( + ctx: CanvasRenderingContext2D, + atlas: CanvasImageSource, + map: Map +) => { + + for (let y = 0; y < map.height; y++) { + for (let x = 0; x < map.width; x++) { + + let wall_type = map.walls[y * map.width + x] + + let atlas_index: [number, number], rotation: number; + switch(wall_type) { + case Wall.EMPTY: + continue + case Wall.WALL_HZ: + atlas_index = [1, 1] + rotation = 0 + break + case Wall.WALL_VT: + atlas_index = [1, 1] + rotation = 90 + break + case Wall.TURN_Q1: + atlas_index = [2, 0] + rotation = 0 + break + case Wall.TURN_Q2: + atlas_index = [2, 0] + rotation = 270 + break + case Wall.TURN_Q3: + atlas_index = [2, 0] + rotation = 180 + break + case Wall.TURN_Q4: + atlas_index = [2, 0] + rotation = 90 + break + case Wall.TEE_NORTH: + atlas_index = [1, 0] + rotation = 180 + break + case Wall.TEE_EAST: + atlas_index = [1, 0] + rotation = 270 + break + case Wall.TEE_SOUTH: + atlas_index = [1, 0] + rotation = 0 + break + case Wall.TEE_WEST: + atlas_index = [1, 0] + rotation = 90 + break + case Wall.CROSS: + atlas_index = [0, 0] + rotation = 0 + break + case Wall.DOT: + atlas_index = [2, 1] + rotation = 0 + break + case Wall.WALL_END_NORTH: + atlas_index = [0, 1] + rotation = 0 + break; + case Wall.WALL_END_EAST: + atlas_index = [0, 1] + rotation = 90 + break; + case Wall.WALL_END_SOUTH: + atlas_index = [0, 1] + rotation = 180 + break; + case Wall.WALL_END_WEST: + atlas_index = [0, 1] + rotation = 270 + break; + } + + draw_sprite ( + ctx, + x, + y, + 1, + atlas, + atlas_index, + ATLAS_TILE_WIDTH, + rotation + ) + } + } + +} + +let map_canvas = document.createElement("canvas") +const draw_map = ( + ctx: CanvasRenderingContext2D, + atlas: CanvasImageSource, + map: Map, + last: number | undefined +) => { + + if (map.id !== last) { + map_canvas.width = map.width * ATLAS_TILE_WIDTH + map_canvas.height = map.height * ATLAS_TILE_WIDTH + + let map_ctx = map_canvas.getContext("2d") + draw_map_canvas(map_ctx, atlas, map) + } + + ctx.drawImage ( + map_canvas, + 0, + 0 + ) + +} + +let last_map_drawn: number | undefined +export const startGraphicsUpdater = () => { + + let canvas = document.getElementById("canvas") as HTMLCanvasElement + let atlas = document.getElementById("atlas") as HTMLImageElement + + /** + * @param {import("./logic").GameState} data + */ + return ( + data: GameState, + frame: number + ) => { + + let map = getMap(data.mapId) + + if (!map) return + + if (map.id !== last_map_drawn) { + canvas.style.display = "" + canvas.width = map.width * ATLAS_TILE_WIDTH + canvas.height = map.height * ATLAS_TILE_WIDTH + } + + let ctx = canvas.getContext("2d") + ctx.clearRect(0, 0, canvas.width, canvas.height) + + draw_map(ctx, atlas, map, last_map_drawn) + draw_items(ctx, atlas, data.items) + draw_players(ctx, atlas, data.players, frame) + update_style(map.width, map.height) + + last_map_drawn = map.id + + } + +} diff --git a/client/src/types.ts b/client/src/types.ts new file mode 100644 index 0000000..df0c8b1 --- /dev/null +++ b/client/src/types.ts @@ -0,0 +1,131 @@ + +export enum Wall { + EMPTY, + WALL_HZ, + WALL_VT, + TURN_Q1, + TURN_Q2, + TURN_Q3, + TURN_Q4, + TEE_NORTH, + TEE_EAST, + TEE_SOUTH, + TEE_WEST, + CROSS, + DOT, + WALL_END_NORTH, + WALL_END_SOUTH, + WALL_END_EAST, + WALL_END_WEST +} + +export enum ItemType { + DOT +} + +export enum Key { + NOTHING, + UP, + DOWN, + LEFT, + RIGHT +} + +export type KeyMap = { + [key: string]: Key +} + +export const GameKeyMap = { + "KeyW": Key.UP, + "KeyA": Key.LEFT, + "KeyS": Key.DOWN, + "KeyD": Key.RIGHT, +} + +export enum Rotation { + NOTHING, + NORTH, + EAST, + SOUTH, + WEST +} + +export type Vec2 = { + x: number, + y: number +} + +export type InputMap = { + [key: number]: Key +} + +export type Player = { + pos: Vec2, + moveRotation: Rotation, + inputRotation: Rotation, + name?: string, + moving: boolean +} + +export type PlayerInput = { + start: boolean, + key: Key, + name?: string +} + +export type Input = { + players: {[key: number]: PlayerInput}, + added?: number[], + removed?: number[], +} + +export type Message = { + type?: string; + connections?: number[], + added?: number, + removed?: number, + id?: number, + frame?: number, + data?: any, + connection?: number, + state?: GameState, + error?: string +} + +export type Players = { + [key: number]: Player +} + +export type Item = { + type: ItemType, + pos: Vec2 +} + +export type Items = { + [key: number]: Item +} + +export type Map = { + data: number[], + walls: number[], + width: number, + height: number, + id: number +} + +export type Maps = { + [key: number]: Map +} + +export type GameState = { + started: boolean, + input: InputMap, + players: Players, + items: Items, + mapId: number | undefined +} + +export type Frame = { + data: GameState, + input: Input +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..4850356 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "noImplicitAny": false, + "sourceMap": true, + "outDir": "./js", + "allowJs": false, + "module": "es6", + "target": "es6" + }, + "include": ["./**/*"] +} diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..6b0a690 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,957 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "async-trait" +version = "0.1.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.13", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "axum" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f8ccfd9221ee7d1f3d4b33e1f8319b3a81ed8f61f2ea40b37b859794b4491" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.21.0", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f958c80c248b34b9a877a643811be8dbca03ca5ba827f2b63baf3a81e5fc4e" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" + +[[package]] +name = "bitflags" +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 = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cpufeatures" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280a9f2d8b3a38871a3c8a46fb80db65e5e5ed97da80c4d08bf27fb63e35e181" +dependencies = [ + "libc", +] + +[[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 = "digest" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8168378f4e5023e7218c89c891c0fd8ecdb5e5e4f18cb78f38cf245dd021e76f" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5e554ff619822309ffd57d8734d77cd5ce6238bc956f037ea06c58238c9899" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "itoa" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" + +[[package]] +name = "libc" +version = "0.2.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99227334921fae1a979cf0bfdfcc6b3e5ce376ef57e16fb6fb3ea2ed6095f80c" + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matchit" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "num_cpus" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" + +[[package]] +name = "percent-encoding" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" + +[[package]] +name = "pin-project" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d0dd4be24fcdcfeaa12a432d588dc59bbad6cad3510c67e74a2b6b2fc950564" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +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 = "rollback" +version = "0.1.0" +dependencies = [ + "axum", + "serde", + "serde_json", + "tokio", + "tower-http", +] + +[[package]] +name = "rustversion" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + +[[package]] +name = "ryu" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" + +[[package]] +name = "serde" +version = "1.0.159" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c04e8343c3daeec41f58990b9d77068df31209f2af111e059e9fe9646693065" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.159" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c614d17805b093df4b147b51339e7e44bf05ef59fba1e45d83500bcfb4d8585" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.13", +] + +[[package]] +name = "serde_json" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d721eca97ac802aa7777b701877c8004d950fc142651367300d21c1cc0194744" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7f05c1d5476066defcdfacce1f52fc3cae3af1d3089727100c02ae92e5abbe0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c9da457c5285ac1f936ebd076af6dac17a61cfe7826f2076b4d015cf47bc8ec" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "thiserror" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.13", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de47a4eecbe11f498978a9b29d792f0d2692d1dd003650c24c76510e3bc001" +dependencies = [ + "autocfg", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a573bdc87985e9d6ddeed1b3d864e8a302c847e40d647746df2f1de209d1ce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.13", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54319c93411147bced34cb5609a80e0a8e44c5999c93903a81cd866630ec0bfd" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" + +[[package]] +name = "tungstenite" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ee6ab729cd4cf0fd55218530c4522ed30b7b6081752839b68fcec8d0960788" +dependencies = [ + "base64 0.13.1", + "byteorder", + "bytes", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +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 = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..98ffd29 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "rollback" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +axum = { version = "0.6.12", features = ["ws"] } +tower-http = { version = "0.4.0", features = ["fs"] } diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..34783ac --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,22 @@ +mod routes; +mod rooms; +mod room; + +#[tokio::main] +async fn main() { + let port = std::env::var("PORT") + .unwrap_or("8080".to_owned()) + .parse::() + .unwrap_or(8080); + + axum::Server::bind(&std::net::SocketAddr::new( + std::net::IpAddr::V6(std::net::Ipv6Addr::from(0)), + port + )) + .serve( + routes::routes() + .into_make_service() + ) + .await + .expect("Error running the web server"); +} diff --git a/server/src/room/handle.rs b/server/src/room/handle.rs new file mode 100644 index 0000000..d397c70 --- /dev/null +++ b/server/src/room/handle.rs @@ -0,0 +1,106 @@ +use std::collections::HashSet; + +use super::messages::{ClientMessage, ServerMessage}; + +// send a ServerMessage::Connections to all sockets +pub async fn send_connections(v: &mut super::Clients, added: Option, removed: Option, frame: u64) { + // get the list of connection IDs + let connections: Vec = v.iter() + .enumerate() + .filter(|(_, n)| n.is_some()) + .map(|(id, _)| id) + .collect(); + + super::send(v, |id, _c| { + Some(ServerMessage::Connections { + connections: connections.clone(), + added, + removed, + id, + frame, + }) + }).await; +} + +// handle incoming websocket messages +pub async fn handle( + v: &mut super::Clients, + requests: &mut HashSet<(u64, Option, usize)>, // frame, connection, client id + pending: &mut Vec<(Option, Option)>, + id: usize, + msg: ClientMessage, +) { + match msg { + // broadcast inputs to every other connection + ClientMessage::Input { data, frame } => { + super::broadcast(v, ServerMessage::Input { + data, + frame, + connection: id + }, Some(id)).await; + }, + // a client needs the current game state, grab it from another client + ClientMessage::RequestState { frame, connection } => { + let count = super::conn_count(v); + + if count < 2 { // nobody to request state *from* + if let Some(Some(client)) = v.get(id) { + client.send(ServerMessage::State { + state: serde_json::Value::Null, + frame: 0, + connection: None, + }).await.ok(); + } + return; + } + + // request state from other clients + requests.insert((frame, connection, id)); + + match connection { + None => { + super::broadcast(v, ServerMessage::RequestState { frame }, Some(id)).await; + }, + Some(id) => { // it's to a specific connection + let Some(Some(client)) = v.get(id) else { + return; + }; + client.send(ServerMessage::RequestState { frame }).await.ok(); + }, + } + }, + // a client responded to a request for game state, tell all the requestees + ClientMessage::State { state, frame } => { + let mut new_requests = HashSet::new(); + for (fr, conn, cid) in requests.drain() { + if + fr != frame || // this isn't the requested frame + (conn.is_some() && Some(id) != conn) // this isn't the requested connection + { + new_requests.insert((fr, conn, cid)); + continue; + } + if let Some(Some(client)) = v.get(cid) { + client.send(ServerMessage::State { + state: state.clone(), + frame, + connection: Some(id), + }).await.ok(); + } + } + *requests = new_requests; + }, + // a client said what frame they're on, actually send the connections message + ClientMessage::Frame { frame } => { + for (added, removed) in pending.into_iter() { + send_connections(v, *added, *removed, frame).await; + } + *pending = Vec::new(); + }, + ClientMessage::Ping { frame } => { + if let Some(Some(client)) = v.get(id) { + client.send(ServerMessage::Pong { frame }).await.ok(); + } + } + } +} diff --git a/server/src/room/messages.rs b/server/src/room/messages.rs new file mode 100644 index 0000000..72958a6 --- /dev/null +++ b/server/src/room/messages.rs @@ -0,0 +1,69 @@ +use serde::{Serialize, Deserialize}; +use serde_json::Value; + +#[derive(Deserialize, Clone, Debug)] +#[serde(tag = "type")] +pub enum ClientMessage { + #[serde(rename = "frame")] + Frame { + frame: u64, + }, + #[serde(rename = "input")] + Input { + data: Value, + frame: u64, + }, + #[serde(rename = "requeststate")] + RequestState { + connection: Option, + frame: u64, + }, + #[serde(rename = "state")] + State { + state: Value, + frame: u64, + }, + #[serde(rename = "ping")] + Ping { + frame: u64, + }, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(tag = "type")] +pub enum ServerMessage { + #[serde(rename = "framerequest")] + FrameRequest, + #[serde(rename = "connections")] + Connections { + connections: Vec, + added: Option, + removed: Option, + id: usize, + frame: u64, + }, + #[serde(rename = "input")] + Input { + data: Value, + frame: u64, + connection: usize, + }, + #[serde(rename = "requeststate")] + RequestState { + frame: u64, + }, + #[serde(rename = "state")] + State { + state: Value, + frame: u64, + connection: Option, + }, + #[serde(rename = "pong")] + Pong { + frame: u64, + }, + #[serde(rename = "error")] + Error { + error: String, + }, +} diff --git a/server/src/room/mod.rs b/server/src/room/mod.rs new file mode 100644 index 0000000..8b3d8c2 --- /dev/null +++ b/server/src/room/mod.rs @@ -0,0 +1,128 @@ +use std::{time::Duration, collections::HashSet}; + +use axum::extract::ws::WebSocket; +use tokio::sync::mpsc; + +mod websocket; +mod messages; +mod handle; + +use messages::{ClientMessage, ServerMessage}; + +pub enum RoomMessage { + Add(WebSocket), + Remove(usize), + WsMessage(usize, ClientMessage), +} + +pub type Client = mpsc::Sender; +pub type Clients = Vec>; + +pub type Room = mpsc::Sender; + +// spawns a task for the room that listens for incoming messages from websockets as well as connections and disconnections +pub fn start_room(room_id: String, room_service: super::rooms::RoomService) -> Room { + let (tx, rx) = mpsc::channel::(20); + + let txret = tx.clone(); + + tokio::spawn(room_task(tx, rx, room_id, room_service)); + + txret +} + +async fn room_task(tx: mpsc::Sender, mut rx: mpsc::Receiver, room_id: String, room_service: super::rooms::RoomService) { + let mut ws = Vec::new(); + let mut state_requests = HashSet::new(); + let mut pending: Vec<(Option, Option)> = Vec::new(); + + while let Some(message) = rx.recv().await { + match message { + RoomMessage::Add(w) => { // a new connection is added + // create channels for the websocket and start a task to send and receive from it + let (wstx, wsrx) = mpsc::channel(5); + let id = ws.len(); + ws.push(Some(wstx)); + tokio::spawn(websocket::start_ws(w, id, tx.clone(), wsrx)); + + if conn_count(&ws) < 2 { // the first connection is on frame 0 + handle::send_connections(&mut ws, Some(id), None, 0).await; + } else { + // connections need to be added on a specific frame + // so ask the clients for a frame to put this event on + broadcast(&mut ws, ServerMessage::FrameRequest, Some(id)).await; + pending.push((Some(id), None)); + } + }, + RoomMessage::Remove(id) => { // a connection is closed (sent by the websocket task on exiting) + // only remove it if it exists + if let Some(item) = ws.get_mut(id) { + *item = None; + }; + let count = conn_count(&ws); + if count == 0 { // remove rooms once they become empty + room_service.send(super::rooms::RoomServiceRequest::Remove(room_id.clone())).await.ok(); + break; + } + + // disconnections happen on a specific frame, ask the clients for a frame + broadcast(&mut ws, ServerMessage::FrameRequest, None).await; + pending.push((None, Some(id))); + }, + RoomMessage::WsMessage(id, msg) => { // new data from a websocket + handle::handle(&mut ws, &mut state_requests, &mut pending, id, msg).await; + } + } + } +} + +// send the websocket to the room task +pub async fn add_connection(tx: &Room, ws: WebSocket) { + tx.send_timeout(RoomMessage::Add(ws), Duration::from_secs(1)).await.ok(); +} + +pub fn conn_count(v: &Clients) -> usize { + v.iter().filter(|i| i.is_some()).count() +} + +// send a message to all or some of the clients, in parallel rather than series, +// based on a callback +pub async fn send(v: &mut Clients, create_message: impl Fn(usize, &Client) -> Option) -> usize { + let tasks = v.iter() + .enumerate() + .map(|(id, c)| { + // send to existing clients + let Some(client) = c.clone() else { + return None; + }; + + let Some(msg) = create_message(id, &client) else { + return None; + }; + + Some(tokio::spawn(async move { + client.send(msg).await.ok(); + })) + }); + + let count = tasks.len(); + // make sure all the tasks complete + for task in tasks { + if let Some(t) = task { + t.await.ok(); + } + } + + count +} + +// send a message to all the websockets in the room (optionally excluding one) +pub async fn broadcast(v: &mut Clients, msg: ServerMessage, except: Option) -> usize { + send(v, |id, _client| { + if Some(id) == except { + return None; + } + + Some(msg.clone()) + }).await +} diff --git a/server/src/room/websocket.rs b/server/src/room/websocket.rs new file mode 100644 index 0000000..50a4537 --- /dev/null +++ b/server/src/room/websocket.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +use axum::extract::ws::{WebSocket, Message}; +use tokio::sync::mpsc; + +use super::RoomMessage; +use super::messages::ServerMessage; + +// set up some senders and receivers so that the websocket can receive messages from the task, send messages to the task, and notify the task when it closes +pub async fn start_ws(mut ws: WebSocket, id: usize, tx: mpsc::Sender, mut rx: mpsc::Receiver) { + loop { + tokio::select! { + m = ws.recv() => { // receive from the websocket and send it to `tx` + if let Some(Ok(msg)) = m { + // get the string contents + let optionstring = match msg { + Message::Text(s) => { + Some(s) + }, + Message::Binary(bin) => { + String::from_utf8(bin).ok() + }, + Message::Close(_) => { // quit the whole loop on disconnect + break; + }, + _ => None + }; + + // ignore things that aren't strings + let Some(s) = optionstring else { + continue; + }; + + // decode and send to the room + match serde_json::from_str(&s) { + Ok(message) => { + tx.send_timeout(RoomMessage::WsMessage(id, message), Duration::from_secs(1)).await.ok(); + }, + Err(e) => { // let the client know if they sent a bad message + if let Ok(text) = serde_json::to_string(&ServerMessage::Error{ + error: format!("Failed to decode JSON message: {}: {}", e, s), + }) { + ws.send(Message::Text(text)).await.ok(); + } + } + } + } else { // websocket error + break; + } + } + s = rx.recv() => { // receive from `rx` and send it to the websocket + if let Some(msg) = s { + if let Ok(string) = serde_json::to_string(&msg) { + ws.send(Message::Text(string)).await.ok(); + } + } else { // shouldn't happen but this is if the room drops the sender, it should close the websocket anyways + break; + } + } + } + } + + // websocket disconnect due to either error or normal disconnect + // notify the room that the socket should be removed + tx.send_timeout(RoomMessage::Remove(id), Duration::from_secs(1)).await.ok(); +} diff --git a/server/src/rooms.rs b/server/src/rooms.rs new file mode 100644 index 0000000..c8199d1 --- /dev/null +++ b/server/src/rooms.rs @@ -0,0 +1,59 @@ +use std::collections::HashMap; + +use axum::extract::ws::WebSocket; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +use super::room; + +pub enum RoomServiceRequest { + Exists(String, oneshot::Sender), + Join(String, WebSocket), + Remove(String), +} + +pub type RoomService = mpsc::Sender; + +type RoomMap = HashMap; + +async fn handle_room_server_message(rooms: &mut RoomMap, req: RoomServiceRequest, tx: RoomService) { + match req { + // check whether a given room exists + // the sender must provide a tokio::sync::oneshot sender to receive a response + RoomServiceRequest::Exists(code, reply) => { + reply.send(rooms.get(&code).is_some()).ok(); + }, + // send a websocket into the given room, starting it if it doesn't exist + RoomServiceRequest::Join(code, ws) => { + let room = match rooms.get(&code) { + Some(rm) => rm, + None => { + let rm = room::start_room(code.clone(), tx); + rooms.insert(code.clone(), rm); + &rooms[&code] + } + }; + + room::add_connection(room, ws).await; + }, + // remove a room (called by the room task itself once there are no more connections to it) + RoomServiceRequest::Remove(code) => { + rooms.remove(&code); + } + } +} + +// a task to manage a hashmap holding the room task senders +// returns a sender to interface with the task +pub fn start_room_server() -> RoomService { + let (tx, mut rx) = mpsc::channel::(10); + let txret = tx.clone(); + + tokio::spawn(async move { + let mut rooms: RoomMap = HashMap::new(); + while let Some(req) = rx.recv().await { + handle_room_server_message(&mut rooms, req, tx.clone()).await; + } + }); + txret +} diff --git a/server/src/routes.rs b/server/src/routes.rs new file mode 100644 index 0000000..a8c96a3 --- /dev/null +++ b/server/src/routes.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{ws::WebSocketUpgrade, Path}, + routing::get, + response::Response, + Router, Extension, +}; +use tokio::sync::oneshot; +use tower_http::services::ServeDir; + +use super::rooms; + +pub fn routes() -> Router { + let room_server: rooms::RoomService = rooms::start_room_server(); + + Router::new() + .route("/api/check", get(|| async {"ok"})) + .route("/api/exists/:code", get(game_exists)) + .route("/api/join/:code", get(game_join)) + .nest_service("/", ServeDir::new("../client")) + .layer(Extension(room_server)) +} + +// check if a given room code exists already +async fn game_exists( + Path(code): Path, + Extension(room_server): Extension +) -> &'static str { + let (tx, rx) = oneshot::channel(); + room_server.send(rooms::RoomServiceRequest::Exists(code, tx)).await.ok(); + + if let Ok(res) = rx.await { + if res { + "true" + } else { + "false" + } + } else { + return "error"; + } +} + +// start a websocket connection and join it to the room +async fn game_join( + Path(code): Path, + ws: WebSocketUpgrade, + Extension(room_server): Extension +) -> Response { + ws.on_upgrade(|s| async move { + room_server.send(rooms::RoomServiceRequest::Join(code, s)).await.ok(); + }) +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 34783ac..0000000 --- a/src/main.rs +++ /dev/null @@ -1,22 +0,0 @@ -mod routes; -mod rooms; -mod room; - -#[tokio::main] -async fn main() { - let port = std::env::var("PORT") - .unwrap_or("8080".to_owned()) - .parse::() - .unwrap_or(8080); - - axum::Server::bind(&std::net::SocketAddr::new( - std::net::IpAddr::V6(std::net::Ipv6Addr::from(0)), - port - )) - .serve( - routes::routes() - .into_make_service() - ) - .await - .expect("Error running the web server"); -} diff --git a/src/room/handle.rs b/src/room/handle.rs deleted file mode 100644 index d397c70..0000000 --- a/src/room/handle.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::collections::HashSet; - -use super::messages::{ClientMessage, ServerMessage}; - -// send a ServerMessage::Connections to all sockets -pub async fn send_connections(v: &mut super::Clients, added: Option, removed: Option, frame: u64) { - // get the list of connection IDs - let connections: Vec = v.iter() - .enumerate() - .filter(|(_, n)| n.is_some()) - .map(|(id, _)| id) - .collect(); - - super::send(v, |id, _c| { - Some(ServerMessage::Connections { - connections: connections.clone(), - added, - removed, - id, - frame, - }) - }).await; -} - -// handle incoming websocket messages -pub async fn handle( - v: &mut super::Clients, - requests: &mut HashSet<(u64, Option, usize)>, // frame, connection, client id - pending: &mut Vec<(Option, Option)>, - id: usize, - msg: ClientMessage, -) { - match msg { - // broadcast inputs to every other connection - ClientMessage::Input { data, frame } => { - super::broadcast(v, ServerMessage::Input { - data, - frame, - connection: id - }, Some(id)).await; - }, - // a client needs the current game state, grab it from another client - ClientMessage::RequestState { frame, connection } => { - let count = super::conn_count(v); - - if count < 2 { // nobody to request state *from* - if let Some(Some(client)) = v.get(id) { - client.send(ServerMessage::State { - state: serde_json::Value::Null, - frame: 0, - connection: None, - }).await.ok(); - } - return; - } - - // request state from other clients - requests.insert((frame, connection, id)); - - match connection { - None => { - super::broadcast(v, ServerMessage::RequestState { frame }, Some(id)).await; - }, - Some(id) => { // it's to a specific connection - let Some(Some(client)) = v.get(id) else { - return; - }; - client.send(ServerMessage::RequestState { frame }).await.ok(); - }, - } - }, - // a client responded to a request for game state, tell all the requestees - ClientMessage::State { state, frame } => { - let mut new_requests = HashSet::new(); - for (fr, conn, cid) in requests.drain() { - if - fr != frame || // this isn't the requested frame - (conn.is_some() && Some(id) != conn) // this isn't the requested connection - { - new_requests.insert((fr, conn, cid)); - continue; - } - if let Some(Some(client)) = v.get(cid) { - client.send(ServerMessage::State { - state: state.clone(), - frame, - connection: Some(id), - }).await.ok(); - } - } - *requests = new_requests; - }, - // a client said what frame they're on, actually send the connections message - ClientMessage::Frame { frame } => { - for (added, removed) in pending.into_iter() { - send_connections(v, *added, *removed, frame).await; - } - *pending = Vec::new(); - }, - ClientMessage::Ping { frame } => { - if let Some(Some(client)) = v.get(id) { - client.send(ServerMessage::Pong { frame }).await.ok(); - } - } - } -} diff --git a/src/room/messages.rs b/src/room/messages.rs deleted file mode 100644 index 72958a6..0000000 --- a/src/room/messages.rs +++ /dev/null @@ -1,69 +0,0 @@ -use serde::{Serialize, Deserialize}; -use serde_json::Value; - -#[derive(Deserialize, Clone, Debug)] -#[serde(tag = "type")] -pub enum ClientMessage { - #[serde(rename = "frame")] - Frame { - frame: u64, - }, - #[serde(rename = "input")] - Input { - data: Value, - frame: u64, - }, - #[serde(rename = "requeststate")] - RequestState { - connection: Option, - frame: u64, - }, - #[serde(rename = "state")] - State { - state: Value, - frame: u64, - }, - #[serde(rename = "ping")] - Ping { - frame: u64, - }, -} - -#[derive(Serialize, Clone, Debug)] -#[serde(tag = "type")] -pub enum ServerMessage { - #[serde(rename = "framerequest")] - FrameRequest, - #[serde(rename = "connections")] - Connections { - connections: Vec, - added: Option, - removed: Option, - id: usize, - frame: u64, - }, - #[serde(rename = "input")] - Input { - data: Value, - frame: u64, - connection: usize, - }, - #[serde(rename = "requeststate")] - RequestState { - frame: u64, - }, - #[serde(rename = "state")] - State { - state: Value, - frame: u64, - connection: Option, - }, - #[serde(rename = "pong")] - Pong { - frame: u64, - }, - #[serde(rename = "error")] - Error { - error: String, - }, -} diff --git a/src/room/mod.rs b/src/room/mod.rs deleted file mode 100644 index 8b3d8c2..0000000 --- a/src/room/mod.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::{time::Duration, collections::HashSet}; - -use axum::extract::ws::WebSocket; -use tokio::sync::mpsc; - -mod websocket; -mod messages; -mod handle; - -use messages::{ClientMessage, ServerMessage}; - -pub enum RoomMessage { - Add(WebSocket), - Remove(usize), - WsMessage(usize, ClientMessage), -} - -pub type Client = mpsc::Sender; -pub type Clients = Vec>; - -pub type Room = mpsc::Sender; - -// spawns a task for the room that listens for incoming messages from websockets as well as connections and disconnections -pub fn start_room(room_id: String, room_service: super::rooms::RoomService) -> Room { - let (tx, rx) = mpsc::channel::(20); - - let txret = tx.clone(); - - tokio::spawn(room_task(tx, rx, room_id, room_service)); - - txret -} - -async fn room_task(tx: mpsc::Sender, mut rx: mpsc::Receiver, room_id: String, room_service: super::rooms::RoomService) { - let mut ws = Vec::new(); - let mut state_requests = HashSet::new(); - let mut pending: Vec<(Option, Option)> = Vec::new(); - - while let Some(message) = rx.recv().await { - match message { - RoomMessage::Add(w) => { // a new connection is added - // create channels for the websocket and start a task to send and receive from it - let (wstx, wsrx) = mpsc::channel(5); - let id = ws.len(); - ws.push(Some(wstx)); - tokio::spawn(websocket::start_ws(w, id, tx.clone(), wsrx)); - - if conn_count(&ws) < 2 { // the first connection is on frame 0 - handle::send_connections(&mut ws, Some(id), None, 0).await; - } else { - // connections need to be added on a specific frame - // so ask the clients for a frame to put this event on - broadcast(&mut ws, ServerMessage::FrameRequest, Some(id)).await; - pending.push((Some(id), None)); - } - }, - RoomMessage::Remove(id) => { // a connection is closed (sent by the websocket task on exiting) - // only remove it if it exists - if let Some(item) = ws.get_mut(id) { - *item = None; - }; - let count = conn_count(&ws); - if count == 0 { // remove rooms once they become empty - room_service.send(super::rooms::RoomServiceRequest::Remove(room_id.clone())).await.ok(); - break; - } - - // disconnections happen on a specific frame, ask the clients for a frame - broadcast(&mut ws, ServerMessage::FrameRequest, None).await; - pending.push((None, Some(id))); - }, - RoomMessage::WsMessage(id, msg) => { // new data from a websocket - handle::handle(&mut ws, &mut state_requests, &mut pending, id, msg).await; - } - } - } -} - -// send the websocket to the room task -pub async fn add_connection(tx: &Room, ws: WebSocket) { - tx.send_timeout(RoomMessage::Add(ws), Duration::from_secs(1)).await.ok(); -} - -pub fn conn_count(v: &Clients) -> usize { - v.iter().filter(|i| i.is_some()).count() -} - -// send a message to all or some of the clients, in parallel rather than series, -// based on a callback -pub async fn send(v: &mut Clients, create_message: impl Fn(usize, &Client) -> Option) -> usize { - let tasks = v.iter() - .enumerate() - .map(|(id, c)| { - // send to existing clients - let Some(client) = c.clone() else { - return None; - }; - - let Some(msg) = create_message(id, &client) else { - return None; - }; - - Some(tokio::spawn(async move { - client.send(msg).await.ok(); - })) - }); - - let count = tasks.len(); - // make sure all the tasks complete - for task in tasks { - if let Some(t) = task { - t.await.ok(); - } - } - - count -} - -// send a message to all the websockets in the room (optionally excluding one) -pub async fn broadcast(v: &mut Clients, msg: ServerMessage, except: Option) -> usize { - send(v, |id, _client| { - if Some(id) == except { - return None; - } - - Some(msg.clone()) - }).await -} diff --git a/src/room/websocket.rs b/src/room/websocket.rs deleted file mode 100644 index 50a4537..0000000 --- a/src/room/websocket.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::time::Duration; - -use axum::extract::ws::{WebSocket, Message}; -use tokio::sync::mpsc; - -use super::RoomMessage; -use super::messages::ServerMessage; - -// set up some senders and receivers so that the websocket can receive messages from the task, send messages to the task, and notify the task when it closes -pub async fn start_ws(mut ws: WebSocket, id: usize, tx: mpsc::Sender, mut rx: mpsc::Receiver) { - loop { - tokio::select! { - m = ws.recv() => { // receive from the websocket and send it to `tx` - if let Some(Ok(msg)) = m { - // get the string contents - let optionstring = match msg { - Message::Text(s) => { - Some(s) - }, - Message::Binary(bin) => { - String::from_utf8(bin).ok() - }, - Message::Close(_) => { // quit the whole loop on disconnect - break; - }, - _ => None - }; - - // ignore things that aren't strings - let Some(s) = optionstring else { - continue; - }; - - // decode and send to the room - match serde_json::from_str(&s) { - Ok(message) => { - tx.send_timeout(RoomMessage::WsMessage(id, message), Duration::from_secs(1)).await.ok(); - }, - Err(e) => { // let the client know if they sent a bad message - if let Ok(text) = serde_json::to_string(&ServerMessage::Error{ - error: format!("Failed to decode JSON message: {}: {}", e, s), - }) { - ws.send(Message::Text(text)).await.ok(); - } - } - } - } else { // websocket error - break; - } - } - s = rx.recv() => { // receive from `rx` and send it to the websocket - if let Some(msg) = s { - if let Ok(string) = serde_json::to_string(&msg) { - ws.send(Message::Text(string)).await.ok(); - } - } else { // shouldn't happen but this is if the room drops the sender, it should close the websocket anyways - break; - } - } - } - } - - // websocket disconnect due to either error or normal disconnect - // notify the room that the socket should be removed - tx.send_timeout(RoomMessage::Remove(id), Duration::from_secs(1)).await.ok(); -} diff --git a/src/rooms.rs b/src/rooms.rs deleted file mode 100644 index c8199d1..0000000 --- a/src/rooms.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::collections::HashMap; - -use axum::extract::ws::WebSocket; -use tokio::sync::mpsc; -use tokio::sync::oneshot; - -use super::room; - -pub enum RoomServiceRequest { - Exists(String, oneshot::Sender), - Join(String, WebSocket), - Remove(String), -} - -pub type RoomService = mpsc::Sender; - -type RoomMap = HashMap; - -async fn handle_room_server_message(rooms: &mut RoomMap, req: RoomServiceRequest, tx: RoomService) { - match req { - // check whether a given room exists - // the sender must provide a tokio::sync::oneshot sender to receive a response - RoomServiceRequest::Exists(code, reply) => { - reply.send(rooms.get(&code).is_some()).ok(); - }, - // send a websocket into the given room, starting it if it doesn't exist - RoomServiceRequest::Join(code, ws) => { - let room = match rooms.get(&code) { - Some(rm) => rm, - None => { - let rm = room::start_room(code.clone(), tx); - rooms.insert(code.clone(), rm); - &rooms[&code] - } - }; - - room::add_connection(room, ws).await; - }, - // remove a room (called by the room task itself once there are no more connections to it) - RoomServiceRequest::Remove(code) => { - rooms.remove(&code); - } - } -} - -// a task to manage a hashmap holding the room task senders -// returns a sender to interface with the task -pub fn start_room_server() -> RoomService { - let (tx, mut rx) = mpsc::channel::(10); - let txret = tx.clone(); - - tokio::spawn(async move { - let mut rooms: RoomMap = HashMap::new(); - while let Some(req) = rx.recv().await { - handle_room_server_message(&mut rooms, req, tx.clone()).await; - } - }); - txret -} diff --git a/src/routes.rs b/src/routes.rs deleted file mode 100644 index 1fa16c7..0000000 --- a/src/routes.rs +++ /dev/null @@ -1,51 +0,0 @@ -use axum::{ - extract::{ws::WebSocketUpgrade, Path}, - routing::get, - response::Response, - Router, Extension, -}; -use tokio::sync::oneshot; -use tower_http::services::ServeDir; - -use super::rooms; - -pub fn routes() -> Router { - let room_server: rooms::RoomService = rooms::start_room_server(); - - Router::new() - .route("/api/check", get(|| async {"ok"})) - .route("/api/exists/:code", get(game_exists)) - .route("/api/join/:code", get(game_join)) - .nest_service("/", ServeDir::new("client")) - .layer(Extension(room_server)) -} - -// check if a given room code exists already -async fn game_exists( - Path(code): Path, - Extension(room_server): Extension -) -> &'static str { - let (tx, rx) = oneshot::channel(); - room_server.send(rooms::RoomServiceRequest::Exists(code, tx)).await.ok(); - - if let Ok(res) = rx.await { - if res { - "true" - } else { - "false" - } - } else { - return "error"; - } -} - -// start a websocket connection and join it to the room -async fn game_join( - Path(code): Path, - ws: WebSocketUpgrade, - Extension(room_server): Extension -) -> Response { - ws.on_upgrade(|s| async move { - room_server.send(rooms::RoomServiceRequest::Join(code, s)).await.ok(); - }) -} -- cgit v1.2.3-freya