diff options
-rw-r--r-- | pixel-client/src/game.rs | 5 | ||||
-rw-r--r-- | pixel-client/src/main.rs | 34 | ||||
-rw-r--r-- | pixel-client/src/menu.rs | 7 | ||||
-rw-r--r-- | pixel-client/src/render/mod.rs | 11 | ||||
-rw-r--r-- | server/examples/client.rs | 70 | ||||
-rw-r--r-- | server/protocol/src/lib.rs | 7 | ||||
-rw-r--r-- | server/protocol/src/movement.rs | 21 | ||||
-rw-r--r-- | server/src/game.rs | 119 | ||||
-rw-r--r-- | server/src/lib.rs | 1 | ||||
-rw-r--r-- | server/src/main.rs | 2 | ||||
-rw-r--r-- | server/src/spatial_index.rs | 31 | ||||
-rw-r--r-- | test-client/index.html | 2 | ||||
-rw-r--r-- | test-client/main.ts | 17 | ||||
-rw-r--r-- | test-client/movement.ts | 36 | ||||
-rw-r--r-- | test-client/protocol.ts | 2 |
15 files changed, 181 insertions, 184 deletions
diff --git a/pixel-client/src/game.rs b/pixel-client/src/game.rs index 1fc49c6b..7e43bdc9 100644 --- a/pixel-client/src/game.rs +++ b/pixel-client/src/game.rs @@ -348,7 +348,10 @@ impl Game { } pub fn draw(&self, ctx: &mut SpriteRenderer) { - ctx.set_view(-self.camera_center + (ctx.size / ctx.get_scale() / 2.), 1.); + ctx.set_view( + -self.camera_center + (ctx.size / ctx.get_scale() / 2.), + ctx.size.min_element() / 32. / 10., + ); self.tilemap.draw(ctx); diff --git a/pixel-client/src/main.rs b/pixel-client/src/main.rs index db2585af..7501aba2 100644 --- a/pixel-client/src/main.rs +++ b/pixel-client/src/main.rs @@ -15,10 +15,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -use anyhow::anyhow; use clap::{Parser, Subcommand}; use game::Game; -use hurrycurry_protocol::glam::{UVec2, Vec2}; +use hurrycurry_protocol::glam::Vec2; use menu::Menu; use network::Network; use render::SpriteRenderer; @@ -27,10 +26,7 @@ use sdl2::{ keyboard::{KeyboardState, Keycode}, pixels::Color, }; -use std::{ - str::FromStr, - time::{Duration, Instant}, -}; +use std::time::{Duration, Instant}; pub mod game; pub mod helper; @@ -41,9 +37,6 @@ pub mod tilemap; #[derive(Debug, Parser)] pub struct Args { - #[arg(short = 'r', long, default_value = "320x240")] - logical_resolution: Resolution, - #[clap(subcommand)] action: Option<Action>, } @@ -58,16 +51,6 @@ pub enum Action { }, } -#[derive(Debug, Clone)] -struct Resolution(UVec2); -impl FromStr for Resolution { - type Err = anyhow::Error; - fn from_str(s: &str) -> Result<Self, Self::Err> { - let (x, y) = s.split_once("x").ok_or(anyhow!("sep missing"))?; - Ok(Resolution(UVec2::new(x.parse()?, y.parse()?))) - } -} - enum State { Ingame(Game), Menu(Menu), @@ -83,7 +66,6 @@ fn main() { .unwrap(); let sdl_context = sdl2::init().unwrap(); - let ttf_context = sdl2::ttf::init().unwrap(); let video_subsystem = sdl_context.video().unwrap(); let window = video_subsystem @@ -113,22 +95,12 @@ fn main() { )), }; - // let font = ttf_context - // .load_font("/usr/share/fonts/noto/NotoSansMono-Regular.ttf", 24) - // .unwrap(); - // let text = font.render("Hello world").blended(Color::WHITE).unwrap(); - // texture_creator.create_texture_from_surface(text).unwrap(); - let mut events = sdl_context.event_pump().unwrap(); let mut last_tick = Instant::now(); - canvas - .set_logical_size(args.logical_resolution.0.x, args.logical_resolution.0.y) - .unwrap(); - 'mainloop: loop { - let (width, height) = canvas.logical_size(); + let (width, height) = canvas.output_size().unwrap(); renderer.size = Vec2::new(width as f32, height as f32); let keyboard = KeyboardState::new(&events); diff --git a/pixel-client/src/menu.rs b/pixel-client/src/menu.rs index d1f43a84..0a05e84c 100644 --- a/pixel-client/src/menu.rs +++ b/pixel-client/src/menu.rs @@ -8,10 +8,5 @@ impl Menu { Self {} } pub fn tick(&mut self, dt: f32, keyboard: &KeyboardState, layout: &AtlasLayout) {} - pub fn draw(&self, ctx: &mut SpriteRenderer) { - - - - - } + pub fn draw(&self, ctx: &mut SpriteRenderer) {} } diff --git a/pixel-client/src/render/mod.rs b/pixel-client/src/render/mod.rs index a2aea365..f18d96ad 100644 --- a/pixel-client/src/render/mod.rs +++ b/pixel-client/src/render/mod.rs @@ -34,6 +34,8 @@ pub struct SpriteRenderer<'a> { pub size: Vec2, texture: Texture<'a>, + round: bool, + view_scale: Vec2, view_offset: Vec2, @@ -103,6 +105,7 @@ impl<'a> SpriteRenderer<'a> { .collect::<HashMap<_, _>>(); Self { + round: true, texture, size: Vec2::ONE, metadata, @@ -139,10 +142,10 @@ impl<'a> SpriteRenderer<'a> { z_order: sprite.z_order, src: sprite.src, dst: FRect::new( - ((sprite.dst.x + self.view_offset.x) * self.view_scale.x).round(), - ((sprite.dst.y + self.view_offset.y) * self.view_scale.y).round(), - (sprite.dst.w * self.view_scale.x).round(), - (sprite.dst.h * self.view_scale.y).round(), + (sprite.dst.x + self.view_offset.x) * self.view_scale.x, + (sprite.dst.y + self.view_offset.y) * self.view_scale.y, + sprite.dst.w * self.view_scale.x, + sprite.dst.h * self.view_scale.y, ), }) } diff --git a/server/examples/client.rs b/server/examples/client.rs deleted file mode 100644 index 70b1bb00..00000000 --- a/server/examples/client.rs +++ /dev/null @@ -1,70 +0,0 @@ -/* - Hurry Curry! - a game about cooking - Copyright 2024 metamuffin - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, version 3 of the License only. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. - -*/ -use hurrycurry_protocol::{glam::Vec2, PacketC, PacketS}; -use std::{ - io::{stdin, BufRead, BufReader, Write}, - net::TcpStream, - thread, -}; - -fn main() { - let mut sock = TcpStream::connect("127.0.0.1:27031").unwrap(); - - let sock2 = BufReader::new(sock.try_clone().unwrap()); - thread::spawn(move || { - for line in sock2.lines() { - let line = line.unwrap(); - let packet: PacketC = serde_json::from_str(&line).unwrap(); - eprintln!("{packet:?}") - } - }); - - for line in stdin().lines() { - let line = line.unwrap(); - let mut toks = line.split(" "); - let packet = match toks.next().unwrap() { - "j" => PacketS::Join { - character: 0, - name: "test".to_string(), - }, - "p" => PacketS::Position { - pos: Vec2::new( - toks.next().unwrap().parse().unwrap(), - toks.next().unwrap().parse().unwrap(), - ), - boosting: false, - rot: 0., - }, - "i" => PacketS::Position { - pos: Vec2::new( - toks.next().unwrap().parse().unwrap(), - toks.next().unwrap().parse().unwrap(), - ), - boosting: false, - rot: toks.next().unwrap_or("0").parse().unwrap(), - }, - _ => { - println!("unknown"); - continue; - } - }; - sock.write_all(serde_json::to_string(&packet).unwrap().as_bytes()) - .unwrap(); - sock.write_all(b"\n").unwrap(); - } -} diff --git a/server/protocol/src/lib.rs b/server/protocol/src/lib.rs index f2a6dd27..14c410b3 100644 --- a/server/protocol/src/lib.rs +++ b/server/protocol/src/lib.rs @@ -88,11 +88,12 @@ pub enum PacketS { character: i32, }, Leave, - Position { + Movement { #[bincode(with_serde)] - pos: Vec2, - rot: f32, + direction: Vec2, boosting: bool, + #[bincode(with_serde)] + pos: Option<Vec2>, }, Interact { #[bincode(with_serde)] diff --git a/server/protocol/src/movement.rs b/server/protocol/src/movement.rs index 9abff295..ab4eb818 100644 --- a/server/protocol/src/movement.rs +++ b/server/protocol/src/movement.rs @@ -72,21 +72,32 @@ impl MovementBase { self.velocity += direction * dt * speed; self.position += self.velocity * dt; self.velocity = self.velocity * (-dt * PLAYER_FRICTION).exp(); - collide_player(self, map); + collide_player_tiles(self, map); - PacketS::Position { - pos: self.position, + PacketS::Movement { + pos: Some(self.position), boosting: self.boosting, - rot: self.rotation, + direction, } } + pub fn collide(&mut self, other: &mut Self, dt: f32) { + let diff = self.position - other.position; + let d = diff.length(); + if d < 0.01 || d > PLAYER_SIZE * 2. { + return; + } + let norm = diff.normalize(); + let f = 100. / (1. + d); + self.velocity += norm * f * dt + } + pub fn get_interact_target(&self) -> IVec2 { (self.position + Vec2::new(self.rotation.sin(), self.rotation.cos())).as_ivec2() } } -pub fn collide_player(p: &mut MovementBase, map: &HashSet<IVec2>) { +fn collide_player_tiles(p: &mut MovementBase, map: &HashSet<IVec2>) { for xo in -1..=1 { for yo in -1..=1 { let tile = IVec2::new(xo, yo) + p.position.as_ivec2(); diff --git a/server/src/game.rs b/server/src/game.rs index 1c50c7c2..b3b23ce0 100644 --- a/server/src/game.rs +++ b/server/src/game.rs @@ -20,16 +20,18 @@ use crate::{ data::Gamedata, entity::{Entity, EntityT}, interaction::{interact, tick_slot, InteractEffect, TickEffect}, + spatial_index::SpatialIndex, }; use anyhow::{anyhow, bail, Result}; use hurrycurry_protocol::{ glam::{IVec2, Vec2}, + movement::MovementBase, ClientGamedata, ItemIndex, ItemLocation, Message, PacketC, PacketS, PlayerID, RecipeIndex, TileIndex, }; use log::{info, warn}; use std::{ - collections::{HashMap, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, sync::Arc, time::{Duration, Instant}, }; @@ -55,17 +57,22 @@ pub struct Tile { pub struct Player { pub name: String, pub character: i32, - pub position: Vec2, - pub last_position_ts: Instant, pub interacting: Option<IVec2>, pub item: Option<Item>, pub communicate_persist: Option<Message>, + + movement: MovementBase, + direction: Vec2, + boost: bool, + last_position_update: Instant, } pub struct Game { pub data: Arc<Gamedata>, tiles: HashMap<IVec2, Tile>, + walkable: HashSet<IVec2>, pub players: HashMap<PlayerID, Player>, + players_spatial_index: SpatialIndex<PlayerID>, packet_out: VecDeque<PacketC>, demand: Option<DemandState>, pub points: i64, @@ -80,9 +87,11 @@ impl Game { packet_out: Default::default(), players: HashMap::new(), tiles: HashMap::new(), + walkable: HashSet::new(), demand: None, end: None, entities: vec![], + players_spatial_index: SpatialIndex::default(), points: 0, } } @@ -130,15 +139,27 @@ impl Game { }), }, ); + if !self.data.tile_collide[tile.0] { + self.walkable.insert(p); + } } for (id, (name, character)) in players { self.players.insert( id, Player { item: None, - last_position_ts: Instant::now(), character, - position: self.data.chef_spawn, + movement: MovementBase { + position: self.data.chef_spawn, + facing: Vec2::X, + rotation: 0., + velocity: Vec2::ZERO, + boosting: false, + stamina: 0., + }, + last_position_update: Instant::now(), + boost: false, + direction: Vec2::ZERO, communicate_persist: None, interacting: None, name: name.clone(), @@ -189,7 +210,7 @@ impl Game { for (&id, player) in &self.players { out.push(PacketC::AddPlayer { id, - position: player.position, + position: player.movement.position, character: player.character, name: player.name.clone(), }); @@ -262,9 +283,18 @@ impl Game { player, Player { item: None, - last_position_ts: Instant::now(), character, - position, + movement: MovementBase { + position: self.data.chef_spawn, + facing: Vec2::X, + rotation: 0., + velocity: Vec2::ZERO, + boosting: false, + stamina: 0., + }, + last_position_update: Instant::now(), + boost: false, + direction: Vec2::ZERO, communicate_persist: None, interacting: None, name: name.clone(), @@ -282,8 +312,11 @@ impl Game { .players .remove(&player) .ok_or(anyhow!("player does not exist"))?; + + self.players_spatial_index.remove_entry(player); + if let Some(item) = p.item { - let pos = p.position.floor().as_ivec2(); + let pos = p.movement.position.floor().as_ivec2(); if let Some(tile) = self.tiles.get_mut(&pos) { if tile.item.is_none() { self.packet_out.push_back(PacketC::SetItem { @@ -297,37 +330,25 @@ impl Game { self.packet_out .push_back(PacketC::RemovePlayer { id: player }) } - PacketS::Position { pos, rot, boosting } => { - let pid = player; + PacketS::Movement { + pos, + boosting, + direction, + } => { let player = self .players .get_mut(&player) .ok_or(anyhow!("player does not exist"))?; - // let dt = player.last_position_ts.elapsed().as_secs_f32(); - // let dist = pos.distance(player.position); - // let speed = dist / dt; - // let interact_dist = player - // .interacting - // .map(|p| (p.as_vec2() + Vec2::splat(0.5)).distance(player.position)) - // .unwrap_or_default(); - // let movement_ok = speed < PLAYER_SPEED_LIMIT && dist < 1. && interact_dist < 2.; - // if movement_ok { - player.position = pos; - player.last_position_ts = Instant::now(); - // } - self.packet_out.push_back(PacketC::Position { - player: pid, - pos: player.position, - rot, - boosting, - }); - // if !movement_ok { - // bail!( - // "{:?} moved to quickly. speed={speed:.02} dist={dist:.02}", - // player.name - // ) - // } + player.direction = direction; + player.boost = boosting; + + if let Some(pos) = pos { + let dt = player.last_position_update.elapsed(); + player.last_position_update += dt; + player.movement.position += + (pos - player.movement.position).clamp_length_max(dt.as_secs_f32()); + } } PacketS::Collide { player, force } => { self.packet_out @@ -348,7 +369,7 @@ impl Game { }; let entpos = pos.as_vec2() + Vec2::splat(0.5); - if edge && entpos.distance(player.position) > 2. { + if edge && entpos.distance(player.movement.position) > 2. { bail!("interacting too far from player"); } @@ -364,7 +385,7 @@ impl Game { let other_pid = if !self.data.is_tile_interactable(tile.kind) { self.players .iter() - .find(|(id, p)| **id != pid && p.position.distance(entpos) < 0.7) + .find(|(id, p)| **id != pid && p.movement.position.distance(entpos) < 0.7) .map(|(&id, _)| id) } else { None @@ -497,6 +518,30 @@ impl Game { } for (&pid, player) in &mut self.players { + player + .movement + .update(&self.walkable, player.direction, player.boost, dt); + + self.players_spatial_index + .update_entry(pid, player.movement.position); + } + + self.players_spatial_index.all(|p1, pos1| { + self.players_spatial_index.query(pos1, 2., |p2, _pos2| { + if let Some([a, b]) = self.players.get_many_mut([&p1, &p2]) { + a.movement.collide(&mut b.movement, dt) + } + }) + }); + + for (&pid, player) in &mut self.players { + self.packet_out.push_back(PacketC::Position { + player: pid, + pos: player.movement.position, + boosting: player.movement.boosting, + rot: player.movement.rotation, + }); + if let Some(effect) = tick_slot(dt, &self.data, None, &mut player.item) { match effect { TickEffect::Progress(warn) => self.packet_out.push_back(PacketC::SetProgress { diff --git a/server/src/lib.rs b/server/src/lib.rs index afd6f1db..0339b535 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -22,3 +22,4 @@ pub mod entity; pub mod game; pub mod interaction; pub mod state; +pub mod spatial_index; diff --git a/server/src/main.rs b/server/src/main.rs index 6f73851a..1cee94cf 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -207,7 +207,7 @@ async fn run() -> anyhow::Result<()> { if matches!( packet, - PacketS::Position { .. } | PacketS::ReplayTick { .. } + PacketS::Movement { .. } | PacketS::ReplayTick { .. } ) { trace!("<- {id:?} {packet:?}"); } else { diff --git a/server/src/spatial_index.rs b/server/src/spatial_index.rs new file mode 100644 index 00000000..4395f0f5 --- /dev/null +++ b/server/src/spatial_index.rs @@ -0,0 +1,31 @@ +use hurrycurry_protocol::glam::Vec2; +use std::{collections::HashMap, hash::Hash}; + +// TODO stub implementation. please implement +pub struct SpatialIndex<T> { + entries: HashMap<T, Vec2>, +} + +impl<T: Eq + Hash + Copy> SpatialIndex<T> { + pub fn update_entry(&mut self, id: T, position: Vec2) { + self.entries.insert(id, position); + } + pub fn remove_entry(&mut self, id: T) { + self.entries.remove(&id); + } + pub fn all(&self, mut cb: impl FnMut(T, Vec2)) { + for (&e, &pos) in &self.entries { + cb(e, pos) + } + } + pub fn query(&self, _position: Vec2, _radius: f32, cb: impl FnMut(T, Vec2)) { + self.all(cb) + } +} +impl<T> Default for SpatialIndex<T> { + fn default() -> Self { + Self { + entries: Default::default(), + } + } +} diff --git a/test-client/index.html b/test-client/index.html index 5b0fb206..dfabbf59 100644 --- a/test-client/index.html +++ b/test-client/index.html @@ -20,7 +20,7 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Hurry Curry - Test Client</title> + <title>Hurry Curry! Test Client</title> <script src="./main.js" type="module"></script> <style> body { diff --git a/test-client/main.ts b/test-client/main.ts index 30e67f67..d56cf7e6 100644 --- a/test-client/main.ts +++ b/test-client/main.ts @@ -17,7 +17,7 @@ */ /// <reference lib="dom" /> -import { MovementBase, update_movement } from "./movement.ts"; +import { MovementBase, collide_player_player, update_movement } from "./movement.ts"; import { Gamedata, ItemIndex, ItemLocation, Message, PacketC, PacketS, PlayerID, TileIndex } from "./protocol.ts"; import { V2, lerp_exp_v2_mut, normalize, lerp_exp } from "./util.ts"; import { draw_ingame, draw_wait } from "./visual.ts"; @@ -76,6 +76,7 @@ export interface PlayerData extends MovementBase { id: number, name: string, item?: ItemData, + direction: V2, character: number, anim_position: V2, message?: MessageData, @@ -145,6 +146,7 @@ function packet(p: PacketC) { rot: 0, facing: { x: 0, y: 1 }, vel: { x: 0, y: 0 }, + direction: { x: 0, y: 0 }, stamina: 0, boosting: false, }) @@ -294,7 +296,7 @@ function set_interact(edge: boolean) { function tick_update() { const p = players.get(my_id) if (!p) return - send({ type: "position", pos: [p.position.x, p.position.y], rot: p.rot, boosting: p.boosting }) + send({ type: "movement", pos: [p.position.x, p.position.y], direction: [p.direction.x, p.direction.y], boosting: p.boosting }) } function frame_update(dt: number) { @@ -303,12 +305,17 @@ function frame_update(dt: number) { if (time_remaining != null) time_remaining -= dt - const input = normalize({ + const direction = normalize({ x: (+keys_down.has(KEY_RIGHT) - +keys_down.has(KEY_LEFT)), y: (+keys_down.has(KEY_DOWN) - +keys_down.has(KEY_UP)) }) - if (interacting) input.x *= 0, input.y *= 0 - update_movement(p, dt, input, keys_down.has("KeyK")) + if (interacting) direction.x *= 0, direction.y *= 0 + p.direction = direction + update_movement(p, dt, direction, keys_down.has("KeyK")) + + for (const [_, a] of players) + for (const [_, b] of players) + collide_player_player(a, b, dt) const update_item = (item: ItemData) => { if (item.tracking) lerp_exp_v2_mut(item, item.tracking, dt * 10.) diff --git a/test-client/movement.ts b/test-client/movement.ts index 9102d7c2..1bbb9569 100644 --- a/test-client/movement.ts +++ b/test-client/movement.ts @@ -17,7 +17,7 @@ */ import { data } from "./main.ts"; -import { tiles, players } from "./main.ts"; +import { tiles } from "./main.ts"; import { V2, normalize, length, sub_v2, lerp_exp_v2_mut } from "./util.ts"; export const PLAYER_SIZE = 0.4 @@ -36,24 +36,23 @@ export interface MovementBase { stamina: number } -export function update_movement(p: MovementBase, dt: number, input: V2, boost: boolean) { - if (length(input) > 0.1) lerp_exp_v2_mut(p.facing, input, dt * 10.) +export function update_movement(p: MovementBase, dt: number, direction: V2, boost: boolean) { + if (length(direction) > 0.1) lerp_exp_v2_mut(p.facing, direction, dt * 10.) p.rot = Math.atan2(p.facing.x, p.facing.y) - boost &&= length(input) > 0.1 + boost &&= length(direction) > 0.1 p.boosting = boost && (p.boosting || p.stamina >= 1) && p.stamina > 0 if (p.boosting) p.stamina -= dt / BOOST_DURATION else p.stamina += dt / BOOST_RESTORE p.stamina = Math.max(Math.min(p.stamina, 1), 0) const speed = PLAYER_SPEED * (p.boosting ? BOOST_FACTOR : 1) - p.vel.x += input.x * dt * speed - p.vel.y += input.y * dt * speed + p.vel.x += direction.x * dt * speed + p.vel.y += direction.y * dt * speed p.position.x += p.vel.x * dt p.position.y += p.vel.y * dt collide_player(p, dt) lerp_exp_v2_mut(p.vel, { x: 0, y: 0 }, dt * PLAYER_FRICTION) } - -function collide_player(p: MovementBase, dt: number) { +function collide_player(p: MovementBase, _dt: number) { for (let xo = -1; xo <= 1; xo++) { for (let yo = -1; yo <= 1; yo++) { const x = Math.floor(p.position.x) + xo @@ -79,17 +78,16 @@ function collide_player(p: MovementBase, dt: number) { p.vel.y -= grad_y * vdotn } } - - for (const [_, player] of players) { - const diff = sub_v2(p.position, player.position) - const d = length(diff) - if (d < 0.01) continue - if (d >= PLAYER_SIZE * 2) continue - const norm = normalize(diff); - const f = 100 / (1 + d) - p.vel.x += norm.x * f * dt - p.vel.y += norm.y * f * dt - } +} +export function collide_player_player(a: MovementBase, b: MovementBase, dt: number) { + const diff = sub_v2(a.position, b.position) + const d = length(diff) + if (d < 0.01) return + if (d >= PLAYER_SIZE * 2) return + const norm = normalize(diff); + const f = 100 / (1 + d) + a.vel.x += norm.x * f * dt + a.vel.y += norm.y * f * dt } export function aabb_point_distance( diff --git a/test-client/protocol.ts b/test-client/protocol.ts index f8241854..2a0f2b87 100644 --- a/test-client/protocol.ts +++ b/test-client/protocol.ts @@ -38,7 +38,7 @@ export interface Gamedata { export type PacketS = { type: "join", name: string, character: number } // Spawns your character. Dont send it to spectate. | { type: "leave" } // Despawns your character - | { type: "position", pos: Vec2, rot: number, boosting: boolean } // Update your position and rotation in radians (0 is -y) + | { type: "movement", pos: Vec2, direction: Vec2, boosting: boolean } | { type: "interact", pos?: Vec2 } // Interact with some tile. pos is a position when pressing and null when releasing interact button | { type: "communicate", message?: Message, persist: boolean } // Send a message | { type: "collide", player: PlayerID, force: Vec2 } // Apply force to another player as a result of a collision |