aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2024-06-04 16:48:07 +0200
committermetamuffin <metamuffin@disroot.org>2024-06-04 16:48:07 +0200
commit34967cd3b6530656ef0bf31810f9fd6dfb853765 (patch)
tree912bb0995db6997b601f246cfb0420b6b3ab2101
parentce0b808a01081322abc7ed51e09d0f452b606ad7 (diff)
downloadgpn-tron-rust-34967cd3b6530656ef0bf31810f9fd6dfb853765.tar
gpn-tron-rust-34967cd3b6530656ef0bf31810f9fd6dfb853765.tar.bz2
gpn-tron-rust-34967cd3b6530656ef0bf31810f9fd6dfb853765.tar.zst
can vie games
-rw-r--r--Cargo.lock26
-rw-r--r--Cargo.toml2
-rw-r--r--build.rs21
-rw-r--r--config.toml1
-rw-r--r--src/bot/mod.rs1
-rw-r--r--src/game/map.rs41
-rw-r--r--src/game/mod.rs28
-rw-r--r--src/game/server.rs70
-rw-r--r--src/lib.rs1
-rw-r--r--src/spectate/index.html6
-rw-r--r--src/spectate/main.ts93
-rw-r--r--src/spectate/server.rs28
12 files changed, 285 insertions, 33 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 31a6eb7..9cc3262 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -432,7 +432,9 @@ dependencies = [
"env_logger",
"futures",
"glam",
+ "headers",
"log",
+ "mime",
"serde",
"serde_json",
"tokio",
@@ -446,6 +448,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
+name = "headers"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9"
+dependencies = [
+ "base64",
+ "bytes",
+ "headers-core",
+ "http",
+ "httpdate",
+ "mime",
+ "sha1",
+]
+
+[[package]]
+name = "headers-core"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
+dependencies = [
+ "http",
+]
+
+[[package]]
name = "hermit-abi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index ad7e550..4713f3c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,3 +14,5 @@ toml = "0.8.14"
serde = { version = "1.0.203", features = ["derive"] }
glam = "0.27.0"
serde_json = "1.0.117"
+headers = "0.4.0"
+mime = "0.3.17"
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..8255656
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,21 @@
+#![feature(exit_status_error)]
+use std::process::{Command, Stdio};
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-changed=src/spectate/main.ts");
+ let outpath = std::env::var("OUT_DIR").unwrap();
+ // this is great :)))
+ println!("cargo:warning=\r\x1b[32m\x1b[1m Bundle\x1b[0m writing main.js ({outpath})");
+ let mut proc = Command::new("esbuild")
+ .arg("src/spectate/main.ts")
+ .arg("--bundle")
+ .arg(format!("--outfile={outpath}/main.js"))
+ .arg("--target=esnext")
+ .arg("--sourcemap")
+ .arg("--format=esm")
+ .stderr(Stdio::piped())
+ .spawn()
+ .unwrap();
+ proc.wait().unwrap().exit_ok().unwrap();
+}
diff --git a/config.toml b/config.toml
index 12d53f9..9cdb4c7 100644
--- a/config.toml
+++ b/config.toml
@@ -3,3 +3,4 @@ bind = "127.0.0.1:8000"
[game]
bind = "0.0.0.0:4000"
+tickrate = 1
diff --git a/src/bot/mod.rs b/src/bot/mod.rs
new file mode 100644
index 0000000..71c659f
--- /dev/null
+++ b/src/bot/mod.rs
@@ -0,0 +1 @@
+pub async fn spawn_bots() {}
diff --git a/src/game/map.rs b/src/game/map.rs
new file mode 100644
index 0000000..420e256
--- /dev/null
+++ b/src/game/map.rs
@@ -0,0 +1,41 @@
+use glam::IVec2;
+use std::ops::{Index, IndexMut};
+
+pub struct Map {
+ pub cells: Vec<Option<u32>>,
+ pub size: IVec2,
+}
+impl Map {
+ pub fn new(width: usize, height: usize) -> Self {
+ Self {
+ cells: vec![None; width * height],
+ size: IVec2::new(width as i32, height as i32),
+ }
+ }
+ pub fn index(&self, v: IVec2) -> usize {
+ let i = v.x.rem_euclid(self.size.x as i32)
+ + v.y.rem_euclid(self.size.y as i32) * self.size.x as i32;
+ i as usize
+ }
+ pub fn clear_player(&mut self, p: u32) {
+ self.cells.iter_mut().for_each(|c| {
+ if let Some(cm) = c {
+ if *cm == p {
+ *c = None;
+ }
+ }
+ });
+ }
+}
+impl Index<IVec2> for Map {
+ type Output = Option<u32>;
+ fn index(&self, index: IVec2) -> &Self::Output {
+ &self.cells[self.index(index)]
+ }
+}
+impl IndexMut<IVec2> for Map {
+ fn index_mut(&mut self, index: IVec2) -> &mut Self::Output {
+ let i = self.index(index);
+ &mut self.cells[i]
+ }
+}
diff --git a/src/game/mod.rs b/src/game/mod.rs
index 27faa27..f59b5d5 100644
--- a/src/game/mod.rs
+++ b/src/game/mod.rs
@@ -1,34 +1,34 @@
use glam::IVec2;
+use map::Map;
use protocol::Direction;
use serde::Deserialize;
use std::{collections::HashMap, net::SocketAddr, ops::ControlFlow};
+pub mod map;
pub mod protocol;
pub mod server;
-#[derive(Deserialize)]
+#[derive(Deserialize, Clone)]
pub struct Config {
bind: SocketAddr,
+ tickrate: f32,
}
pub struct Game {
pub heads: HashMap<u32, (Direction, IVec2, String)>,
- pub map: HashMap<IVec2, u32>,
- pub size: IVec2,
+ pub map: Map,
pub dead: Vec<u32>,
}
impl Game {
- pub fn new(players: Vec<String>) -> Self {
- let mut map = HashMap::new();
+ pub fn new(players: Vec<(u32, String)>) -> Self {
+ let mut map = Map::new(players.len() * 2, players.len() * 2);
let mut heads = HashMap::new();
- let plen = players.len();
- for (p, name) in players.into_iter().enumerate() {
+ for (p, name) in players {
let pos = IVec2::ONE * p as i32 * 2;
- map.insert(pos, p as u32);
- heads.insert(p as u32, (Direction::Up, pos, name));
+ map[pos] = Some(p);
+ heads.insert(p, (Direction::Up, pos, name));
}
Self {
- size: IVec2::ONE * plen as i32 * 2,
heads,
map,
dead: Vec::new(),
@@ -36,14 +36,14 @@ impl Game {
}
pub fn tick(&mut self) -> ControlFlow<Option<u32>, ()> {
for (_player, (dir, head, _)) in &mut self.heads {
- *head = (*head + dir.vector()).rem_euclid(self.size)
+ *head = (*head + dir.vector()).rem_euclid(self.map.size)
}
self.dead.clear();
let mut h = HashMap::<IVec2, Vec<u32>>::new();
for (player, (_, head, _)) in &self.heads {
h.entry(*head).or_default().push(*player);
- if self.map.contains_key(head) {
+ if self.map[*head].is_some() {
self.dead.push(*player);
}
}
@@ -54,11 +54,11 @@ impl Game {
}
for (player, (_, head, _)) in &mut self.heads {
- self.map.insert(*head, *player);
+ self.map[*head] = Some(*player);
}
for d in &self.dead {
- self.map.retain(|_, p| *d != *p);
+ self.map.clear_player(*d);
self.heads.remove(&d);
}
diff --git a/src/game/server.rs b/src/game/server.rs
index a4472a8..f1d10d2 100644
--- a/src/game/server.rs
+++ b/src/game/server.rs
@@ -1,15 +1,18 @@
-use super::{protocol::Packet, Config};
+use super::{protocol::Packet, Config, Game};
use crate::State;
use anyhow::{anyhow, bail, Result};
use log::{debug, error, info};
-use std::sync::Arc;
+use std::{ops::ControlFlow, sync::Arc, time::Duration};
use tokio::{
io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader, BufWriter},
net::{TcpListener, TcpStream},
spawn,
+ time::sleep,
};
pub async fn game_server(config: Config, state: Arc<State>) -> Result<()> {
+ spawn(game_loop(config.clone(), state.clone()));
+
let listener = TcpListener::bind(config.bind).await?;
info!("listening on {}", listener.local_addr()?);
while let Ok((sock, addr)) = listener.accept().await {
@@ -25,10 +28,39 @@ pub async fn game_server(config: Config, state: Arc<State>) -> Result<()> {
bail!("accept failure")
}
+async fn game_loop(config: Config, state: Arc<State>) {
+ loop {
+ sleep(Duration::from_secs_f32(1. / config.tickrate)).await;
+
+ let mut g = state.game.write().await;
+ let res = g.tick();
+ match res {
+ ControlFlow::Continue(()) => {
+ let _ = state.tick.send(false);
+ }
+ ControlFlow::Break(winner) => {
+ info!("winner: {winner:?}");
+ let p = state.players.read().await;
+ *g = Game::new(p.clone().into_iter().collect());
+ let _ = state.tick.send(true);
+ }
+ }
+ drop(g);
+ }
+}
+
+struct ClientState {
+ pid: Option<u32>,
+ alive: bool,
+}
+
async fn handle_client(sock: TcpStream, state: Arc<State>) -> Result<()> {
- let mut pid = None;
- let res = handle_client_inner(sock, &state, &mut pid).await;
- if let Some(pid) = pid {
+ let mut cstate = ClientState {
+ pid: None,
+ alive: false,
+ };
+ let res = handle_client_inner(sock, &state, &mut cstate).await;
+ if let Some(pid) = cstate.pid {
state.players.write().await.remove(&pid);
}
res
@@ -37,7 +69,7 @@ async fn handle_client(sock: TcpStream, state: Arc<State>) -> Result<()> {
async fn handle_client_inner(
sock: TcpStream,
state: &Arc<State>,
- pid: &mut Option<u32>,
+ pid: &mut ClientState,
) -> anyhow::Result<()> {
let (rx, tx) = sock.into_split();
let rx = BufReader::new(rx);
@@ -79,19 +111,25 @@ impl<T: AsyncWrite + Unpin> SendPacketExt for T {
async fn handle_tick(
mut tx: impl AsyncWrite + Unpin,
- pid: &mut Option<u32>,
+ cstate: &mut ClientState,
state: &Arc<State>,
new_game: bool,
) -> anyhow::Result<()> {
- let Some(pid) = pid else { return Ok(()) };
+ let Some(pid) = cstate.pid else { return Ok(()) };
let mut events = Vec::new();
+ if new_game {
+ if cstate.alive {
+ tx.send_packet(Packet::Win(0, 0)).await?;
+ }
+ cstate.alive = true;
+ }
{
let g = state.game.read().await;
if new_game {
events.push(Packet::Game {
my_id: 0,
- width: g.size.x as usize,
- height: g.size.y as usize,
+ width: g.map.size.x as usize,
+ height: g.map.size.y as usize,
});
for (player, (_, _, name)) in &g.heads {
events.push(Packet::Player {
@@ -110,7 +148,7 @@ async fn handle_tick(
if !g.dead.is_empty() {
events.push(Packet::Die(g.dead.clone()));
}
- if g.dead.contains(pid) {
+ if g.dead.contains(&pid) {
events.push(Packet::Lose(0, 0)); // TODO implement stats
}
events.push(Packet::Tick);
@@ -123,7 +161,7 @@ async fn handle_tick(
async fn handle_packet(
mut tx: impl AsyncWrite + Unpin,
- pid: &mut Option<u32>,
+ cstate: &mut ClientState,
state: &Arc<State>,
line: String,
) -> anyhow::Result<()> {
@@ -141,7 +179,7 @@ async fn handle_packet(
username,
password: _,
} => {
- if pid.is_some() {
+ if cstate.pid.is_some() {
tx.send_packet(Packet::Error("already joined".to_string()))
.await?
} else {
@@ -151,13 +189,13 @@ async fn handle_packet(
id += 1;
}
g.insert(id, username);
- *pid = Some(id);
+ cstate.pid = Some(id);
}
}
Packet::Move(dir) => {
- if let Some(pid) = pid {
+ if let Some(pid) = cstate.pid {
let mut g = state.game.write().await;
- if let Some((head_dir, _, _)) = g.heads.get_mut(pid) {
+ if let Some((head_dir, _, _)) = g.heads.get_mut(&pid) {
*head_dir = dir
} else {
drop(g);
diff --git a/src/lib.rs b/src/lib.rs
index a213a83..b3f62fe 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,6 +7,7 @@ use tokio::sync::{broadcast, RwLock};
pub mod config;
pub mod game;
pub mod spectate;
+pub mod bot;
pub struct State {
pub tick: broadcast::Sender<bool>, // true for new game
diff --git a/src/spectate/index.html b/src/spectate/index.html
index 235a894..94ef698 100644
--- a/src/spectate/index.html
+++ b/src/spectate/index.html
@@ -4,8 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GPN Tron</title>
+ <script src="main.js"></script>
</head>
<body>
-
+ <noscript>
+ This is the GPN-Tron spectator application. You need JavaScript to
+ run it.
+ </noscript>
</body>
</html>
diff --git a/src/spectate/main.ts b/src/spectate/main.ts
new file mode 100644
index 0000000..4d508b6
--- /dev/null
+++ b/src/spectate/main.ts
@@ -0,0 +1,93 @@
+/// <reference lib="dom" />
+
+const ws = new WebSocket("/events")
+
+type Packet = "tick"
+ | { pos: { id: number, x: number, y: number } }
+ | { game: { width: number, height: number } }
+ | { player: { id: number, name: string } }
+
+class Snake {
+ parts: { x: number, y: number, dx: number, dy: number }[] = []
+ constructor(public name: string) { }
+ add_part(x: number, y: number) {
+ if (!this.parts.length) return this.parts.push({ x, y, dx: 0, dy: 0 })
+ const last = this.parts[this.parts.length - 1]
+ let dx = x - last.x, dy = y - last.y;
+ if (x > last.x + 1 || x < last.x - 1) dx *= -1, dx /= Math.abs(dx)
+ if (y > last.y + 1 || y < last.y - 1) dy *= -1, dy /= Math.abs(dy)
+ this.parts.push({ x, y, dx, dy })
+ console.log(this.parts);
+
+ }
+}
+let size = 0
+let snakes = new Map<number, Snake>()
+
+let canvas: HTMLCanvasElement
+let ctx: CanvasRenderingContext2D
+
+document.addEventListener("DOMContentLoaded", () => {
+ canvas = document.createElement("canvas")
+ ctx = canvas.getContext("2d")!
+ document.body.append(canvas)
+ canvas.width = 1000;
+ canvas.height = 1000;
+ redraw()
+})
+
+function redraw() {
+ ctx.fillStyle = "black"
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ ctx.save()
+ const scale = canvas.width / size
+ ctx.scale(scale, scale)
+ for (let x = 0; x < size; x++) {
+ for (let y = 0; y < size; y++) {
+ ctx.strokeStyle = "grey"
+ ctx.lineWidth = 0.02
+ ctx.strokeRect(x, y, 1, 1)
+ }
+ }
+ ctx.translate(0.5, 0.5)
+ for (const [xo, yo] of [[0, 0], [-size, 0], [size, 0], [0, -size], [0, size]]) {
+ ctx.save()
+ ctx.translate(xo, yo)
+ for (const snake of snakes.values()) {
+ ctx.beginPath();
+ for (let i = 0; i < snake.parts.length; i++) {
+ const p = snake.parts[i];
+ ctx.moveTo(p.x - p.dx, p.y - p.dy)
+ ctx.lineTo(p.x, p.y)
+ }
+ ctx.lineCap = "round"
+ ctx.lineJoin = "round"
+ ctx.lineWidth = 0.6;
+ ctx.strokeStyle = "red"
+ ctx.stroke()
+ }
+ ctx.restore()
+ }
+ ctx.restore()
+
+ requestAnimationFrame(redraw)
+}
+
+ws.onerror = console.error
+ws.onmessage = message => {
+ const p = JSON.parse(message.data) as Packet
+ console.log(p);
+ if (p == "tick") {
+
+ } else if ("game" in p) {
+ snakes.clear()
+ size = p.game.width
+ } else if ("player" in p) {
+ snakes.set(p.player.id, new Snake(p.player.name))
+ } else if ("pos" in p) {
+ snakes.get(p.pos.id)?.add_part(p.pos.x, p.pos.y)
+ }
+
+}
+
diff --git a/src/spectate/server.rs b/src/spectate/server.rs
index 9dbfebe..e3e9f80 100644
--- a/src/spectate/server.rs
+++ b/src/spectate/server.rs
@@ -6,6 +6,7 @@ use anyhow::Result;
use axum::extract;
use axum::extract::connect_info::ConnectInfo;
use axum::extract::ws::Message;
+use axum::http::HeaderMap;
use axum::response::Html;
use axum::{
extract::ws::{WebSocket, WebSocketUpgrade},
@@ -13,8 +14,10 @@ use axum::{
routing::get,
Router,
};
+use headers::ContentType;
use log::{info, warn};
use std::net::SocketAddr;
+use std::str::FromStr;
use std::sync::Arc;
use tokio::spawn;
use tokio::sync::{broadcast, RwLock};
@@ -32,6 +35,7 @@ pub async fn spectate_server(config: Config, state: Arc<State>) -> Result<()> {
spawn(broadcaster(sstate.clone(), state));
let app = Router::new()
.route("/", get(index))
+ .route("/main.js", get(javascript))
.route("/events", get(ws_handler))
.with_state(sstate);
let listener = tokio::net::TcpListener::bind(config.bind).await.unwrap();
@@ -47,6 +51,26 @@ pub async fn spectate_server(config: Config, state: Arc<State>) -> Result<()> {
async fn index() -> Html<&'static str> {
Html(include_str!("index.html"))
}
+#[cfg(debug_assertions)]
+async fn javascript() -> (HeaderMap, String) {
+ use headers::HeaderMapExt;
+ use tokio::fs::read_to_string;
+ let mut hm = HeaderMap::new();
+ hm.typed_insert(ContentType::from_str("application/javascript").unwrap());
+ (
+ hm,
+ read_to_string(concat!(env!("OUT_DIR"), "/main.js"))
+ .await
+ .unwrap(),
+ )
+}
+#[cfg(not(debug_assertions))]
+async fn javascript() -> (HeaderMap, &'static str) {
+ use headers::HeaderMapExt;
+ let mut hm = HeaderMap::new();
+ hm.typed_insert(ContentType::from_str("application/javascript").unwrap());
+ (hm, include_str!(concat!(env!("OUT_DIR"), "/main.js")))
+}
async fn broadcaster(sstate: Arc<SpectateState>, state: Arc<State>) {
let mut ticks = state.tick.subscribe();
@@ -59,8 +83,8 @@ async fn broadcaster(sstate: Arc<SpectateState>, state: Arc<State>) {
sstate.past_events.write().await.clear();
events.push(Packet::Game {
my_id: 0,
- width: g.size.x as usize,
- height: g.size.y as usize,
+ width: g.map.size.x as usize,
+ height: g.map.size.y as usize,
});
for (player, (_, _, name)) in &g.heads {
events.push(Packet::Player {