diff --git a/overcooked_simulator/__init__.py b/overcooked_simulator/__init__.py index c5d7d61bd0acf0412b5affe0069c97bbab98bf2e..05cb843178a2897e655d7e11e372f81e7d57d43f 100644 --- a/overcooked_simulator/__init__.py +++ b/overcooked_simulator/__init__.py @@ -32,6 +32,8 @@ python overcooked_simulator/main.py ## Direct integration into your code. Initialize an environment.... +**TODO** JSON State description. + # Citation diff --git a/overcooked_simulator/counters.py b/overcooked_simulator/counters.py index 2cbbeb4d048a7d113c784ea1792103cdda1bdba1..b20ff3f07d12126d84e2150adec4e19b2e61a606 100644 --- a/overcooked_simulator/counters.py +++ b/overcooked_simulator/counters.py @@ -19,6 +19,7 @@ Stove: """ from __future__ import annotations +import dataclasses import logging from collections import deque from datetime import datetime, timedelta @@ -272,6 +273,18 @@ class Dispenser(Counter): return Item(**kwargs) +@dataclasses.dataclass +class PlateConfig: + """Configure the initial and behavior of the plates in the environment.""" + + clean_plates: int = 0 + """clean plates at the start.""" + dirty_plates: int = 3 + """dirty plates at the start.""" + plate_delay: list[int, int] = dataclasses.field(default_factory=lambda: [5, 10]) + """The uniform sampling range for the plate delay between serving and return.""" + + class PlateDispenser(Counter): """At the moment, one and only one plate dispenser must exist in an environment, because only at one place the dirty plates should arrive. @@ -289,14 +302,13 @@ class PlateDispenser(Counter): """ def __init__( - self, pos, dispensing, plate_config, plate_transitions, **kwargs + self, pos, dispensing, plate_config: PlateConfig, plate_transitions, **kwargs ) -> None: super().__init__(pos, **kwargs) self.dispensing = dispensing self.occupied_by = deque() self.out_of_kitchen_timer = [] - self.plate_config = {"plate_delay": [5, 10]} - self.plate_config.update(plate_config) + self.plate_config = plate_config self.next_plate_time = datetime.max self.plate_transitions = plate_transitions self.setup_plates() @@ -325,8 +337,8 @@ class PlateDispenser(Counter): # not perfect identical to datetime.now but based on framerate enough. time_plate_to_add = env_time + timedelta( seconds=np.random.uniform( - low=self.plate_config["plate_delay"][0], - high=self.plate_config["plate_delay"][1], + low=self.plate_config.plate_delay[0], + high=self.plate_config.plate_delay[1], ) ) log.debug(f"New plate out of kitchen until {time_plate_to_add}") @@ -336,15 +348,17 @@ class PlateDispenser(Counter): def setup_plates(self): """Create plates based on the config. Clean and dirty ones.""" - if "dirty_plates" in self.plate_config: + if self.plate_config.dirty_plates > 0: + log.info(f"Setup {self.plate_config.dirty_plates} dirty plates.") self.occupied_by.extend( - [self.create_item() for _ in range(self.plate_config["dirty_plates"])] + [self.create_item() for _ in range(self.plate_config.dirty_plates)] ) - if "clean_plates" in self.plate_config: + if self.plate_config.clean_plates > 0: + log.info(f"Setup {self.plate_config.dirty_plates} clean plates.") self.occupied_by.extend( [ self.create_item(clean=True) - for _ in range(self.plate_config["clean_plates"]) + for _ in range(self.plate_config.clean_plates) ] ) diff --git a/overcooked_simulator/gui_2d_vis/__init__.py b/overcooked_simulator/gui_2d_vis/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..3afb0b56bf80f430bf71e2d165e46f4e396f1fe7 100644 --- a/overcooked_simulator/gui_2d_vis/__init__.py +++ b/overcooked_simulator/gui_2d_vis/__init__.py @@ -0,0 +1,26 @@ +""" +2D visualization of the overcooked simulator. + +You can select the layout and start an environment: + +You can play the overcooked simulator. You can quit the application in the top right or end the level in the bottom right. + + +The orders are pictured in the top, the current score in the bottom left and the remaining time in the bottom. + + +The final screen after ending a level shows the score: + + +The keys for the control of the players are: + +Player 1: +- Movement: `W`, `A`, `S`, `D`, +- Pickup: `E` +- Interact: `F` + +Player 2: +- Movement: `⬆`, `⬅`, `⬇`, `➡` (arrow keys) +- Pickup: `I` +- Interact: `SPACE` +""" diff --git a/overcooked_simulator/gui_2d_vis/images/overcooked-end-screen.png b/overcooked_simulator/gui_2d_vis/images/overcooked-end-screen.png new file mode 100644 index 0000000000000000000000000000000000000000..d678687d61277930d839882aa5f6956caeb5e9a6 Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/overcooked-end-screen.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/overcooked-level-screen.png b/overcooked_simulator/gui_2d_vis/images/overcooked-level-screen.png new file mode 100644 index 0000000000000000000000000000000000000000..0f2cc384aacabcd80d3b88542fd9a3345506b35d Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/overcooked-level-screen.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/overcooked-start-screen.png b/overcooked_simulator/gui_2d_vis/images/overcooked-start-screen.png new file mode 100644 index 0000000000000000000000000000000000000000..420d1eedb6cf57a493aba7dbabe7e3ba80ecb1b4 Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/overcooked-start-screen.png differ diff --git a/overcooked_simulator/gui_2d_vis/overcooked_gui.py b/overcooked_simulator/gui_2d_vis/overcooked_gui.py index 0e7db7ccea58611bfa917562639d4b9dfcae5c68..7c3f4d0ca7bd781373c4d3d990a0064aaab8e45a 100644 --- a/overcooked_simulator/gui_2d_vis/overcooked_gui.py +++ b/overcooked_simulator/gui_2d_vis/overcooked_gui.py @@ -23,7 +23,11 @@ from overcooked_simulator.game_items import ( from overcooked_simulator.gui_2d_vis.game_colors import BLUE from overcooked_simulator.gui_2d_vis.game_colors import colors, Color from overcooked_simulator.order import Order -from overcooked_simulator.overcooked_environment import Action +from overcooked_simulator.overcooked_environment import ( + Action, + ActionType, + InterActionData, +) from overcooked_simulator.simulation_runner import Simulator USE_PLAYER_COOK_SPRITES = True @@ -224,7 +228,7 @@ class PyGameGUI: if np.linalg.norm(move_vec) != 0: move_vec = move_vec / np.linalg.norm(move_vec) - action = Action(key_set.name, "movement", move_vec) + action = Action(key_set.name, ActionType.MOVEMENT, move_vec) self.send_action(action) def handle_key_event(self, event): @@ -237,15 +241,19 @@ class PyGameGUI: """ for key_set in self.player_key_sets: if event.key == key_set.pickup_key and event.type == pygame.KEYDOWN: - action = Action(key_set.name, "pickup", "pickup") + action = Action(key_set.name, ActionType.PICKUP, "pickup") self.send_action(action) if event.key == key_set.interact_key: if event.type == pygame.KEYDOWN: - action = Action(key_set.name, "interact", "keydown") + action = Action( + key_set.name, ActionType.INTERACT, InterActionData.START + ) self.send_action(action) elif event.type == pygame.KEYUP: - action = Action(key_set.name, "interact", "keyup") + action = Action( + key_set.name, ActionType.INTERACT, InterActionData.STOP + ) self.send_action(action) def draw_background(self): diff --git a/overcooked_simulator/order.py b/overcooked_simulator/order.py index 54c7ef1e7a8b9948df220fe28fa17376c992e7cf..ae3447aa830f7ff4e5f71970f8cc90d2ffb266e9 100644 --- a/overcooked_simulator/order.py +++ b/overcooked_simulator/order.py @@ -1,4 +1,22 @@ -"""""" +""" +You can configure the order creation/generation via the `environment_config.yml`. + +It is very configurable by letting you reference own Python classes and functions. + +```yaml +orders: + serving_not_ordered_meals: null + order_gen_class: !!python/name:overcooked_simulator.order.RandomOrderGeneration '' + order_gen_kwargs: + ... +``` + +For the `serving_not_ordered_meals` is a function expected. It received a meal as an argument and should return a +tuple of a bool and the score. If the bool is true, the score will be added to the score. Otherwise, it will not +accept the meal for serving. + +The `order_gen_class` should be a child of the `OrderGeneration` class. The `order_gen_kwargs` depend then on your +class referenced.""" from __future__ import annotations import dataclasses @@ -370,7 +388,7 @@ class OrderAndScoreManager: self.order_gen: OrderGeneration = order_config["order_gen_class"]( available_meals=available_meals, kwargs=order_config["order_gen_kwargs"] ) - self.kwargs_for_func = order_config["kwargs"] + self.kwargs_for_func = order_config["order_gen_kwargs"] self.serving_not_ordered_meals = order_config["serving_not_ordered_meals"] self.available_meals = available_meals self.open_orders: Deque[Order] = deque() diff --git a/overcooked_simulator/overcooked_environment.py b/overcooked_simulator/overcooked_environment.py index f4bd59de451d5f02b9d1f99ab24c13490f7b7b40..9ec02b2bfa990b885344f2f8db8ea66c6378cedf 100644 --- a/overcooked_simulator/overcooked_environment.py +++ b/overcooked_simulator/overcooked_environment.py @@ -1,10 +1,13 @@ from __future__ import annotations +import dataclasses import logging import random from datetime import timedelta +from enum import Enum from pathlib import Path from threading import Lock +from typing import Literal import numpy as np import numpy.typing as npt @@ -21,6 +24,7 @@ from overcooked_simulator.counters import ( Sink, PlateDispenser, SinkAddon, + PlateConfig, ) from overcooked_simulator.game_items import ( ItemInfo, @@ -28,27 +32,45 @@ from overcooked_simulator.game_items import ( CookingEquipment, ) from overcooked_simulator.order import OrderAndScoreManager -from overcooked_simulator.player import Player +from overcooked_simulator.player import Player, PlayerConfig from overcooked_simulator.utils import create_init_env_time log = logging.getLogger(__name__) +class ActionType(Enum): + """The 3 different types of valid actions. They can be extended via the `Action.action_data` attribute.""" + + MOVEMENT = "movement" + """move the agent.""" + PICKUP = "pickup" + """interaction type 1, e.g., for pickup or drop off.""" + INTERACT = "interact" + """interaction type 2, e.g., for progressing. Start and stop interaction via `keydown` and `keyup` actions.""" + + +class InterActionData(Enum): + """The data for the interaction action: `ActionType.MOVEMENT`.""" + + START = "keydown" + "start an interaction." + STOP = "keyup" + "stop an interaction without moving away." + + +@dataclasses.dataclass class Action: """Action class, specifies player, action type and action itself.""" - def __init__(self, player, act_type, action): - self.player = player - self.act_type = act_type - assert self.act_type in [ - "movement", - "pickup", - "interact", - ], "Unknown action type" - self.action = action + player: str + """Id of the player.""" + action_type: ActionType + """Type of the action to perform. Defines what action data is valid.""" + action_data: npt.NDArray[float] | InterActionData | Literal["pickup"] + """Data for the action, e.g., movement vector or start and stop interaction.""" def __repr__(self): - return f"Action({self.player},{self.act_type},{self.action})" + return f"Action({self.player},{self.action_type.value},{self.action_data})" class Environment: @@ -123,9 +145,13 @@ class Environment: plate_transitions=plate_transitions, pos=pos, dispensing=self.item_info["Plate"], - plate_config=self.environment_config["plates"] - if "plates" in self.environment_config - else {}, + plate_config=PlateConfig( + **( + self.environment_config["plates"] + if "plates" in self.environment_config + else {} + ) + ), ), "N": lambda pos: Dispenser(pos, self.item_info["Onion"]), # N for oNioN "_": "Free", @@ -187,6 +213,8 @@ class Environment: self.init_counters() self.env_time = create_init_env_time() + """the internal time of the environment. An environment starts always with the time from + `create_init_env_time`.""" self.order_and_score.create_init_orders(self.env_time) self.beginning_time = self.env_time self.env_time_end = self.env_time + timedelta( @@ -195,6 +223,9 @@ class Environment: log.debug(f"End time: {self.env_time_end}") def get_env_time(self): + """the internal time of the environment. An environment starts always with the time from `create_init_env_time`. + + Utility method to pass a reference to the serving window.""" return self.env_time @property @@ -202,6 +233,7 @@ class Environment: return self.env_time >= self.env_time_end def load_item_info(self) -> dict[str, ItemInfo]: + """Load `item_info.yml`, create ItemInfo classes and replace equipment strings with item infos.""" with open(self.item_info_path, "r") as file: item_lookup = yaml.safe_load(file) for item_name in item_lookup: @@ -213,6 +245,7 @@ class Environment: return item_lookup def validate_item_info(self): + """TODO""" pass # infos = {t: [] for t in ItemType} # graph = nx.DiGraph() @@ -325,22 +358,22 @@ class Environment: assert action.player in self.players.keys(), "Unknown player." player = self.players[action.player] - if action.act_type == "movement": + if action.action_type == ActionType.MOVEMENT: with self.lock: - self.perform_movement(player, action.action) + self.perform_movement(player, action.action_data) else: counter = self.get_facing_counter(player) if player.can_reach(counter): - if action.act_type == "pickup": + if action.action_type == ActionType.PICKUP: with self.lock: player.pick_action(counter) - elif action.act_type == "interact": - if action.action == "keydown": + elif action.action_type == ActionType.INTERACT: + if action.action_data == InterActionData.START: player.perform_interact_hold_start(counter) player.last_interacted_counter = counter - if action.action == "keyup": + if action.action_data == InterActionData.STOP: if player.last_interacted_counter: with self.lock: player.perform_interact_hold_stop( @@ -464,7 +497,7 @@ class Environment: other_players = filter(lambda p: p.name != player.name, self.players.values()) def collide(p): - return np.linalg.norm(player.pos - p.pos) <= (player.radius) + (p.radius) + return np.linalg.norm(player.pos - p.pos) <= player.radius + p.radius return list(filter(collide, other_players)) @@ -482,7 +515,7 @@ class Environment: other_players = filter(lambda p: p.name != player.name, self.players.values()) def collide(p): - return np.linalg.norm(player.pos - p.pos) <= ((player.radius) + (p.radius)) + return np.linalg.norm(player.pos - p.pos) <= (player.radius + p.radius) return any(map(collide, other_players)) @@ -502,7 +535,8 @@ class Environment: ) ) - def detect_collision_player_counter(self, player: Player, counter: Counter): + @staticmethod + def detect_collision_player_counter(player: Player, counter: Counter): """Checks if the player and counter collide (overlap). A counter is modelled as a rectangle (square actually), a player is modelled as a circle. The distance of the player position (circle center) and the counter rectangle is calculated, if it is @@ -520,12 +554,26 @@ class Environment: dx = max(np.abs(cx - counter.pos[0]) - 1 / 2, 0) dy = max(np.abs(cy - counter.pos[1]) - 1 / 2, 0) distance = np.linalg.norm([dx, dy]) - return distance < (player.radius) + return distance < player.radius def add_player(self, player_name: str, pos: npt.NDArray = None): + """Add a player to the environment. + + Args: + player_name: The id/name of the player to reference actions and in the state. + pos: The optional init position of the player. + """ log.debug(f"Add player {player_name} to the game") player = Player( - player_name, player_config=self.environment_config["player_config"], pos=pos + player_name, + player_config=PlayerConfig( + **( + self.environment_config["player_config"] + if "player_config" in self.environment_config + else {} + ) + ), + pos=pos, ) self.players[player.name] = player if player.pos is None: @@ -592,6 +640,11 @@ class Environment: pass def init_counters(self): + """Initialize the counters in the environment. + + Connect the `ServingWindow`(s) with the `PlateDispenser`. + Find and connect the `SinkAddon`s with the `Sink`s + """ plate_dispenser = self.get_counter_of_type(PlateDispenser) assert len(plate_dispenser) > 0, "No Plate Return in the environment" @@ -600,8 +653,10 @@ class Environment: for counter in self.counters: match counter: case ServingWindow(): + counter: ServingWindow # Pycharm type checker does now work for match statements? counter.add_plate_dispenser(plate_dispenser[0]) case Sink(pos=pos): + counter: Sink # Pycharm type checker does now work for match statements? assert len(sink_addons) > 0, "No SinkAddon but normal Sink" closest_addon = self.get_closest(pos, sink_addons) assert 1 - (1 * 0.05) <= np.linalg.norm( @@ -609,17 +664,23 @@ class Environment: ), f"No SinkAddon connected to Sink at pos {pos}" counter.set_addon(closest_addon) - pass - @staticmethod - def get_closest(pos: npt.NDArray[float], counter: list[Counter]): - return min(counter, key=lambda c: np.linalg.norm(c.pos - pos)) + def get_closest(pos: npt.NDArray[float], counters: list[Counter]): + """Find the closest counter for a position + + Args: + pos: the position to find the closest one from. Needs to be the same shape as the `Counter.pos` array. + counters: target to find the closest one. + """ + return min(counters, key=lambda c: np.linalg.norm(c.pos - pos)) def get_counter_of_type(self, counter_type) -> list[Counter]: + """Filter all counters in the environment for a counter type.""" return list( filter(lambda counter: isinstance(counter, counter_type), self.counters) ) def reset_env_time(self): + """Reset the env time to the initial time, defined by `create_init_env_time`.""" self.env_time = create_init_env_time() log.debug(f"Reset env time to {self.env_time}") diff --git a/overcooked_simulator/player.py b/overcooked_simulator/player.py index 63f803c86a1c2575c2cf2a8b8248e8c0a62d116a..6ff77cb692814c8ceebb5dfb0861306f8d9c5984 100644 --- a/overcooked_simulator/player.py +++ b/overcooked_simulator/player.py @@ -1,6 +1,17 @@ +"""The player contains the logic which method to call on counters and items for a pick action: + +If the player holds nothing, it picks up the content from the counter. + +If the item the player holds can be dropped on the counter it will do so. + +If the counter is not a sink or plate dispenser, it checks if it can combine the content on the counter with the +holding object. If so, it picks up the content and combines it on its hands. +""" + +import dataclasses import logging from collections import deque -from typing import Optional, Any +from typing import Optional import numpy as np import numpy.typing as npt @@ -11,6 +22,18 @@ from overcooked_simulator.game_items import Item, Plate log = logging.getLogger(__name__) +@dataclasses.dataclass +class PlayerConfig: + """Configure the player attributes in the `environment.yml`.""" + + radius: float = 0.4 + """The size of the player. The size of a counter is 1""" + move_dist: float = 0.15 + """The move distance/speed of the player per action call.""" + interaction_range: float = 1.6 + """How far player can interact with counters.""" + + class Player: """Class representing a player in the game environment. A player consists of a name, their position and what the player is currently holding in the hands. @@ -21,11 +44,10 @@ class Player: def __init__( self, name: str, - player_config: dict[str, Any], + player_config: PlayerConfig, pos: Optional[npt.NDArray[float]] = None, ): self.name: str = name - self.player_config = player_config if pos is not None: self.pos: npt.NDArray[float] = np.array(pos, dtype=float) else: @@ -33,10 +55,14 @@ class Player: self.holding: Optional[Item] = None - self.radius: float = self.player_config["radius"] - self.move_dist: int = self.player_config["move_dist"] - self.interaction_range: int = self.player_config["interaction_range"] + self.radius: float = player_config.radius + """See `PlayerConfig.radius`.""" + self.move_dist: float = player_config.move_dist + """See `PlayerConfig.move_dist`.""" + self.interaction_range: float = player_config.interaction_range + """See `PlayerConfig.interaction_range`.""" self.facing_direction: npt.NDArray[float] = np.array([0, 1]) + """Current direction the player looks.""" self.last_interacted_counter: Optional[ Counter ] = None # needed to stop progress when moved away @@ -116,7 +142,8 @@ class Player: if isinstance(self.holding, Plate): log.debug(self.holding.clean) - def perform_interact_hold_start(self, counter: Counter): + @staticmethod + def perform_interact_hold_start(counter: Counter): """Starts an interaction with the counter. Should be called for a keydown event, for holding down a key on the keyboard. @@ -125,7 +152,8 @@ class Player: """ counter.interact_start() - def perform_interact_hold_stop(self, counter: Counter): + @staticmethod + def perform_interact_hold_stop(counter: Counter): """Stops an interaction with the counter. Should be called for a keyup event, for letting go of a keyboard key. diff --git a/overcooked_simulator/utils.py b/overcooked_simulator/utils.py index 4e4ab7adac96f14999878ac6acd442f2bc8e03b5..dfb5da0068a533134ad72e85bf14289d295cc585 100644 --- a/overcooked_simulator/utils.py +++ b/overcooked_simulator/utils.py @@ -2,6 +2,7 @@ from datetime import datetime def create_init_env_time(): + """Init time of the environment time, because all environments should have the same internal time.""" return datetime( year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0 ) diff --git a/tests/test_start.py b/tests/test_start.py index d6010a9e0e373c12673601c8f6a99c55651296d0..afc0dfca16930bc32c5a101dd6f798780b9b7499 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -7,7 +7,12 @@ import pytest from overcooked_simulator import ROOT_DIR from overcooked_simulator.counters import Counter, CuttingBoard from overcooked_simulator.game_items import Item -from overcooked_simulator.overcooked_environment import Action, Environment +from overcooked_simulator.overcooked_environment import ( + Action, + Environment, + ActionType, + InterActionData, +) from overcooked_simulator.simulation_runner import Simulator from overcooked_simulator.utils import create_init_env_time @@ -89,7 +94,7 @@ def test_movement(): start_pos = np.array([1, 2]) sim.register_player(player_name, start_pos) move_direction = np.array([1, 0]) - move_action = Action(player_name, "movement", move_direction) + move_action = Action(player_name, ActionType.MOVEMENT, move_direction) do_moves_number = 6 for i in range(do_moves_number): sim.enter_action(move_action) @@ -157,7 +162,7 @@ def test_player_reach(): do_moves_number = 30 for i in range(do_moves_number): - move_action = Action("p1", "movement", np.array([0, -1])) + move_action = Action("p1", ActionType.MOVEMENT, np.array([0, -1])) sim.enter_action(move_action) assert player.can_reach(counter), "Player can reach counter?" @@ -177,9 +182,9 @@ def test_pickup(): sim.register_player("p1", np.array([2, 3])) player = sim.env.players["p1"] - move_down = Action("p1", "movement", np.array([0, -1])) - move_up = Action("p1", "movement", np.array([0, 1])) - pick = Action("p1", "pickup", "pickup") + move_down = Action("p1", ActionType.MOVEMENT, np.array([0, -1])) + move_up = Action("p1", ActionType.MOVEMENT, np.array([0, 1])) + pick = Action("p1", ActionType.PICKUP, "pickup") sim.enter_action(move_down) assert player.can_reach(counter), "Player can reach counter?" @@ -232,13 +237,13 @@ def test_processing(): player = sim.env.players["p1"] player.holding = tomato - move = Action("p1", "movement", np.array([0, -1])) - pick = Action("p1", "pickup", "pickup") + move = Action("p1", ActionType.MOVEMENT, np.array([0, -1])) + pick = Action("p1", ActionType.PICKUP, "pickup") sim.enter_action(move) sim.enter_action(pick) - hold_down = Action("p1", "interact", "keydown") + hold_down = Action("p1", ActionType.INTERACT, InterActionData.START) sim.enter_action(hold_down) assert tomato.name != "ChoppedTomato", "Tomato is not finished yet." @@ -247,7 +252,7 @@ def test_processing(): assert tomato.name == "ChoppedTomato", "Tomato should be finished." - button_up = Action("p1", "interact", "keyup") + button_up = Action("p1", ActionType.INTERACT, InterActionData.STOP) sim.enter_action(button_up) finally: sim.stop()