diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Cargo.lock | 60 | ||||
| -rw-r--r-- | data/demands/default.yaml (renamed from data/demands.yaml) | 0 | ||||
| -rw-r--r-- | data/demands/none.yaml | 0 | ||||
| -rw-r--r-- | data/index.yaml | 14 | ||||
| -rw-r--r-- | data/makefile | 4 | ||||
| -rw-r--r-- | data/maps/big.yaml (renamed from data/map.yaml) | 30 | ||||
| -rw-r--r-- | data/maps/lobby.yaml | 55 | ||||
| -rw-r--r-- | data/maps/small.yaml | 78 | ||||
| -rw-r--r-- | data/recipes/.gitignore | 1 | ||||
| -rw-r--r-- | data/recipes/default.ts (renamed from data/recipes.ts) | 2 | ||||
| -rw-r--r-- | data/recipes/none.ts | 1 | ||||
| -rw-r--r-- | server/Cargo.toml | 2 | ||||
| -rw-r--r-- | server/src/bin/graph.rs | 63 | ||||
| -rw-r--r-- | server/src/customer/mod.rs | 78 | ||||
| -rw-r--r-- | server/src/data.rs | 233 | ||||
| -rw-r--r-- | server/src/game.rs | 52 | ||||
| -rw-r--r-- | server/src/lib.rs | 21 | ||||
| -rw-r--r-- | server/src/main.rs | 42 | ||||
| -rw-r--r-- | server/src/protocol.rs | 16 | ||||
| -rw-r--r-- | server/src/state.rs | 68 | ||||
| -rw-r--r-- | test-client/index.html | 6 | ||||
| -rw-r--r-- | test-client/main.ts | 32 | ||||
| -rw-r--r-- | test-client/protocol.ts | 6 | 
24 files changed, 639 insertions, 227 deletions
| @@ -1,8 +1,6 @@  /target -.vscode/  /test-client/*.js  /specs/*.html -/data/recipes.yaml  .godot  undercooked.pot  *.mo @@ -142,6 +142,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"  checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"  [[package]] +name = "clap" +version = "4.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" + +[[package]]  name = "colorchoice"  version = "1.0.1"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -304,6 +344,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"  checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"  [[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]]  name = "hermit-abi"  version = "0.3.9"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -653,6 +699,12 @@ dependencies = [  ]  [[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]]  name = "signal-hook-registry"  version = "1.4.2"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -687,6 +739,12 @@ dependencies = [  ]  [[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]]  name = "syn"  version = "2.0.67"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -788,6 +846,7 @@ name = "undercooked"  version = "0.1.0"  dependencies = [   "anyhow", + "clap",   "env_logger",   "futures-util",   "glam", @@ -796,6 +855,7 @@ dependencies = [   "serde",   "serde_json",   "serde_yaml", + "shlex",   "tokio",   "tokio-tungstenite",  ] diff --git a/data/demands.yaml b/data/demands/default.yaml index f6be0911..f6be0911 100644 --- a/data/demands.yaml +++ b/data/demands/default.yaml diff --git a/data/demands/none.yaml b/data/demands/none.yaml new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/data/demands/none.yaml diff --git a/data/index.yaml b/data/index.yaml new file mode 100644 index 00000000..e00ff9f2 --- /dev/null +++ b/data/index.yaml @@ -0,0 +1,14 @@ +initial: [null, lobby, null] + +demands: +    - none +    - default + +maps: +    - lobby +    - big +    - small + +recipes: +    - none +    - default diff --git a/data/makefile b/data/makefile new file mode 100644 index 00000000..b5984dd7 --- /dev/null +++ b/data/makefile @@ -0,0 +1,4 @@ + +all: $(patsubst %.ts,%.yaml,$(wildcard recipes/*.ts)) +recipes/%.yaml: recipes/%.ts +	deno run $< > $@ diff --git a/data/map.yaml b/data/maps/big.yaml index 207a4f90..46527a87 100644 --- a/data/map.yaml +++ b/data/maps/big.yaml @@ -16,27 +16,26 @@  map:      - "'''''*'''*'''''*'''*'''*'''*''*'"      - "'''*''''*'*'**'''*''**''**''*'''" -    - "''██v███v███v███v███v███v██v██*'" -    - "''vctc.ctc.ctc.ctc.ctc█⌷....sv**" +    - "''██▒██▒██▒███▒███▒████▒██▒███*'" +    - "''█ctc.ctc.ctc.ctc.ctc█⌷....s█**"      - "''█.....c.............█⌷....s█''" -    - "'*vc...c...████www██dd█⌷.⌷.⌷⌷v*'" -    - "*'█tc.ctc..█⌷.C...ff..d..⌷..H█''" -    - "''█c...c...█⌷.C.....~.d..⌷..R█'*" -    - "*'vc.......w..⌷⌷⌷⌷⌷⌷⌷⌷█⌷.⌷..Tv*'" -    - "'*█tc......w..........d..⌷..F█*'" -    - "''vc.....ct█⌷S⌷S⌷S⌷⌷oo█⌷⌷⌷..Xv*'" -    - "*'██v█dd█v███v██████v███v██v██*'" -    - "''''''__''''''''''''''''''''''''" -    - "*'''''___________________!______" -    - "''''''__________________________" -    - "''''''''''''''''''''''''''''''''" +    - "'*▒c...c...████www██dd█⌷.⌷.⌷⌷█*'" +    - "*'█tc.ctc..█⌷.C...ff..d..⌷..L█''" +    - "''▒c...c...█⌷.C.....~.d..⌷..R█'*" +    - "*'█c.......w..⌷⌷⌷⌷⌷⌷⌷⌷█⌷.⌷..T█*'" +    - "'*▒tc......w..........d..⌷..F█''" +    - "''█c.....ct█⌷S⌷S⌷S⌷⌷oo█⌷⌷⌷..X█*'" +    - "*'████dd██████████▒███████▒███'*" +    - "'''*''__''''''''''''''''''''''''" +    - "*'''*'___________________!______" +    - "'*''''__________________________" +    - "*''*''''''''''''''''''''''''''''"  tiles:      "⌷": counter      "f": counter      "t": table      "w": counter-window -    "v": wall-window      "s": sink      "o": oven      "S": stove @@ -44,7 +43,7 @@ tiles:      "R": raw-steak-crate      "T": tomato-crate      "F": flour-crate -    "H": leek-crate +    "L": leek-crate      "X": trash      "c": chair @@ -56,6 +55,7 @@ tiles:      "_": path      "d": door      "█": wall +    "▒": wall-window  items:      "S": pot diff --git a/data/maps/lobby.yaml b/data/maps/lobby.yaml new file mode 100644 index 00000000..b35d5839 --- /dev/null +++ b/data/maps/lobby.yaml @@ -0,0 +1,55 @@ +# Undercooked - 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/>. +# +map: +    - "'''''*'''*''''" +    - "'''*''''*'*'*'" +    - "''██████████*'" +    - "''█...ctc.c█**" +    - "''█c...c.cT█''" +    - "'*█tc.....c█*'" +    - "*'█c..~..ct█''" +    - "''█......cT█'*" +    - "*'█.c.....c█*'" +    - "'*█cTc..c..█'*" +    - "''█.c..ctc.█*'" +    - "*'██████████*'" +    - "'*'''*'''*''*'" +    - "*''*''**'''**'" + +tiles: +    "t": table +    "T": table +    "s": sink +    "c": chair +    "~": floor +    ".": floor +    "'": grass +    "*": tree +    "█": wall + +items: +    "t": tomato-soup-plate +    "T": bread-slice-plate + +chef_spawn: "~" +customer_spawn: "!" + +walkable: +    - floor +    - chair +    - grass +collider: +    - wall diff --git a/data/maps/small.yaml b/data/maps/small.yaml new file mode 100644 index 00000000..c45a1d19 --- /dev/null +++ b/data/maps/small.yaml @@ -0,0 +1,78 @@ +# Undercooked - 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/>. +# +map: +    - "'''''*'''*'''''*'''*'''*'" +    - "'''*''''*'*'**'''*''**'''" +    - "''████▒████▒████▒████▒█*'" +    - "''█ctc.ctc.ctc.ctc.ctc█**" +    - "''▒.....c.............█''" +    - "'*█c...c...████ww██dd██*'" +    - "*'█tc.ctc..█sCC..f⌷..L█''" +    - "''▒c...c...█........~R█'*" +    - "*'█c.......█⌷⌷⌷⌷⌷⌷⌷..T█*'" +    - "'*█tc......w.........F█'*" +    - "''█c.....ct█⌷oo⌷⌷SSS⌷X█*'" +    - "*'████dd██████▒████▒███*'" +    - "'*''''__'''''''''''''''''" +    - "*'''''___________________" +    - "'''*''__________________!" +    - "'*'''''''''''''''''''''''" + +tiles: +    "⌷": counter +    "f": counter +    "t": table +    "w": counter-window +    "s": sink +    "o": oven +    "S": stove +    "C": cuttingboard +    "R": raw-steak-crate +    "T": tomato-crate +    "F": flour-crate +    "L": leek-crate +    "X": trash + +    "c": chair +    "~": floor +    ".": floor +    "'": grass +    "*": tree +    "!": path +    "_": path +    "d": door +    "▒": wall-window +    "█": wall + +items: +    "S": pot +    "w": plate +    "f": foodprocessor + +chef_spawn: "~" +customer_spawn: "!" + +walkable: +    - door +    - floor +    - chair +    - grass +    - path + +collider: +    - wall +    - wall-window +    - tree diff --git a/data/recipes/.gitignore b/data/recipes/.gitignore new file mode 100644 index 00000000..1e82fc7d --- /dev/null +++ b/data/recipes/.gitignore @@ -0,0 +1 @@ +*.yaml diff --git a/data/recipes.ts b/data/recipes/default.ts index c3c764f6..4efdb16a 100644 --- a/data/recipes.ts +++ b/data/recipes/default.ts @@ -22,7 +22,7 @@ interface Recipe {      tile?: string,      inputs: (string | null)[],      outputs: (string | null)[], -    action: "instant" | "passive" | "active" +    action: "instant" | "passive" | "active" | "demand"      duration?: number      revert_duration?: number,      warn?: boolean diff --git a/data/recipes/none.ts b/data/recipes/none.ts new file mode 100644 index 00000000..3338909f --- /dev/null +++ b/data/recipes/none.ts @@ -0,0 +1 @@ +// This scripts generates no recipes. Interesting, isn't it? diff --git a/server/Cargo.toml b/server/Cargo.toml index 23a34d99..9b954908 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,3 +16,5 @@ tokio-tungstenite = "0.23.1"  futures-util = "0.3.30"  serde_yaml = "0.9.34+deprecated"  rand = "0.9.0-alpha.1" +shlex = "1.3.0" +clap = { version = "4.5.7", features = ["derive"] } diff --git a/server/src/bin/graph.rs b/server/src/bin/graph.rs index aa65e91a..94dddbc4 100644 --- a/server/src/bin/graph.rs +++ b/server/src/bin/graph.rs @@ -1,41 +1,46 @@  /*      Undercooked - 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 anyhow::Result;  use undercooked::{ +    data::DataIndex,      interaction::Recipe, -    load_gamedata,      protocol::{ItemIndex, RecipeIndex},  }; -fn main() { -    let data = load_gamedata(); +fn main() -> Result<()> { +    let mut index = DataIndex::default(); +    index.reload()?;      println!("digraph {{"); -    for i in 0..data.item_names.len() { -        println!("i{i} [label=\"{}\"]", data.item_name(ItemIndex(i))) -    } -    for (RecipeIndex(ri), recipe) in data.recipes() { -        let (kind, color) = match recipe { -            Recipe::Passive { .. } => ("Passive", "#2bc493"), -            Recipe::Active { .. } => ("Active", "#47c42b"), -            Recipe::Instant { .. } => ("Instant", "#5452d8"), -        }; -        println!( +    for rn in &index.recipes { +        let data = index.generate(format!("default-none-{rn}"))?; + +        for i in 0..data.item_names.len() { +            println!("i{i} [label=\"{}\"]", data.item_name(ItemIndex(i))) +        } +        for (RecipeIndex(ri), recipe) in data.recipes() { +            let (kind, color) = match recipe { +                Recipe::Passive { .. } => ("Passive", "#2bc493"), +                Recipe::Active { .. } => ("Active", "#47c42b"), +                Recipe::Instant { .. } => ("Instant", "#5452d8"), +            }; +            println!(              "r{ri} [label=\"{kind}\\non {}\" shape=box color={color:?} fillcolor={color:?} style=filled]",              if let Some(tile) = recipe.tile() {                  data.tile_name(tile) @@ -43,23 +48,25 @@ fn main() {                  "anything"              }          ); -        for ItemIndex(input) in recipe.inputs() { -            println!("i{input} -> r{ri}") -        } -        for ItemIndex(output) in recipe.outputs() { -            println!("r{ri} -> i{output}") +            for ItemIndex(input) in recipe.inputs() { +                println!("i{input} -> r{ri}") +            } +            for ItemIndex(output) in recipe.outputs() { +                println!("r{ri} -> i{output}") +            }          } -    } -    for (di, d) in data.demands.iter().enumerate() { -        let color = "#c4422b"; -        println!( +        for (di, d) in data.demands.iter().enumerate() { +            let color = "#c4422b"; +            println!(              "d{di} [label=\"Demand\\ntakes {}s\" shape=box color={color:?} fillcolor={color:?} style=filled]",              d.duration          ); -        println!("i{} -> d{di}", d.from.0); -        println!("d{di} -> i{}", d.to.0); +            println!("i{} -> d{di}", d.from.0); +            println!("d{di} -> i{}", d.to.0); +        }      }      println!("}}"); +    Ok(())  } diff --git a/server/src/customer/mod.rs b/server/src/customer/mod.rs index 16275227..185133e7 100644 --- a/server/src/customer/mod.rs +++ b/server/src/customer/mod.rs @@ -1,19 +1,19 @@  /*      Undercooked - 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/>. -     +  */  pub mod movement;  mod pathfinding; @@ -22,6 +22,7 @@ use crate::{      data::Gamedata,      game::Game,      protocol::{DemandIndex, ItemIndex, Message, PacketC, PacketS, PlayerID}, +    state::State,  };  use glam::{IVec2, Vec2};  use log::{debug, error}; @@ -39,6 +40,7 @@ use tokio::{  };  struct CustomerManager { +    disabled: bool,      walkable: HashSet<IVec2>,      chairs: HashMap<IVec2, bool>,      items: HashMap<IVec2, ItemIndex>, @@ -77,34 +79,21 @@ struct Customer {      state: CustomerState,  } -pub async fn customer(game: Arc<RwLock<Game>>, mut grx: broadcast::Receiver<PacketC>) { +pub async fn customer(gstate: Arc<RwLock<State>>, mut grx: broadcast::Receiver<PacketC>) {      let mut state = CustomerManager {          customer_id_counter: PlayerID(0),          walkable: Default::default(),          chairs: Default::default(),          items: Default::default(),          customers: Default::default(), +        disabled: true,          demand: DemandState {              data: Gamedata::default(),          },      }; -    let initial = game.write().await.prime_client(PlayerID(-1)); -    for p in initial { -        match p { -            PacketC::Init { data, .. } => { -                state.demand.data = data; -            } -            PacketC::UpdateMap { pos, tile, .. } => { -                let tilename = state.demand.data.tile_name(tile); -                if !state.demand.data.is_tile_colliding(tile) { -                    state.walkable.insert(pos); -                } -                if tilename == "chair" { -                    state.chairs.insert(pos, true); -                } -            } -            _ => (), -        } +    let initial = gstate.write().await.game.prime_client(); +    for packet in initial { +        state.packet(packet);      }      let mut interval = interval(Duration::from_millis(40)); @@ -112,21 +101,25 @@ pub async fn customer(game: Arc<RwLock<Game>>, mut grx: broadcast::Receiver<Pack      loop {          tokio::select! {              packet = grx.recv() => { -                match packet.unwrap() { +                let packet = packet.unwrap(); +                match packet {                      PacketC::PutItem { .. }                      | PacketC::TakeItem { .. }                      | PacketC::SetTileItem { .. } => { -                        let g = game.read().await; -                        update_items(&mut state, &g) +                        let g = gstate.read().await; +                        update_items(&mut state, &g.game)                      },                      _ => ()                  } +                state.packet(packet);              }              _ = interval.tick() => { -                state.tick(&mut packets_out, 0.04); -                for (player,packet) in packets_out.drain(..) { -                    if let Err(e) = game.write().await.packet_in(player, packet) { -                        error!("customer misbehaved: {e}") +                if !state.disabled { +                    state.tick(&mut packets_out, 0.04); +                    for (player,packet) in packets_out.drain(..) { +                        if let Err(e) = gstate.write().await.packet_in(player, packet).await { +                            error!("customer misbehaved: {e}") +                        }                      }                  }              } @@ -134,6 +127,7 @@ pub async fn customer(game: Arc<RwLock<Game>>, mut grx: broadcast::Receiver<Pack      }  } +// TODO very inefficient, please do that incrementally  fn update_items(state: &mut CustomerManager, game: &Game) {      state.items.clear();      for (&pos, tile) in game.tiles() { @@ -150,12 +144,36 @@ impl DemandState {      }      pub fn generate_demand(&self) -> DemandIndex {          // TODO insert sofa magic formula -          DemandIndex(random::<usize>() % self.data.demands.len())      }  }  impl CustomerManager { +    pub fn packet(&mut self, packet: PacketC) { +        match packet { +            PacketC::Data { data } => { +                self.disabled = data.demands.is_empty(); +                self.demand.data = data; +            } +            PacketC::RemovePlayer { id } => { +                self.customers.remove(&id); +            } +            PacketC::UpdateMap { +                tile: pos, +                kind: Some(tile), +                .. +            } => { +                let tilename = self.demand.data.tile_name(tile); +                if !self.demand.data.is_tile_colliding(tile) { +                    self.walkable.insert(pos); +                } +                if tilename == "chair" { +                    self.chairs.insert(pos, true); +                } +            } +            _ => (), +        } +    }      pub fn tick(&mut self, packets_out: &mut Vec<(PlayerID, PacketS)>, dt: f32) {          if self.customers.len() < self.demand.target_customer_count() {              self.customer_id_counter.0 -= 1; diff --git a/server/src/data.rs b/server/src/data.rs index 64509f37..e980ccbd 100644 --- a/server/src/data.rs +++ b/server/src/data.rs @@ -19,9 +19,14 @@ use crate::{      interaction::Recipe,      protocol::{DemandIndex, ItemIndex, RecipeIndex, TileIndex},  }; +use anyhow::{anyhow, bail, Result};  use glam::{IVec2, Vec2};  use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, sync::RwLock}; +use std::{ +    collections::{HashMap, HashSet}, +    fs::File, +    sync::RwLock, +};  #[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]  #[serde(rename_all = "snake_case")] @@ -78,7 +83,9 @@ pub struct Demand {  #[derive(Debug, Clone, Serialize, Deserialize, Default)]  pub struct Gamedata { -    recipes: Vec<Recipe>, +    #[serde(skip)] +    pub recipes: Vec<Recipe>, +    #[serde(skip)]      pub demands: Vec<Demand>,      pub item_names: Vec<String>,      pub tile_names: Vec<String>, @@ -90,105 +97,153 @@ pub struct Gamedata {      pub customer_spawn: Vec2,  } -pub fn build_gamedata( -    recipes_in: Vec<RecipeDecl>, -    map_in: InitialMap, -    demands_in: Vec<DemandDecl>, -) -> Gamedata { -    let item_names = RwLock::new(Vec::new()); -    let tile_names = RwLock::new(Vec::new()); -    let mut recipes = Vec::new(); -    let mut demands = Vec::new(); +#[derive(Debug, Deserialize, Default)] +pub struct DataIndex { +    pub maps: HashSet<String>, +    pub demands: HashSet<String>, +    pub recipes: HashSet<String>, +} + +impl DataIndex { +    pub fn reload(&mut self) -> anyhow::Result<()> { +        *self = serde_yaml::from_reader(File::open("data/index.yaml")?)?; +        Ok(()) +    } + +    pub fn generate(&self, spec: String) -> anyhow::Result<Gamedata> { +        let [demands, map, recipes] = spec +            .split("-") +            .collect::<Vec<_>>() +            .try_into() +            .map_err(|_| anyhow!("data specification malformed"))?; + +        if !self.demands.contains(demands) { +            bail!("unknown demands: {demands:?}"); +        } +        if !self.maps.contains(map) { +            bail!("unknown map: {map:?}"); +        } +        if !self.recipes.contains(recipes) { +            bail!("unknown recipes: {recipes:?}"); +        } + +        let demands_path = format!("data/demands/{demands}.yaml"); +        let map_path = format!("data/maps/{map}.yaml"); +        let recipes_path = format!("data/recipes/{recipes}.yaml"); + +        let demands_in = serde_yaml::from_reader(File::open(demands_path).unwrap()).unwrap(); +        let map_in = serde_yaml::from_reader(File::open(map_path).unwrap()).unwrap(); +        let recipes_in = serde_yaml::from_reader(File::open(recipes_path).unwrap()).unwrap(); + +        Ok(Gamedata::build(recipes_in, map_in, demands_in)?) +    } +} + +impl Gamedata { +    pub fn build( +        recipes_in: Vec<RecipeDecl>, +        map_in: InitialMap, +        demands_in: Vec<DemandDecl>, +    ) -> Result<Self> { +        let item_names = RwLock::new(Vec::new()); +        let tile_names = RwLock::new(Vec::new()); +        let mut recipes = Vec::new(); +        let mut demands = Vec::new(); -    for r in recipes_in { -        let r2 = r.clone(); -        let mut inputs = r -            .inputs -            .into_iter() -            .map(|i| ItemIndex(register(&item_names, i))); -        let mut outputs = r -            .outputs -            .into_iter() -            .map(|o| ItemIndex(register(&item_names, o))); -        let tile = r.tile.map(|t| TileIndex(register(&tile_names, t))); -        match r.action { -            Action::Never => {} -            Action::Passive => recipes.push(Recipe::Passive { -                duration: r.duration.expect("duration for passive missing"), -                warn: r.warn, -                tile, -                revert_duration: r.revert_duration, -                input: inputs.next().expect("passive recipe without input"), -                output: outputs.next(), -            }), -            Action::Active => recipes.push(Recipe::Active { -                duration: r.duration.expect("duration for active missing"), -                tile, -                input: inputs.next().expect("active recipe without input"), -                outputs: [outputs.next(), outputs.next()], -            }), -            Action::Instant => { -                recipes.push(Recipe::Instant { +        for r in recipes_in { +            let r2 = r.clone(); +            let mut inputs = r +                .inputs +                .into_iter() +                .map(|i| ItemIndex(register(&item_names, i))); +            let mut outputs = r +                .outputs +                .into_iter() +                .map(|o| ItemIndex(register(&item_names, o))); +            let tile = r.tile.map(|t| TileIndex(register(&tile_names, t))); +            match r.action { +                Action::Never => {} +                Action::Passive => recipes.push(Recipe::Passive { +                    duration: r.duration.expect("duration for passive missing"), +                    warn: r.warn,                      tile, -                    inputs: [inputs.next(), inputs.next()], +                    revert_duration: r.revert_duration, +                    input: inputs.next().expect("passive recipe without input"), +                    output: outputs.next(), +                }), +                Action::Active => recipes.push(Recipe::Active { +                    duration: r.duration.expect("duration for active missing"), +                    tile, +                    input: inputs.next().expect("active recipe without input"),                      outputs: [outputs.next(), outputs.next()], -                }); +                }), +                Action::Instant => { +                    recipes.push(Recipe::Instant { +                        tile, +                        inputs: [inputs.next(), inputs.next()], +                        outputs: [outputs.next(), outputs.next()], +                    }); +                }              } +            assert_eq!(inputs.next(), None, "{r2:?}"); +            assert_eq!(outputs.next(), None, "{r2:?}");          } -        assert_eq!(inputs.next(), None, "{r2:?}"); -        assert_eq!(outputs.next(), None, "{r2:?}"); -    } -    for d in demands_in { -        demands.push(Demand { -            from: ItemIndex(register(&item_names, d.from)), -            to: ItemIndex(register(&item_names, d.to)), -            duration: d.duration, -        }) -    } +        for d in demands_in { +            demands.push(Demand { +                from: ItemIndex(register(&item_names, d.from)), +                to: ItemIndex(register(&item_names, d.to)), +                duration: d.duration, +            }) +        } -    let mut chef_spawn = Vec2::new(0., 0.); -    let mut customer_spawn = Vec2::new(0., 0.); -    let mut initial_map = HashMap::new(); -    for (y, line) in map_in.map.iter().enumerate() { -        for (x, tile) in line.trim().chars().enumerate() { -            let pos = IVec2::new(x as i32, y as i32); -            if tile == map_in.chef_spawn { -                chef_spawn = pos.as_vec2() + Vec2::splat(0.5); +        let mut chef_spawn = Vec2::new(0., 0.); +        let mut customer_spawn = Vec2::new(0., 0.); +        let mut initial_map = HashMap::new(); +        for (y, line) in map_in.map.iter().enumerate() { +            for (x, tile) in line.trim().chars().enumerate() { +                let pos = IVec2::new(x as i32, y as i32); +                if tile == map_in.chef_spawn { +                    chef_spawn = pos.as_vec2() + Vec2::splat(0.5); +                } +                if tile == map_in.customer_spawn { +                    customer_spawn = pos.as_vec2() + Vec2::splat(0.5); +                } +                let tilename = map_in +                    .tiles +                    .get(&tile) +                    .ok_or(anyhow!("tile {tile} is undefined"))? +                    .clone(); +                let itemname = map_in.items.get(&tile).cloned(); +                let tile = TileIndex(register(&tile_names, tilename)); +                let item = itemname.map(|i| ItemIndex(register(&item_names, i))); +                initial_map.insert(pos, (tile, item));              } -            if tile == map_in.customer_spawn { -                customer_spawn = pos.as_vec2() + Vec2::splat(0.5); -            } -            let tilename = map_in.tiles[&tile].clone(); -            let itemname = map_in.items.get(&tile).cloned(); -            let tile = TileIndex(register(&tile_names, tilename)); -            let item = itemname.map(|i| ItemIndex(register(&item_names, i))); -            initial_map.insert(pos, (tile, item));          } -    } -    let item_names = item_names.into_inner().unwrap(); -    let tile_names = tile_names.into_inner().unwrap(); +        let item_names = item_names.into_inner().unwrap(); +        let tile_names = tile_names.into_inner().unwrap(); -    let tile_collide = tile_names -        .iter() -        .map(|i| !map_in.walkable.contains(i)) -        .collect(); -    let tile_interact = tile_names -        .iter() -        .map(|i| !map_in.collider.contains(i) && !map_in.walkable.contains(i)) -        .collect(); +        let tile_collide = tile_names +            .iter() +            .map(|i| !map_in.walkable.contains(i)) +            .collect(); +        let tile_interact = tile_names +            .iter() +            .map(|i| !map_in.collider.contains(i) && !map_in.walkable.contains(i)) +            .collect(); -    Gamedata { -        demands, -        tile_collide, -        tile_interact, -        recipes, -        initial_map, -        item_names, -        tile_names, -        chef_spawn, -        customer_spawn, +        Ok(Gamedata { +            demands, +            tile_collide, +            tile_interact, +            recipes, +            initial_map, +            item_names, +            tile_names, +            chef_spawn, +            customer_spawn, +        })      }  } diff --git a/server/src/game.rs b/server/src/game.rs index c5eb8c9f..c0a03616 100644 --- a/server/src/game.rs +++ b/server/src/game.rs @@ -67,15 +67,39 @@ pub struct Game {  }  impl Game { -    pub fn new(gamedata: Arc<Gamedata>) -> Self { -        let mut g = Self { -            data: gamedata.clone(), +    pub fn new() -> Self { +        Self { +            data: Gamedata::default().into(),              packet_out: Default::default(),              players: Default::default(),              tiles: Default::default(), -        }; -        for (&p, (tile, item)) in &gamedata.initial_map { -            g.tiles.insert( +        } +    } + +    fn unload(&mut self) { +        for (id, _) in self.players.drain() { +            self.packet_out.push_back(PacketC::RemovePlayer { id }) +        } +        for (pos, _) in self.tiles.drain() { +            self.packet_out.push_back(PacketC::UpdateMap { +                tile: pos, +                kind: None, +                neighbors: [None, None, None, None], +            }) +        } +    } +    pub fn load(&mut self, gamedata: Gamedata) { +        let players = self +            .players +            .iter() +            .map(|(id, p)| (*id, (p.name.to_owned(), p.character))) +            .collect::<HashMap<_, _>>(); + +        self.unload(); + +        self.data = gamedata.into(); +        for (&p, (tile, item)) in &self.data.initial_map { +            self.tiles.insert(                  p,                  Tile {                      kind: *tile, @@ -86,7 +110,12 @@ impl Game {                  },              );          } -        g +        for (id, (name, character)) in players { +            self.packet_in(id, PacketS::Join { name, character }) +                .unwrap(); +        } + +        self.packet_out.extend(self.prime_client());      }      pub fn tiles(&self) -> &HashMap<IVec2, Tile> { @@ -97,10 +126,9 @@ impl Game {          self.packet_out.pop_front()      } -    pub fn prime_client(&self, id: PlayerID) -> Vec<PacketC> { +    pub fn prime_client(&self) -> Vec<PacketC> {          let mut out = Vec::new(); -        out.push(PacketC::Init { -            id, +        out.push(PacketC::Data {              data: self.data.deref().to_owned(),          });          for (&id, player) in &self.players { @@ -125,14 +153,14 @@ impl Game {          }          for (&tile, tdata) in &self.tiles {              out.push(PacketC::UpdateMap { -                pos: tile, +                tile,                  neighbors: [                      self.tiles.get(&(tile + IVec2::NEG_Y)).map(|e| e.kind),                      self.tiles.get(&(tile + IVec2::NEG_X)).map(|e| e.kind),                      self.tiles.get(&(tile + IVec2::Y)).map(|e| e.kind),                      self.tiles.get(&(tile + IVec2::X)).map(|e| e.kind),                  ], -                tile: tdata.kind.clone(), +                kind: Some(tdata.kind.clone()),              });              if let Some(item) = &tdata.item {                  out.push(PacketC::SetTileItem { diff --git a/server/src/lib.rs b/server/src/lib.rs index ac0fbfa4..466defb4 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,33 +1,24 @@  /*      Undercooked - 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 data::{build_gamedata, Gamedata}; -use std::fs::File; +*/ +#![feature(if_let_guard)]  pub mod customer;  pub mod data;  pub mod game;  pub mod interaction;  pub mod protocol; - -pub fn load_gamedata() -> Gamedata { -    build_gamedata( -        serde_yaml::from_reader(File::open("data/recipes.yaml").unwrap()).unwrap(), -        serde_yaml::from_reader(File::open("data/map.yaml").unwrap()).unwrap(), -        serde_yaml::from_reader(File::open("data/demands.yaml").unwrap()).unwrap(), -    ) -} +pub mod state; diff --git a/server/src/main.rs b/server/src/main.rs index aeda9c2f..6773bf29 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,19 +1,19 @@  /*      Undercooked - 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 anyhow::Result;  use futures_util::{SinkExt, StreamExt}; @@ -23,14 +23,13 @@ use tokio::{      net::TcpListener,      spawn,      sync::{broadcast, mpsc::channel, RwLock}, -    time::sleep, +    time::interval,  };  use tokio_tungstenite::tungstenite::Message;  use undercooked::{      customer::customer, -    game::Game, -    load_gamedata,      protocol::{PacketC, PacketS, PlayerID}, +    state::State,  };  #[tokio::main] @@ -39,29 +38,22 @@ async fn main() -> Result<()> {      let ws_listener = TcpListener::bind("0.0.0.0:27032").await?;      info!("listening for websockets on {}", ws_listener.local_addr()?); -    let data = load_gamedata(); -    let game = Arc::new(RwLock::new(Game::new(data.into())));      let (tx, rx) = broadcast::channel::<PacketC>(1024); +    let state = Arc::new(RwLock::new(State::new(tx)?));      { -        let game = game.clone(); +        let state = state.clone();          spawn(async move {              let dt = 1. / 25.; +            let mut tick = interval(Duration::from_secs_f32(dt));              loop { -                { -                    let mut g = game.write().await; -                    g.tick(dt); -                    while let Some(p) = g.packet_out() { -                        debug!("-> {p:?}"); -                        let _ = tx.send(p); -                    } -                } -                sleep(Duration::from_secs_f32(dt)).await; +                tick.tick().await; +                state.write().await.tick(dt).await;              }          });      } -    spawn(customer(game.clone(), rx.resubscribe())); +    spawn(customer(state.clone(), rx.resubscribe()));      for id in (1..).map(PlayerID) {          let (sock, addr) = ws_listener.accept().await?; @@ -70,11 +62,12 @@ async fn main() -> Result<()> {              continue;          };          let (mut write, mut read) = sock.split(); -        let game = game.clone(); +        let state = state.clone();          let mut rx = rx.resubscribe();          let (error_tx, mut error_rx) = channel::<PacketC>(8);          info!("{addr} connected via ws"); -        let init = game.write().await.prime_client(id); +        let mut init = state.write().await.game.prime_client(); +        init.insert(0, PacketC::Init { id });          spawn(async move {              for p in init {                  if let Err(e) = write @@ -114,7 +107,7 @@ async fn main() -> Result<()> {                              break;                          };                          debug!("<- {id:?} {packet:?}"); -                        if let Err(e) = game.write().await.packet_in(id, packet) { +                        if let Err(e) = state.write().await.packet_in(id, packet).await {                              warn!("client error: {e}");                              let _ = error_tx                                  .send(PacketC::Error { @@ -127,7 +120,8 @@ async fn main() -> Result<()> {                      _ => (),                  }              } -            let _ = game.write().await.packet_in(id, PacketS::Leave); +            info!("{id:?} left"); +            state.write().await.packet_in(id, PacketS::Leave).await.ok();          });      }      Ok(()) diff --git a/server/src/protocol.rs b/server/src/protocol.rs index 8690febf..262fad8d 100644 --- a/server/src/protocol.rs +++ b/server/src/protocol.rs @@ -1,19 +1,19 @@  /*      Undercooked - 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 crate::data::Gamedata;  use glam::{IVec2, Vec2}; @@ -77,9 +77,11 @@ pub enum Message {  #[serde(rename_all = "snake_case", tag = "type")]  pub enum PacketC {      Init { -        data: Gamedata,          id: PlayerID,      }, +    Data { +        data: Gamedata, +    },      AddPlayer {          id: PlayerID,          position: Vec2, @@ -116,8 +118,8 @@ pub enum PacketC {          warn: bool,      },      UpdateMap { -        pos: IVec2, -        tile: TileIndex, +        tile: IVec2, +        kind: Option<TileIndex>,          neighbors: [Option<TileIndex>; 4],      },      Collide { diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 00000000..98f92a24 --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,68 @@ +use crate::{ +    data::DataIndex, +    game::Game, +    protocol::{Message, PacketC, PacketS, PlayerID}, +}; +use anyhow::{anyhow, Result}; +use clap::Parser; +use log::debug; +use tokio::sync::broadcast::Sender; + +pub struct State { +    index: DataIndex, +    tx: Sender<PacketC>, +    pub game: Game, +} + +#[derive(Parser)] +#[clap(multicall = true)] +enum Command { +    Start { spec: String }, +} + +impl State { +    pub fn new(tx: Sender<PacketC>) -> Result<Self> { +        let mut index = DataIndex::default(); +        index.reload()?; + +        let mut game = Game::new(); +        game.load(index.generate("none-lobby-none".to_string())?); + +        Ok(Self { game, index, tx }) +    } + +    pub async fn tick(&mut self, dt: f32) { +        self.game.tick(dt); +        while let Some(p) = self.game.packet_out() { +            debug!("-> {p:?}"); +            let _ = self.tx.send(p); +        } +    } +    pub async fn packet_in(&mut self, player: PlayerID, packet: PacketS) -> Result<()> { +        match &packet { +            PacketS::Communicate { +                message: Some(Message::Text(message)), +            } if let Some(command) = message.strip_prefix("/") => { +                self.handle_command(Command::try_parse_from( +                    shlex::split(command) +                        .ok_or(anyhow!("quoting invalid"))? +                        .into_iter(), +                )?) +                .await?; +                return Ok(()); +            } +            _ => (), +        } +        self.game.packet_in(player, packet)?; +        Ok(()) +    } + +    async fn handle_command(&mut self, command: Command) -> Result<()> { +        match command { +            Command::Start { spec } => { +                self.game.load(self.index.generate(spec)?); +            } +        } +        Ok(()) +    } +} diff --git a/test-client/index.html b/test-client/index.html index d1cd7133..63d02f9e 100644 --- a/test-client/index.html +++ b/test-client/index.html @@ -31,6 +31,12 @@              noscript {                  color: white;              } +            input[type="text"] { +                position: absolute; +                top: 0px; +                left: 0px; +                font-size: 30px; +            }          </style>      </head>      <body> diff --git a/test-client/main.ts b/test-client/main.ts index 69a4ff69..0f841b06 100644 --- a/test-client/main.ts +++ b/test-client/main.ts @@ -92,7 +92,6 @@ export const items_removed = new Set<ItemData>()  export let data: Gamedata = { item_names: [], tile_names: [], spawn: [0, 0], tile_collide: [], tile_interact: [] } -  export let my_id: PlayerID = -1  export const camera: V2 = { x: 0, y: 0 }  export const interact_target_anim: V2 = { x: 0, y: 0 } @@ -107,6 +106,8 @@ function packet(p: PacketC) {      switch (p.type) {          case "init":              my_id = p.id +            break; +        case "data":              data = p.data              break;          case "add_player": { @@ -172,7 +173,8 @@ function packet(p: PacketC) {              break;          }          case "update_map": -            tiles.set(p.pos.toString(), { x: p.pos[0], y: p.pos[1], kind: p.tile }) +            if (p.kind !== undefined && p.kind !== null) tiles.set(p.tile.toString(), { x: p.tile[0], y: p.tile[1], kind: p.kind }) +            else tiles.delete(p.tile.toString())              break;          case "communicate": {              const player = players.get(p.player)! @@ -188,9 +190,14 @@ function packet(p: PacketC) {      }  } +export let chat: null | HTMLInputElement = null; +  export const keys_down = new Set();  const HANDLED_KEYS = ["KeyW", "KeyA", "KeyS", "KeyD", "Space"]  function keyboard(ev: KeyboardEvent, down: boolean) { +    if (down && ev.code == "Enter") return toggle_chat() +    else if (down && ev.code == "Escape" && chat) return close_chat() +    else if (chat) return      if (HANDLED_KEYS.includes(ev.code)) ev.preventDefault()      if (!keys_down.has("Space") && ev.code == "Space" && down) set_interact(true)      if (keys_down.has("Space") && ev.code == "Space" && !down) set_interact(false) @@ -198,6 +205,27 @@ function keyboard(ev: KeyboardEvent, down: boolean) {      else keys_down.delete(ev.code)  } +function close_chat() { +    if (!chat) return +    chat.remove() +    canvas.focus() +    chat = null; +} +function toggle_chat() { +    if (chat) { +        if (chat.value.length) send({ type: "communicate", message: { text: chat.value } }) +        chat.remove() +        canvas.focus() +        chat = null; +    } else { +        chat = document.createElement("input") +        chat.type = "text" +        chat.placeholder = "Message" +        document.body.append(chat) +        chat.focus() +    } +} +  export function get_interact_target(): V2 | undefined {      if (interacting) return interacting      const me = players.get(my_id) diff --git a/test-client/protocol.ts b/test-client/protocol.ts index 48a1cca7..f4ffba01 100644 --- a/test-client/protocol.ts +++ b/test-client/protocol.ts @@ -32,10 +32,12 @@ export type PacketS =      { type: "join", name: string, character: number } // You join, sent as first packet.      | { type: "position", pos: Vec2, rot: number } // Update your position and rotation in radians (0 is -y)      | { type: "interact", pos: Vec2, edge: boolean } // Interact with some tile. edge is true when pressing and false when releasing interact button +    | { type: "communicate", message: Message } // Send a message      | { type: "collide", player: PlayerID, force: Vec2 } // Apply force to another player as a result of a collision  export type PacketC = -    { type: "init", id: PlayerID, data: Gamedata } // You joined +    { type: "init", id: PlayerID } // You just connected. This is your id for this session. +    | { type: "data", data: Gamedata } // Game data was changed      | { type: "add_player", id: PlayerID, name: string, position: Vec2, character: number } // Somebody else joined (or was already in the game)      | { type: "remove_player", id: PlayerID }  // Somebody left      | { type: "position", player: PlayerID, pos: Vec2, rot: number } // Update the position of a players (your own position is included here) @@ -44,7 +46,7 @@ export type PacketC =      | { type: "set_tile_item", tile: Vec2, item?: ItemIndex } // A tile changed its item      | { type: "set_player_item", player: PlayerID, item?: ItemIndex } // A player changed their item      | { type: "set_active", tile: Vec2, progress?: number, warn: boolean } // A tile is doing something. progress goes from 0 to 1, then null when finished -    | { type: "update_map", pos: Vec2, tile: TileIndex, neighbors: [TileIndex | null] } // A map tile was changed +    | { type: "update_map", tile: Vec2, kind: TileIndex | null, neighbors: [TileIndex | null] } // A map tile was changed      | { type: "communicate", player: PlayerID, message?: Message } // A player wants to communicate something, message is null when cleared      | { type: "error", message?: Message } // Your client did something wrong. | 
