diff --git a/README.md b/README.md index 4e7e315bc87854f056c643808796056629f27e75..7da169c9bb2e36fc85f6e839603ffa27301aadaa 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The real-time overcooked simulation for a cognitive cooperative system. You have two options to install the environment. Either clone it and install it locally or install it in your site-packages. -You need a Python 3.10 or higher environment. Either conda or PyEnv. +You need a Python 3.10 or newer environment. Either conda or PyEnv. ### Local Editable Installation @@ -73,7 +73,8 @@ can be cooked/created. ### Layout Config You can define the layout of the kitchen via a layout file. The position of counters are based on a grid system, even -when the players do not move grid steps but continuous steps. Each character defines a different type of counter. +when the players do not move grid steps but continuous steps. Each character defines a different type of counter. Which +character is mapped to which counter is defined in the Environment config. ### Environment Config diff --git a/overcooked_simulator/__init__.py b/overcooked_simulator/__init__.py index cfd15d482adac935144d6b73af50657fec6b67c4..5d7acaeac21d083a862405fb39c9060ac5365e30 100644 --- a/overcooked_simulator/__init__.py +++ b/overcooked_simulator/__init__.py @@ -5,8 +5,8 @@ This is the documentation of the Overcooked Simulator. # About the package The package contains an environment for cooperation between players/agents. A PyGameGUI visualizes the game to -human or visual agents in 2D. A 3D web-enabled version (for example for online studies, currently under development) -can be found [here](https://gitlab.ub.uni-bielefeld.de/scs/cocosy/godot-overcooked-3d-visualization) +human or virtual agents in 2D. A 3D web-enabled version (for example for online studies, currently under development) +can be found [here](https://gitlab.ub.uni-bielefeld.de/scs/cocosy/godot-overcooked-3d-visualization). # Background / Literature The overcooked/cooking domain is a well established cooperation domain/task. There exists @@ -19,9 +19,23 @@ With this overcooked-simulator, we want to bring both worlds together: the reinf environment with an appealing visualisation. Enable the potential of developing artificial agents that play with humans like a "real" cooperative / human partner. +# Installation + +You need a Python **3.10** or newer environment. +```bash +pip install overcooked-environment@git+https://gitlab.ub.uni-bielefeld.de/scs/cocosy/overcooked-simulator@main +``` +Or clone it and install it as an editable library which allows you to use all the scripts directly. +```bash +git clone https://gitlab.ub.uni-bielefeld.de/scs/cocosy/overcooked-simulator.git +cd overcooked_simulator +pip install -e . +``` + # Usage / Examples -Our overcooked simulator is designed for real time interaction but also with reinforcement learning in mind (gymnasium environment). -It focuses on configurability, extensibility and appealing visualization options. + Our overcooked simulator is designed for real time interaction but also for reinforcement +learning (gymnasium environment) with time independent step function calls. It focuses on configurability, extensibility and appealing visualization +options. ## Human Player Run it via the command line (in your pyenv/conda environment): @@ -30,7 +44,7 @@ Run it via the command line (in your pyenv/conda environment): overcooked-sim --url "localhost" --port 8000 ``` -_The arguments are the defaults. Therefore, they are optional._ +*The arguments shown are the defaults.* You can also start the **Game Server** and the **PyGame GUI** individually in different terminals. @@ -41,12 +55,261 @@ python3 overcooked_simulator/gui_2d_vis/overcooked_gui.py --url "localhost" --po ``` ## Connect with agent and receive game state -... +Or you start a game server, create an environment and connect each player/agent via a websocket connection. + +To start a game server see above. Your own manager needs to create an environment. +```python +import requests + +from overcooked_simulator import ROOT_DIR +from overcooked_simulator.game_server import CreateEnvironmentConfig +from overcooked_simulator.server_results import CreateEnvResult + + +with open(ROOT_DIR / "game_content" / "item_info.yaml", "r") as file: + item_info = file.read() +with open(ROOT_DIR / "game_content" / "layouts" / "basic.layout", "r") as file: + layout = file.read() +with open(ROOT_DIR / "game_content" / "environment_config.yaml", "r") as file: + environment_config = file.read() + +create_env = CreateEnvironmentConfig( + manager_id="SECRETKEY1", + number_players=2, + environment_settings={"all_player_can_pause_game": False}, + item_info_config=item_info, + environment_config=environment_config, + layout_config=layout, +).model_dump(mode="json") + +env_info = requests.post("http://localhost:8000/manage/create_env", json=create_env) +if env_info.status_code == 403: + raise ValueError(f"Forbidden Request: {env_info.json()['detail']}") +env_info: CreateEnvResult = env_info.json() +``` + +Connect each player via a websocket (threaded or async). +```python +import json +import dataclasses +from websockets import connect + +from overcooked_simulator.overcooked_environment import Action, ActionType, InterActionData +from overcooked_simulator.utils import custom_asdict_factory + + +p1_websocket = connect("ws://localhost:8000/ws/player/" + env_info["player_info"]["0"]["client_id"]) + +# set player "0" as ready +p1_websocket.send(json.dumps({"type": "ready", "player_hash": env_info["player_info"]["0"]["player_hash"]})) +assert json.loads(websocket.recv())["status"] == 200, "not accepted player" + + +# get the state for player "0", call every frame/step +p1_websocket.send(json.dumps({"type": "get_state", "player_hash": env_info["player_info"]["0"]["player_hash"]})) +state = json.loads(websocket.recv()) + +# send an action for player "0" +# --- movement --- +action = Action( + player="0", + action_type=ActionType.MOVEMENT, + action_data=[0.0, 1.0], # direction (here straight up) + duration=0.5 # seconds +) +# --- pickup/drop off --- +action = Action( + player="0", + action_type=ActionType.PUT, + action_data="pickup", +) +# --- interact --- +action = Action( + player="0", + action_type=ActionType.INTERACT, + action_data=InterActionData.START # InterActionData.STOP when to stop the interaction +) + +p1_websocket.send(json.dumps({ + "type": "action", + "player_hash": env_info["player_info"]["0"]["player_hash"], + "action": dataclasses.asdict( + action, dict_factory=custom_asdict_factory + ), +})) +websocket.recv() + +``` + +Stop the environment if you want the game to end before the time is up. +```python +requests.post( + "http://localhost:8000/manage/stop_env", + json={ + "manager_id": "SECRETKEY1", + "env_id": env_info["env_id"], + "reason": "closed environment", + }, +) +``` + +## Direct integration into your code +You can use the `overcooked_simulator.overcooked_environment.Environment` class +and call the `step`, `get_json_state`, and `perform_action` methods directly. + +```python +from datetime import timedelta + +from overcooked_simulator import ROOT_DIR +from overcooked_simulator.overcooked_environment import Action, Environment + +env = Environment( + env_config=ROOT_DIR / "game_content" / "environment_config.yaml", + layout_config=ROOT_DIR / "game_content" / "layouts" / "basic.layout", + item_info=ROOT_DIR / "game_content" / "item_info.yaml" +) + +env.add_player("0") +env.add_player("1") + +while True: + # adapt this to real time if needed with time.sleep etc. + env.step(timedelta(seconds=0.1)) + + player_0_state = json.loads(env.get_json_state("0")) + if player_0_state["ended"]: + break + + action = ... # See above but use np.array instead of list for the movement direction vector + env.perform_action(action) +``` + +# JSON State +The JSON Schema for the state of the environment for a player can be generated by running the `state_representation.py` +```bash +python state_representation.py +``` +Should look like +``` +{'$defs': {'CookingEquipmentState': {'properties': {'content_list': {'items': {'$ref': '#/$defs/ItemState'}, 'title': 'Content List', 'type': 'array'}, 'content_ready': {'anyOf': [{'$ref': '#/$defs/ItemState'}, {'type': 'null'}]}}, 'required': ['content_list', 'content_ready'], 'title': 'CookingEquipmentState', 'type': 'object'}, 'CounterState': {'properties': {'id': {'title': 'Id', 'type': 'string'}, 'category': {'const': 'Counter', 'title': 'Category'}, 'type': {'title': 'Type', 'type': 'string'}, 'pos': {'items': {'type': 'number'}, 'title': 'Pos', 'type': 'array'}, 'occupied_by': {'anyOf': [{'items': {'anyOf': [{'$ref': '#/$defs/ItemState'}, {'$ref': '#/$defs/CookingEquipmentState'}]}, 'type': 'array'}, {'$ref': '#/$defs/ItemState'}, {'$ref': '#/$defs/CookingEquipmentState'}, {'type': 'null'}], 'title': 'Occupied By'}}, 'required': ['id', 'category', 'type', 'pos', 'occupied_by'], 'title': 'CounterState', 'type': 'object'}, 'ItemState': {'properties': {'id': {'title': 'Id', 'type': 'string'}, 'category': {'anyOf': [{'const': 'Item'}, {'const': 'ItemCookingEquipment'}], 'title': 'Category'}, 'type': {'title': 'Type', 'type': 'string'}, 'progress_percentage': {'anyOf': [{'type': 'number'}, {'type': 'integer'}], 'title': 'Progress Percentage'}}, 'required': ['id', 'category', 'type', 'progress_percentage'], 'title': 'ItemState', 'type': 'object'}, 'KitchenInfo': {'properties': {'width': {'title': 'Width', 'type': 'number'}, 'height': {'title': 'Height', 'type': 'number'}}, 'required': ['width', 'height'], 'title': 'KitchenInfo', 'type': 'object'}, 'OrderState': {'properties': {'id': {'title': 'Id', 'type': 'string'}, 'category': {'const': 'Order', 'title': 'Category'}, 'meal': {'title': 'Meal', 'type': 'string'}, 'start_time': {'format': 'date-time', 'title': 'Start Time', 'type': 'string'}, 'max_duration': {'title': 'Max Duration', 'type': 'number'}}, 'required': ['id', 'category', 'meal', 'start_time', 'max_duration'], 'title': 'OrderState', 'type': 'object'}, 'PlayerState': {'properties': {'id': {'title': 'Id', 'type': 'string'}, 'pos': {'items': {'type': 'number'}, 'title': 'Pos', 'type': 'array'}, 'facing_direction': {'items': {'type': 'number'}, 'title': 'Facing Direction', 'type': 'array'}, 'holding': {'anyOf': [{'$ref': '#/$defs/ItemState'}, {'$ref': '#/$defs/CookingEquipmentState'}, {'type': 'null'}], 'title': 'Holding'}, 'current_nearest_counter_pos': {'anyOf': [{'items': {'type': 'number'}, 'type': 'array'}, {'type': 'null'}], 'title': 'Current Nearest Counter Pos'}, 'current_nearest_counter_id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Current Nearest Counter Id'}}, 'required': ['id', 'pos', 'facing_direction', 'holding', 'current_nearest_counter_pos', 'current_nearest_counter_id'], 'title': 'PlayerState', 'type': 'object'}}, 'properties': {'players': {'items': {'$ref': '#/$defs/PlayerState'}, 'title': 'Players', 'type': 'array'}, 'counters': {'items': {'$ref': '#/$defs/CounterState'}, 'title': 'Counters', 'type': 'array'}, 'kitchen': {'$ref': '#/$defs/KitchenInfo'}, 'score': {'anyOf': [{'type': 'number'}, {'type': 'integer'}], 'title': 'Score'}, 'orders': {'items': {'$ref': '#/$defs/OrderState'}, 'title': 'Orders', 'type': 'array'}, 'ended': {'title': 'Ended', 'type': 'boolean'}, 'env_time': {'format': 'date-time', 'title': 'Env Time', 'type': 'string'}, 'remaining_time': {'title': 'Remaining Time', 'type': 'number'}}, 'required': ['players', 'counters', 'kitchen', 'score', 'orders', 'ended', 'env_time', 'remaining_time'], 'title': 'StateRepresentation', 'type': 'object'} +``` + +The BaseModel and TypedDicts can be found in `overcooked_simulator.state_representation`. The +`overcooked_simulator.state_representation.StateRepresentation` represents the json state that the `get_json_state` +returns. + +## Generate images from JSON states +You might have stored some json states and now you want to visualize them via the +pygame-2d visualization. You can do that by running the `drawing.py` script and referencing a json file. +```bash +python3 overcooked_simulator/gui_2d_vis/drawing.py --state my_state.json +``` +- You can specify a different visualization config with `-v` or `--visualization_config`. +- You can specify the name of the output file with `-o` or `--output_file`. The default is `screenshot.jpg`. + +# Configuration + +The environment configuration is currently done with 3 config files + GUI configuration. + +## Item Config + +The item config defines which ingredients, cooking equipment and meals can exist and how meals and processed ingredients +can be cooked/created. + +For example + + CuttingBoard: + type: Equipment + + Stove: + type: Equipment + + Pot: + type: Equipment + equipment: Stove + + Tomato: + type: Ingredient + + ChoppedTomato: + type: Ingredient + needs: [ Tomato ] + seconds: 4.0 + equipment: CuttingBoard + + TomatoSoup: + type: Meal + needs: [ ChoppedTomato, ChoppedTomato, ChoppedTomato ] + seconds: 6.0 + equipment: Pot + + +## Layout Config + +You can define the layout of the kitchen via a layout file. The position of counters are based on a grid system, even +when the players do not move grid steps but continuous steps. Each character defines a different type of counter. Which +character is mapped to which counter is defined in the Environment config. + +For example + +``` +#QU#FO#TNLB# +#__________M +#__________K +W__________I +#__A_____A_D +C__________E +C__________G +#__________# +#P#S+#X##S+# +``` + +## Environment Config + +The environment config defines how a level/environment is defined. Here, the available plates, meals, order and player +configuration is done. + +For example + +```yaml +plates: + clean_plates: 1 + dirty_plates: 2 + plate_delay: [ 5, 10 ] + # range of seconds until the dirty plate arrives. + +game: + time_limit_seconds: 300 + +meals: + all: true + +layout_chars: + _: Free + hash: Counter + A: Agent + P: PlateDispenser + C: CuttingBoard + X: Trashcan + W: ServingWindow + S: Sink + +: SinkAddon + U: Pot # with Stove + T: Tomato + +orders: + ... + +player_config: + radius: 0.4 + player_speed_units_per_seconds: 8 + interaction_range: 1.6 +``` -## Direct integration into your code. -Initialize an environment.... +## PyGame Visualization Config -**TODO** JSON State description. +Here the visualisation for all objects is defined. Reference the images or define a list of base shapes that represent +the counters, ingredients, meals and players. # Citation @@ -54,14 +317,17 @@ Initialize an environment.... # Structure of the Documentation The API documentation follows the file and content structure in the repo. On the left you can find the navigation panel that brings you to the implementation of +- the **counter factory** converts the characters in the layout file to counter instances, - the **counters**, including the kitchen utility objects like dispenser, cooking counter (stove, deep fryer, oven), sink, etc., - the **game items**, the holdable ingredients, cooking equipment, composed ingredients, and meals, -- in **main**, you find an example how to start a simulation, +- the **game server**, which can manage several running environments and can communicates via FastAPI post requests and +websockets, - the **orders**, how to sample incoming orders and their attributes, - the **environment**, handles the incoming actions and provides the state, - the **player**/agent, that interacts in the environment, -- a **simulation runner**, that calls the step function of the environment for a real-time interaction, and +- type hints are defined in **state representation** for the json state and **server results** for the data returned by +the game server in post requests. - **util**ity code. diff --git a/overcooked_simulator/__main__.py b/overcooked_simulator/__main__.py index 436201f2ec085f299699bdbf641f6f01029270b8..f48180ac4aa23e525646e844d9e53b9f49a71595 100644 --- a/overcooked_simulator/__main__.py +++ b/overcooked_simulator/__main__.py @@ -50,7 +50,7 @@ def main(cli_args=None): print("Received Keyboard interrupt") finally: if game_server is not None and game_server.is_alive(): - print("Terminate gparserame server") + print("Terminate game server") game_server.terminate() if pygame_gui is not None and pygame_gui.is_alive(): print("Terminate pygame gui") diff --git a/overcooked_simulator/counter_factory.py b/overcooked_simulator/counter_factory.py index 8f36b974384b8c78bbf92b004e830dc6769630d1..9b1487c3706a70dde8b224283a1877c126c41b56 100644 --- a/overcooked_simulator/counter_factory.py +++ b/overcooked_simulator/counter_factory.py @@ -1,5 +1,39 @@ +""" +The `CounterFactory` initializes the `Counter` classes from the characters in the layout config. +The mapping depends on the definition in the `environment_config.yml` in the `layout_chars` section. + +```yaml +layout_chars: + _: Free + hash: Counter + A: Agent + P: PlateDispenser + C: CuttingBoard + X: Trashcan + W: ServingWindow + S: Sink + +: SinkAddon + U: Pot # with Stove + Q: Pan # with Stove + O: Peel # with Oven + F: Basket # with DeepFryer + T: Tomato + N: Onion # oNioN + L: Lettuce + K: Potato # Kartoffel + I: Fish # fIIIsh + D: Dough + E: Cheese # chEEEse + G: Sausage # sausaGe + B: Bun + M: Meat +``` + +# Code Documentation +""" import inspect import sys +from random import Random from typing import Any, Type, TypeVar import numpy as np @@ -17,7 +51,15 @@ from overcooked_simulator.counters import ( SinkAddon, Trashcan, ) -from overcooked_simulator.game_items import ItemInfo, ItemType, CookingEquipment, Plate +from overcooked_simulator.effect_manager import EffectManager +from overcooked_simulator.game_items import ( + ItemInfo, + ItemType, + CookingEquipment, + Plate, + Item, +) +from overcooked_simulator.hooks import Hooks from overcooked_simulator.order import OrderAndScoreManager from overcooked_simulator.utils import get_closest @@ -27,12 +69,15 @@ T = TypeVar("T") def convert_words_to_chars(layout_chars_config: dict[str, str]) -> dict[str, str]: """Converts words in a given layout chars configuration dictionary to their corresponding characters. + This is useful for characters that can not be keys in a yaml file. For example, `#` produces a comment. + Therefore, you can specify `hash` as a key (`hash: Counter`). `word_refs` defines the conversions. *Click on `▶ View Source`.* + Args: - layout_chars_config (dict[str, str]): A dictionary containing layout character configurations, where the keys are words + layout_chars_config: A dictionary containing layout character configurations, where the keys are words representing layout characters and the values are the corresponding character representations. Returns: - dict[str, str]: A dictionary where the keys are the layout characters and the values are their corresponding words. + A dictionary where the keys are the layout characters and the values are their corresponding words. """ word_refs = { "hash": "#", @@ -75,56 +120,71 @@ class CounterFactory: serving_window_additional_kwargs: dict[str, Any], plate_config: PlateConfig, order_and_score: OrderAndScoreManager, + effect_manager_config: dict, + hook: Hooks, + random: Random, ) -> None: """Constructor for the `CounterFactory` class. Set up the attributes necessary to instantiate the counters. - Args: - layout_chars_config (dict[str, str]): A dictionary mapping layout characters to their corresponding names. - item_info (dict[str, ItemInfo]): A dictionary containing information about different items. - serving_window_additional_kwargs (dict[str, Any]): Additional keyword arguments for serving window configuration. - plate_config (PlateConfig): The configuration for plate usage. - - Returns: - None - Initializes the object with the provided parameters. It performs the following tasks: - Converts the layout character configuration from words to characters. - Sets the item information dictionary. - Sets the additional keyword arguments for serving window configuration. - Sets the plate configuration. - It also sets the following attributes: - - `no_counter_chars`: A set of characters that represent counters for agents or free spaces. - - `counter_classes`: A dictionary of counter classes imported from the 'overcooked_simulator.counters' module. - - `cooking_counter_equipments`: A dictionary mapping cooking counters to the list of equipment items associated with them. + Args: + layout_chars_config: A dictionary mapping layout characters to their corresponding names. + item_info: A dictionary containing information about different items. + serving_window_additional_kwargs: Additional keyword arguments for serving window configuration. + plate_config: The configuration for plate usage. """ - self.layout_chars_config = convert_words_to_chars(layout_chars_config) - self.item_info = item_info - self.serving_window_additional_kwargs = serving_window_additional_kwargs - self.plate_config = plate_config - self.order_and_score = order_and_score + self.layout_chars_config: dict[str, str] = convert_words_to_chars( + layout_chars_config + ) + """Layout chars to the counter names.""" + self.item_info: dict[str, ItemInfo] = item_info + """All item infos from the `item_info` config.""" + self.serving_window_additional_kwargs: dict[ + str, Any + ] = serving_window_additional_kwargs + """The additional keyword arguments for the serving window.""" + self.plate_config: PlateConfig = plate_config + """The plate config from the `environment_config`""" + self.order_and_score: OrderAndScoreManager = order_and_score + """The order and score manager to pass to `ServingWindow` and the `Tashcan` which can affect the scores.""" + self.effect_manager_config = effect_manager_config + """The effect manager config to setup the effect manager based on the defined effects in the item info.""" - self.no_counter_chars = set( + self.no_counter_chars: set[str] = set( c for c, name in self.layout_chars_config.items() if name in ["Agent", "Free"] ) + """A set of characters that represent counters for agents or free spaces.""" - self.counter_classes = dict( + self.counter_classes: dict[str, Type] = dict( inspect.getmembers( sys.modules["overcooked_simulator.counters"], inspect.isclass ) ) + """A dictionary of counter classes imported from the 'overcooked_simulator.counters' module.""" - self.cooking_counter_equipments = { - cooking_counter: [ + self.cooking_counter_equipments: dict[str, set[str]] = { + cooking_counter: { equipment for equipment, e_info in self.item_info.items() if e_info.equipment and e_info.equipment.name == cooking_counter - ] + } for cooking_counter, info in self.item_info.items() if info.type == ItemType.Equipment and info.equipment is None } + """A dictionary mapping cooking counters to the list of equipment items associated with them.""" + + self.hook = hook + """Reference to the hook manager.""" + + self.random = random + """Random instance.""" def get_counter_object(self, c: str, pos: npt.NDArray[float]) -> Counter: """Create and returns a counter object based on the provided character and position.""" @@ -137,36 +197,49 @@ class CounterFactory: if item_info.equipment.name not in self.counter_classes: return CookingCounter( name=item_info.equipment.name, - cooking_counter_equipments=self.cooking_counter_equipments, + equipments=self.cooking_counter_equipments[ + item_info.equipment.name + ], pos=pos, occupied_by=CookingEquipment( name=item_info.name, item_info=item_info, transitions=self.filter_item_info( - by_equipment_name=item_info.name + by_equipment_name=item_info.name, + add_effects=True, ), ), + hook=self.hook, ) elif item_info.type == ItemType.Ingredient: - return Dispenser(pos=pos, dispensing=item_info) + return Dispenser(pos=pos, hook=self.hook, dispensing=item_info) + elif item_info.type == ItemType.Tool: + return Counter( + pos=pos, + hook=self.hook, + occupied_by=Item(name=item_info.name, item_info=item_info), + ) if counter_class is None: counter_class = self.counter_classes[self.layout_chars_config[c]] kwargs = { "pos": pos, + "hook": self.hook, } if issubclass(counter_class, (CuttingBoard, Sink)): kwargs["transitions"] = self.filter_item_info( - by_equipment_name=counter_class.__name__ + by_equipment_name=counter_class.__name__, + add_effects=True, ) elif issubclass(counter_class, PlateDispenser): kwargs.update( { "plate_transitions": self.filter_item_info( - by_item_type=ItemType.Meal + by_item_type=ItemType.Meal, add_effects=True ), "plate_config": self.plate_config, "dispensing": self.item_info[Plate.__name__], + "random": self.random, } ) elif issubclass(counter_class, ServingWindow): @@ -200,21 +273,32 @@ class CounterFactory: self, by_item_type: ItemType = None, by_equipment_name: str = None, + add_effects: bool = False, ) -> dict[str, ItemInfo]: """Filter the item info dict by item type or equipment name""" + filtered = {} if by_item_type is not None: - return { + filtered = { name: info for name, info in self.item_info.items() if info.type == by_item_type } if by_equipment_name is not None: - return { + filtered = { name: info for name, info in self.item_info.items() if info.equipment is not None and info.equipment.name == by_equipment_name } + if add_effects: + for name, effect in self.filter_item_info( + by_item_type=ItemType.Effect + ).items(): + for need in effect.needs: + if need in filtered: + filtered.update({name: effect}) + if by_item_type or by_equipment_name: + return filtered return self.item_info def post_counter_setup(self, counters: list[Counter]): @@ -240,11 +324,34 @@ class CounterFactory: counter: Sink # Pycharm type checker does now work for match statements? assert len(sink_addons) > 0, "No SinkAddon but normal Sink" closest_addon = get_closest(pos, sink_addons) - assert 1 - (1 * 0.05) <= np.linalg.norm( + assert 1.0 == np.linalg.norm( closest_addon.pos - pos ), f"No SinkAddon connected to Sink at pos {pos}" counter.set_addon(closest_addon) + def setup_effect_manger(self, counters: list[Counter]) -> dict[str, EffectManager]: + effect_manager = {} + for name, effect in self.filter_item_info(by_item_type=ItemType.Effect).items(): + assert ( + effect.manager in self.effect_manager_config + ), f"Manager for effect not found: {name} -> {effect.manager} not in {list(self.effect_manager_config.keys())}" + if effect.manager in effect_manager: + manager = effect_manager[effect.manager] + else: + manager = self.effect_manager_config[effect.manager]["class"]( + hook=self.hook, + random=self.random, + **self.effect_manager_config[effect.manager]["kwargs"], + ) + manager.set_counters(counters) + effect_manager[effect.manager] = manager + + manager.add_effect(effect) + + effect.manager = manager + + return effect_manager + @staticmethod def get_counter_of_type(counter_type: Type[T], counters: list[Counter]) -> list[T]: """Filter all counters in the environment for a counter type.""" diff --git a/overcooked_simulator/counters.py b/overcooked_simulator/counters.py index c3e33392d38b4331273061c8c21a1bdd5687eb1a..3d930aa54e15a4c6ad9f187b3766eee34f96cc07 100644 --- a/overcooked_simulator/counters.py +++ b/overcooked_simulator/counters.py @@ -3,8 +3,8 @@ what should happen when the agent wants to pick something up from the counter. O the `Counter.drop_off` method receives the item what should be put on the counter. Before that the `Counter.can_drop_off` method checked if the item can be put on the counter. The progress on Counters or on objects on the counters are handled via the Counters. They have the task to delegate the progress call via the `progress` -method, e.g., the `CuttingBoard.progress`. On which type of counter the progress method is called is currently -defined in the environment class. +method, e.g., the `CuttingBoard.progress`. The environment class detects which classes in this module have the +`progress` method defined and on instances of these classes the progress will be delegated. Inside the item_info.yaml, equipment needs to be defined. It includes counters that are part of the interaction/requirements for the interaction. @@ -18,6 +18,7 @@ interaction/requirements for the interaction. Stove: type: Equipment + The defined counter classes are: - `Counter` - `CuttingBoard` @@ -39,9 +40,31 @@ import uuid from collections import deque from collections.abc import Iterable from datetime import datetime, timedelta +from random import Random from typing import TYPE_CHECKING, Optional, Callable, Set +from overcooked_simulator.hooks import ( + Hooks, + POST_DISPENSER_PICK_UP, + PRE_DISPENSER_PICK_UP, + CUTTING_BOARD_PROGRESS, + CUTTING_BOARD_100, + PRE_COUNTER_PICK_UP, + POST_COUNTER_PICK_UP, + PRE_SERVING, + POST_SERVING, + NO_SERVING, + DIRTY_PLATE_ARRIVES, + TRASHCAN_USAGE, + PLATE_CLEANED, + ADDED_PLATE_TO_SINK, + DROP_ON_SINK_ADDON, + PICK_UP_FROM_SINK_ADDON, + PLATE_OUT_OF_KITCHEN_TIME, +) + if TYPE_CHECKING: + from overcooked_simulator.effect_manager import Effect from overcooked_simulator.overcooked_environment import ( OrderAndScoreManager, ) @@ -54,12 +77,15 @@ from overcooked_simulator.game_items import ( CookingEquipment, Plate, ItemInfo, + EffectType, ) log = logging.getLogger(__name__) +"""The logger for this module.""" COUNTER_CATEGORY = "Counter" +"""The string for the `category` value in the json state representation for all counters.""" class Counter: @@ -71,6 +97,7 @@ class Counter: def __init__( self, pos: npt.NDArray[float], + hook: Hooks, occupied_by: Optional[Item] = None, uid: hex = None, **kwargs, @@ -81,14 +108,30 @@ class Counter: pos: Position of the counter in the environment. 2-element vector. occupied_by: The item on top of the counter. """ - self.uuid = uuid.uuid4().hex if uid is None else None + self.uuid: str = uuid.uuid4().hex if uid is None else None + """A unique id for better tracking in GUIs with assets which instance moved or changed.""" self.pos: npt.NDArray[float] = pos + """The position of the counter.""" self.occupied_by: Optional[Item] = occupied_by + """What is on top of the counter, e.g., `Item`s.""" + self.active_effects: list[Effect] = [] + """The effects that currently affect the usage of the counter.""" + self.hook = hook + """Reference to the hook manager.""" + self.orientation: npt.NDArray[float] = np.array([0, 1], dtype=float) + """In what direction the counter is facing.""" @property - def occupied(self): + def occupied(self) -> bool: + """Is something on top of the counter.""" return self.occupied_by is not None + def set_orientation(self, orientation: npt.NDArray[float]) -> None: + if not np.isclose(np.linalg.norm(orientation), 1): + self.orientation = orientation / np.linalg.norm(orientation) + else: + self.orientation = orientation + def pick_up(self, on_hands: bool = True) -> Item | None: """Gets called upon a player performing the pickup action. If the counter can give something to the player, it does so. In the standard counter this is when an item is on the counter. @@ -98,16 +141,36 @@ class Counter: Returns: The item which the counter is occupied by. None if nothing is there. """ + self.hook(PRE_COUNTER_PICK_UP, counter=self, on_hands=on_hands) if on_hands: if self.occupied_by: occupied_by = self.occupied_by self.occupied_by = None + self.hook( + POST_COUNTER_PICK_UP, + counter=self, + on_hands=on_hands, + return_this=occupied_by, + ) return occupied_by return None if self.occupied_by and isinstance(self.occupied_by, CookingEquipment): - return self.occupied_by.release() + return_this = self.occupied_by.release() + self.hook( + POST_COUNTER_PICK_UP, + counter=self, + on_hands=on_hands, + return_this=return_this, + ) + return return_this occupied_by = self.occupied_by self.occupied_by = None + self.hook( + POST_COUNTER_PICK_UP, + counter=self, + on_hands=on_hands, + return_this=occupied_by, + ) return occupied_by def can_drop_off(self, item: Item) -> bool: @@ -137,25 +200,53 @@ class Counter: return self.occupied_by.combine(item) return None - def interact_start(self): - """Starts an interaction by the player. Nothing happens for the standard counter.""" - pass - - def interact_stop(self): - """Stops an interaction by the player. Nothing happens for the standard counter.""" - pass - def __repr__(self): return ( f"{self.__class__.__name__}(pos={self.pos},occupied_by={self.occupied_by})" ) + def do_tool_interaction(self, passed_time: timedelta, tool: Item): + successful = False + if self.occupied_by: + if isinstance(self.occupied_by, deque): + for item in self.occupied_by: + successful |= self._do_single_tool_interaction( + passed_time, tool, item + ) + else: + successful = self._do_single_tool_interaction( + passed_time, tool, self.occupied_by + ) + if not successful: + self._do_single_tool_interaction(passed_time, tool, self) + + def _do_single_tool_interaction( + self, passed_time: timedelta, tool: Item, target: Item | Counter + ) -> bool: + suitable_effects = [ + e for e in target.active_effects if e.name in tool.item_info.needs + ] + if suitable_effects: + effect = suitable_effects[0] + percent = passed_time.total_seconds() / tool.item_info.seconds + effect.progres_percentage += percent + if effect.progres_percentage > 1.0: + effect.item_info.manager.remove_active_effect(effect, target) + target.active_effects.remove(effect) + return True + return False + + def do_hand_free_interaction(self, passed_time: timedelta, now: datetime): + ... + def to_dict(self) -> dict: + """For the state representation. Only the relevant attributes are put into the dict.""" return { "id": self.uuid, "category": COUNTER_CATEGORY, "type": self.__class__.__name__, "pos": self.pos.tolist(), + "orientation": self.orientation.tolist(), "occupied_by": None if self.occupied_by is None else ( @@ -163,31 +254,34 @@ class Counter: if isinstance(self.occupied_by, Iterable) else self.occupied_by.to_dict() ), + "active_effects": [e.to_dict() for e in self.active_effects], } class CuttingBoard(Counter): - """Cutting ingredients on. The requirement in a new object could look like + """Cutting ingredients on. The requirement in a new object could look like. + + ChoppedTomato: + type: Ingredient + needs: [ Tomato ] + seconds: 4.0 + equipment: CuttingBoard - ```yaml - ChoppedTomato: - type: Ingredient - needs: [ Tomato ] - seconds: 4.0 - equipment: CuttingBoard - ``` The character `C` in the `layout` file represents the CuttingBoard. """ - def __init__(self, pos: np.ndarray, transitions: dict[str, ItemInfo], **kwargs): - self.progressing = False - self.transitions = transitions - self.inverted_transition_dict = { + def __init__(self, transitions: dict[str, ItemInfo], **kwargs): + self.transitions: dict[str, ItemInfo] = transitions + """The allowed transitions to a new item. Keys are the resulting items and the `ItemInfo` (value) contains + the needed items in the `need` attribute.""" + self.inverted_transition_dict: dict[str, ItemInfo] = { info.needs[0]: info for name, info in self.transitions.items() } - super().__init__(pos=pos, **kwargs) + """For faster accessing the needed item. Keys are the ingredients that the player can put and chop on the + board.""" + super().__init__(**kwargs) - def progress(self, passed_time: timedelta, now: datetime): + def do_hand_free_interaction(self, passed_time: timedelta, now: datetime): """Called by environment step function for time progression. Args: @@ -200,8 +294,15 @@ class CuttingBoard(Counter): """ if ( self.occupied - and self.progressing and self.occupied_by.name in self.inverted_transition_dict + and not any( + e.item_info.effect_type == EffectType.Unusable + for e in self.occupied_by.active_effects + ) + and not any( + e.item_info.effect_type == EffectType.Unusable + for e in self.active_effects + ) ): percent = ( passed_time.total_seconds() @@ -210,32 +311,18 @@ class CuttingBoard(Counter): self.occupied_by.progress( equipment=self.__class__.__name__, percent=percent ) + self.hook( + CUTTING_BOARD_PROGRESS, + counter=self, + percent=percent, + passed_time=passed_time, + ) if self.occupied_by.progress_percentage == 1.0: self.occupied_by.reset() self.occupied_by.name = self.inverted_transition_dict[ self.occupied_by.name ].name - - def start_progress(self): - """Starts the cutting process.""" - self.progressing = True - - def pause_progress(self): - """Pauses the cutting process""" - self.progressing = False - - def interact_start(self): - """Handles player interaction, starting to hold key down.""" - self.start_progress() - - def interact_stop(self): - """Handles player interaction, stopping to hold key down.""" - self.pause_progress() - - def to_dict(self) -> dict: - d = super().to_dict() - d.update((("progressing", self.progressing),)) - return d + self.hook(CUTTING_BOARD_100, counter=self) class ServingWindow(Counter): @@ -252,28 +339,44 @@ class ServingWindow(Counter): def __init__( self, - pos: npt.NDArray[float], order_and_score: OrderAndScoreManager, meals: set[str], env_time_func: Callable[[], datetime], plate_dispenser: PlateDispenser = None, **kwargs, ): - self.order_and_score = order_and_score - self.plate_dispenser = plate_dispenser - self.meals = meals - self.env_time_func = env_time_func - super().__init__(pos=pos, **kwargs) + self.order_and_score: OrderAndScoreManager = order_and_score + """Reference to the OrderAndScoreManager class. It determines which meals can be served and it manages the + score.""" + self.plate_dispenser: PlateDispenser = plate_dispenser + """Served meals are mentioned on the plate dispenser. So that the plate dispenser can add a dirty plate after + some time.""" + self.meals: set[str] = meals + """All allowed meals by the `environment_config.yml`.""" + self.env_time_func: Callable[[], datetime] = env_time_func + """Reference to get the current env time by calling the `env_time_func`.""" + super().__init__(**kwargs) def drop_off(self, item) -> Item | None: env_time = self.env_time_func() + self.hook(PRE_SERVING, counter=self, item=item, env_time=env_time) if self.order_and_score.serve_meal(item=item, env_time=env_time): if self.plate_dispenser is not None: self.plate_dispenser.update_plate_out_of_kitchen(env_time=env_time) + self.hook(POST_SERVING, counter=self, item=item, env_time=env_time) return None + self.hook(NO_SERVING, counter=self, item=item, env_time=env_time) return item def can_drop_off(self, item: Item) -> bool: + if any( + e.item_info.effect_type == EffectType.Unusable for e in self.active_effects + ): + return False + if any( + e.item_info.effect_type == EffectType.Unusable for e in item.active_effects + ): + return False return isinstance(item, CookingEquipment) and ( (item.content_ready is not None and item.content_ready.name in self.meals) or (len(item.content_list) == 1 and item.content_list[0].name in self.meals) @@ -291,7 +394,7 @@ class Dispenser(Counter): At the moment all ingredients have an unlimited stock. - The character for each dispenser in the `layout` file is currently hard coded in the environment class: + The character for each dispenser in the `layout` file is defined in the `environment_config.yml`: ```yaml T: Tomato L: Lettuce @@ -299,23 +402,29 @@ class Dispenser(Counter): B: Bun M: Meat ``` - The plan is to put the info also in the config. In the implementation, an instance of the item to dispense is always on top of the dispenser. Which also is easier for the visualization of the dispenser. """ - def __init__(self, pos: npt.NDArray[float], dispensing: ItemInfo, **kwargs): - self.dispensing = dispensing + def __init__(self, dispensing: ItemInfo, **kwargs): + self.dispensing: ItemInfo = dispensing + """`ItemInfo` what the the Dispenser is dispensing. One ready object always is on top of the counter.""" super().__init__( - pos=pos, occupied_by=self.create_item(), **kwargs, ) def pick_up(self, on_hands: bool = True) -> Item | None: + self.hook(PRE_DISPENSER_PICK_UP, counter=self, on_hands=on_hands) return_this = self.occupied_by self.occupied_by = self.create_item() + self.hook( + POST_DISPENSER_PICK_UP, + counter=self, + on_hands=on_hands, + return_this=return_this, + ) return return_this def drop_off(self, item: Item) -> Item | None: @@ -329,6 +438,7 @@ class Dispenser(Counter): return f"{self.dispensing.name}Dispenser" def create_item(self) -> Item: + """Create a new item to put on the dispenser after the previous one was picked up.""" kwargs = { "name": self.dispensing.name, "item_info": self.dispensing, @@ -371,19 +481,29 @@ class PlateDispenser(Counter): def __init__( self, - pos: npt.NDArray[float], dispensing: ItemInfo, plate_config: PlateConfig, plate_transitions: dict[str, ItemInfo], + random: Random, **kwargs, ) -> None: - super().__init__(pos=pos, **kwargs) - self.dispensing = dispensing - self.occupied_by = deque() - self.out_of_kitchen_timer = [] - self.plate_config = plate_config - self.next_plate_time = datetime.max - self.plate_transitions = plate_transitions + super().__init__(**kwargs) + self.dispensing: ItemInfo = dispensing + """Plate ItemInfo.""" + self.occupied_by: deque = deque() + """The queue of plates. New dirty ones are put at the end and therefore under the current plates.""" + self.out_of_kitchen_timer: list[datetime] = [] + """Internal timer for how many plates are out of kitchen and how long.""" + self.plate_config: PlateConfig = plate_config + """The config how many plates are present in the kitchen at the beginning (and in total) and the config for + the random "out of kitchen" timer.""" + self.next_plate_time: datetime = datetime.max + """For efficient checking if dirty plates should be created, instead of looping through the + `out_of_kitchen_timer` list every frame.""" + self.plate_transitions: dict[str, ItemInfo] = plate_transitions + """Transitions for the plates. Relevant for the sink, because a plate can become a clean one there.""" + self.random = random + """Random instance.""" self.setup_plates() def pick_up(self, on_hands: bool = True) -> Item | None: @@ -394,8 +514,6 @@ class PlateDispenser(Counter): return not self.occupied_by or self.occupied_by[-1].can_combine(item) def drop_off(self, item: Item) -> Item | None: - """At the moment items can be put on the top of the plate dispenser or the top plate if it is clean and can - be put on a plate.""" if not self.occupied_by: self.occupied_by.append(item) elif self.occupied_by[-1].can_combine(item): @@ -403,21 +521,23 @@ class PlateDispenser(Counter): return None def add_dirty_plate(self): + """Add a dirty plate after a timer is completed.""" self.occupied_by.appendleft(self.create_item()) def update_plate_out_of_kitchen(self, env_time: datetime): """Is called from the serving window to add a plate out of kitchen.""" # 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], + seconds=self.random.uniform( + a=self.plate_config.plate_delay[0], + b=self.plate_config.plate_delay[1], ) ) log.debug(f"New plate out of kitchen until {time_plate_to_add}") self.out_of_kitchen_timer.append(time_plate_to_add) if time_plate_to_add < self.next_plate_time: self.next_plate_time = time_plate_to_add + self.hook(PLATE_OUT_OF_KITCHEN_TIME, time_plate_to_add=time_plate_to_add) def setup_plates(self): """Create plates based on the config. Clean and dirty ones.""" @@ -441,6 +561,7 @@ class PlateDispenser(Counter): idx_delete = [] for i, times in enumerate(self.out_of_kitchen_timer): if times < now: + self.hook(DIRTY_PLATE_ARRIVES, counter=self, times=times, now=now) idx_delete.append(i) log.debug("Add dirty plate") self.add_dirty_plate() @@ -456,6 +577,11 @@ class PlateDispenser(Counter): return "PlateReturn" def create_item(self, clean: bool = False) -> Plate: + """Create a plate. + + Args: + clean: Whether the plate is clean or dirty. + """ kwargs = { "clean": clean, "transitions": self.plate_transitions, @@ -470,22 +596,30 @@ class Trashcan(Counter): The character `X` in the `layout` file represents the Trashcan. """ - def __init__( - self, order_and_score: OrderAndScoreManager, pos: npt.NDArray[float], **kwargs - ): - super().__init__(pos, **kwargs) - self.order_and_score = order_and_score + def __init__(self, order_and_score: OrderAndScoreManager, **kwargs): + super().__init__(**kwargs) + self.order_and_score: OrderAndScoreManager = order_and_score + """Reference to the `OrderAndScoreManager`, because unnecessary removed items can affect the score.""" def pick_up(self, on_hands: bool = True) -> Item | None: pass def drop_off(self, item: Item) -> Item | None: + if any( + e.item_info.effect_type == EffectType.Unusable for e in item.active_effects + ) or any( + e.item_info.effect_type == EffectType.Unusable for e in self.active_effects + ): + return item if isinstance(item, CookingEquipment): - self.order_and_score.apply_penalty_for_using_trash(item.content_list) + penalty = self.order_and_score.apply_penalty_for_using_trash( + item.content_list + ) item.reset_content() return item else: - self.order_and_score.apply_penalty_for_using_trash(item) + penalty = self.order_and_score.apply_penalty_for_using_trash(item) + self.hook(TRASHCAN_USAGE, counter=self, item=item, penalty=penalty) return None def can_drop_off(self, item: Item) -> bool: @@ -507,19 +641,18 @@ class CookingCounter(Counter): def __init__( self, name: str, - cooking_counter_equipments: dict[str, list[str]], + equipments: set[str], **kwargs, ): - self.name = name - self.cooking_counter_equipments = cooking_counter_equipments + self.name: str = name + """The type/name of the cooking counter, e.g., Stove, DeepFryer, Oven.""" + self.equipments: set[str] = equipments + """The valid equipment for the cooking counter, e.g., for a Stove {'Pot','Pan'}.""" super().__init__(**kwargs) def can_drop_off(self, item: Item) -> bool: if self.occupied_by is None: - return ( - isinstance(item, CookingEquipment) - and item.name in self.cooking_counter_equipments[self.name] - ) + return isinstance(item, CookingEquipment) and item.name in self.equipments else: return self.occupied_by.can_combine(item) @@ -528,8 +661,12 @@ class CookingCounter(Counter): if ( self.occupied_by and isinstance(self.occupied_by, CookingEquipment) - and self.occupied_by.name in self.cooking_counter_equipments[self.name] + and self.occupied_by.name in self.equipments and self.occupied_by.can_progress() + and not any( + e.item_info.effect_type == EffectType.Unusable + for e in self.active_effects + ) ): self.occupied_by.progress(passed_time, now) @@ -557,13 +694,11 @@ class Sink(Counter): def __init__( self, - pos: npt.NDArray[float], transitions: dict[str, ItemInfo], sink_addon: SinkAddon = None, **kwargs, ): - super().__init__(pos=pos, **kwargs) - self.progressing = False + super().__init__(**kwargs) self.sink_addon: SinkAddon = sink_addon """The connected sink addon which will receive the clean plates""" self.occupied_by: deque[Plate] = deque() @@ -580,15 +715,34 @@ class Sink(Counter): self.transition_needs.update([info.needs[0]]) @property - def occupied(self): + def occupied(self) -> bool: + """If there is a plate in the sink.""" return len(self.occupied_by) != 0 - def progress(self, passed_time: timedelta, now: datetime): + def do_hand_free_interaction(self, passed_time: timedelta, now: datetime): """Called by environment step function for time progression""" if ( self.occupied - and self.progressing and self.occupied_by[-1].name in self.transition_needs + and not any( + e.item_info.effect_type == EffectType.Unusable + for e in self.active_effects + ) + and not any( + e.item_info.effect_type == EffectType.Unusable + for e in self.sink_addon.active_effects + ) + and not any( + e.item_info.effect_type == EffectType.Unusable + for e in self.occupied_by[-1].active_effects + ) + and ( + not self.sink_addon.occupied_by + or not any( + e.item_info.effect_type == EffectType.Unusable + for e in self.sink_addon.occupied_by[-1].active_effects + ) + ) ): for name, info in self.transitions.items(): if info.needs[0] == self.occupied_by[-1].name: @@ -597,6 +751,7 @@ class Sink(Counter): equipment=self.__class__.__name__, percent=percent ) if self.occupied_by[-1].progress_percentage == 1.0: + self.hook(PLATE_CLEANED, counter=self) self.occupied_by[-1].reset() self.occupied_by[-1].name = name plate = self.occupied_by.pop() @@ -604,40 +759,21 @@ class Sink(Counter): self.sink_addon.add_clean_plate(plate) break - def start_progress(self): - """Starts the cutting process.""" - self.progressing = True - - def pause_progress(self): - """Pauses the cutting process""" - self.progressing = False - - def interact_start(self): - """Handles player interaction, starting to hold key down.""" - self.start_progress() - - def interact_stop(self): - """Handles player interaction, stopping to hold key down.""" - self.pause_progress() - def can_drop_off(self, item: Item) -> bool: return isinstance(item, Plate) and not item.clean - def drop_off(self, item: Item) -> Item | None: + def drop_off(self, item: Plate) -> Item | None: self.occupied_by.appendleft(item) + self.hook(ADDED_PLATE_TO_SINK, counter=self, item=item) return None def pick_up(self, on_hands: bool = True) -> Item | None: return None def set_addon(self, sink_addon: SinkAddon): + """Set the closest addon in post_setup.""" self.sink_addon = sink_addon - def to_dict(self) -> dict: - d = super().to_dict() - d.update((("progressing", self.progressing),)) - return d - class SinkAddon(Counter): """The counter on which the clean plates appear after cleaning them in the `Sink` @@ -647,20 +783,24 @@ class SinkAddon(Counter): The character `+` in the `layout` file represents the SinkAddon. """ - def __init__(self, pos: npt.NDArray[float], occupied_by=None): - super().__init__(pos=pos) + def __init__(self, occupied_by=None, **kwargs): + super().__init__(**kwargs) # maybe check if occupied by is already a list or deque? - self.occupied_by = deque([occupied_by]) if occupied_by else deque() + self.occupied_by: deque = deque([occupied_by]) if occupied_by else deque() + """The stack of clean plates.""" def can_drop_off(self, item: Item) -> bool: return self.occupied_by and self.occupied_by[-1].can_combine(item) def drop_off(self, item: Item) -> Item | None: + self.hook(DROP_ON_SINK_ADDON, counter=self, item=item) return self.occupied_by[-1].combine(item) def add_clean_plate(self, plate: Plate): + """Called from the `Sink` after a plate is cleaned / the progress is complete.""" self.occupied_by.appendleft(plate) def pick_up(self, on_hands: bool = True) -> Item | None: if self.occupied_by: + self.hook(PICK_UP_FROM_SINK_ADDON) return self.occupied_by.pop() diff --git a/overcooked_simulator/effect_manager.py b/overcooked_simulator/effect_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..bb13ee977c25bcc715bf7867e0208671c1cd9748 --- /dev/null +++ b/overcooked_simulator/effect_manager.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from collections import deque +from datetime import timedelta, datetime +from random import Random +from typing import TYPE_CHECKING, Tuple + +from overcooked_simulator.game_items import ( + ItemInfo, + Item, + ItemType, + Effect, + CookingEquipment, +) +from overcooked_simulator.hooks import Hooks +from overcooked_simulator.utils import get_touching_counters, find_item_on_counters + +if TYPE_CHECKING: + from overcooked_simulator.counters import Counter + + +class EffectManager: + def __init__(self, hook: Hooks, random: Random) -> None: + self.effects = [] + self.counters = [] + self.hook = hook + self.new_effects: list[Tuple[Effect, Item | Counter]] = [] + self.random = random + + def add_effect(self, effect: ItemInfo): + self.effects.append(effect) + + def set_counters(self, counters: list[Counter]): + self.counters.extend(counters) + + def register_active_effect(self, effect: Effect, target: Item | Counter): + target.active_effects.append(effect) + self.new_effects.append((effect, target)) + + def progress(self, passed_time: timedelta, now: datetime): + ... + + def can_start_effect_transition( + self, effect: ItemInfo, target: Item | Counter + ) -> bool: + return effect.name not in [e.name for e in target.active_effects] + + def remove_active_effect(self, effect: Effect, target: Item | Counter): + ... + + +class FireEffectManager(EffectManager): + # TODO add Random object + + def __init__( + self, + spreading_duration: list[float], + fire_burns_ingredients_and_meals: bool, + **kwargs, + ): + super().__init__(**kwargs) + self.spreading_duration = spreading_duration + self.fire_burns_ingredients_and_meals = fire_burns_ingredients_and_meals + self.effect_to_timer: dict[str:datetime] = {} + self.next_finished_timer = datetime.max + self.active_effects: list[Tuple[Effect, Item | Counter]] = [] + + def progress(self, passed_time: timedelta, now: datetime): + if self.new_effects: + for effect, target in self.new_effects: + self.effect_to_timer[effect.uuid] = now + timedelta( + seconds=self.random.uniform(*self.spreading_duration) + ) + self.next_finished_timer = min( + self.next_finished_timer, self.effect_to_timer[effect.uuid] + ) + self.active_effects.append((effect, target)) + self.new_effects = [] + if self.next_finished_timer < now: + for effect, target in self.active_effects: + if self.effect_to_timer[effect.uuid] < now: + if isinstance(target, Item): + target = find_item_on_counters(target.uuid, self.counters) + if target: + touching = get_touching_counters(target, self.counters) + for counter in touching: + if counter.occupied_by: + if isinstance(counter.occupied_by, deque): + self.apply_effect(effect, counter.occupied_by[-1]) + else: + self.apply_effect(effect, counter.occupied_by) + else: + self.apply_effect(effect, counter) + self.effect_to_timer[effect.uuid] = now + timedelta( + seconds=self.random.uniform(*self.spreading_duration) + ) + if self.effect_to_timer: + self.next_finished_timer = min(self.effect_to_timer.values()) + else: + self.next_finished_timer = datetime.max + + def apply_effect(self, effect: Effect, target: Item | Counter): + if ( + isinstance(target, Item) + and target.item_info.type == ItemType.Tool + and effect.name in target.item_info.needs + ): + # Tools that reduce fire can not burn + return + if effect.name not in target.active_effects and target.uuid not in [ + t.uuid for _, t in self.active_effects + ]: + if isinstance(target, CookingEquipment): + if target.content_list: + for content in target.content_list: + self.burn_content(content) + if self.fire_burns_ingredients_and_meals: + self.burn_content(target.content_ready) + elif isinstance(target, Item): + self.burn_content(target) + self.register_active_effect( + Effect(effect.name, item_info=effect.item_info), target + ) + + def burn_content(self, content: Item): + if self.fire_burns_ingredients_and_meals and content: + if not content.name.startswith("Burnt"): + content.name = "Burnt" + content.name + + def remove_active_effect(self, effect: Effect, target: Item | Counter): + if (effect, target) in self.active_effects: + self.active_effects.remove((effect, target)) + if effect.uuid in self.effect_to_timer: + del self.effect_to_timer[effect.uuid] diff --git a/overcooked_simulator/game_content/environment_config.yaml b/overcooked_simulator/game_content/environment_config.yaml index 9e60cf36634c0c4010734c704066d73a93ea4d0a..6e58cec45fa0d7c81701afe42a427e8475102447 100644 --- a/overcooked_simulator/game_content/environment_config.yaml +++ b/overcooked_simulator/game_content/environment_config.yaml @@ -20,6 +20,7 @@ layout_chars: _: Free hash: Counter A: Agent + pipe: Extinguisher P: PlateDispenser C: CuttingBoard X: Trashcan @@ -41,6 +42,7 @@ layout_chars: B: Bun M: Meat + orders: order_gen_class: !!python/name:overcooked_simulator.order.RandomOrderGeneration '' # the class to that receives the kwargs. Should be a child class of OrderGeneration in order.py @@ -85,3 +87,45 @@ player_config: radius: 0.4 player_speed_units_per_seconds: 6 interaction_range: 1.6 + + +effect_manager: + FireManager: + class: !!python/name:overcooked_simulator.effect_manager.FireEffectManager '' + kwargs: + spreading_duration: [ 5, 10 ] + fire_burns_ingredients_and_meals: true + + +extra_setup_functions: + # json_states: + # func: !!python/name:overcooked_simulator.recording.class_recording_with_hooks '' + # kwargs: + # hooks: [ json_state ] + # log_class: !!python/name:overcooked_simulator.recording.LogRecorder '' + # log_class_kwargs: + # log_path: USER_LOG_DIR/ENV_NAME/json_states.jsonl + actions: + func: !!python/name:overcooked_simulator.recording.class_recording_with_hooks '' + kwargs: + hooks: [ pre_perform_action ] + log_class: !!python/name:overcooked_simulator.recording.LogRecorder '' + log_class_kwargs: + log_path: USER_LOG_DIR/ENV_NAME/LOG_RECORD_NAME.jsonl + random_env_events: + func: !!python/name:overcooked_simulator.recording.class_recording_with_hooks '' + kwargs: + hooks: [ order_duration_sample, plate_out_of_kitchen_time ] + log_class: !!python/name:overcooked_simulator.recording.LogRecorder '' + log_class_kwargs: + log_path: USER_LOG_DIR/ENV_NAME/LOG_RECORD_NAME.jsonl + add_hook_ref: true + env_configs: + func: !!python/name:overcooked_simulator.recording.class_recording_with_hooks '' + kwargs: + hooks: [ env_initialized, item_info_config ] + log_class: !!python/name:overcooked_simulator.recording.LogRecorder '' + log_class_kwargs: + log_path: USER_LOG_DIR/ENV_NAME/LOG_RECORD_NAME.jsonl + add_hook_ref: true + diff --git a/overcooked_simulator/game_content/item_info.yaml b/overcooked_simulator/game_content/item_info.yaml index a6458c6329cbf0323e6fb59338f34e0739c9786e..1266f61ebd611cd5c2a9097b1be9dd7eff65b7f7 100644 --- a/overcooked_simulator/game_content/item_info.yaml +++ b/overcooked_simulator/game_content/item_info.yaml @@ -176,3 +176,57 @@ Pizza: needs: [ PizzaBase, ChoppedTomato, GratedCheese, ChoppedSausage ] seconds: 7.0 equipment: Peel + +# -------------------------------------------------------------------------------- + +BurntCookedPatty: + type: Waste + seconds: 5.0 + needs: [ CookedPatty ] + equipment: Pan + +BurntChips: + type: Waste + seconds: 5.0 + needs: [ Chips ] + equipment: Basket + +BurntFriedFish: + type: Waste + seconds: 5.0 + needs: [ FriedFish ] + equipment: Basket + +BurntTomatoSoup: + type: Waste + needs: [ TomatoSoup ] + seconds: 6.0 + equipment: Pot + +BurntOnionSoup: + type: Waste + needs: [ OnionSoup ] + seconds: 6.0 + equipment: Pot + +BurntPizza: + type: Waste + needs: [ Pizza ] + seconds: 7.0 + equipment: Peel + +# -------------------------------------------------------------------------------- + +Fire: + type: Effect + seconds: 5.0 + needs: [ BurntCookedPatty, BurntChips, BurntFriedFish, BurntTomatoSoup, BurntOnionSoup, BurntPizza ] + manager: FireManager + effect_type: Unusable + +# -------------------------------------------------------------------------------- + +Extinguisher: + type: Tool + seconds: 1.0 + needs: [ Fire ] diff --git a/overcooked_simulator/game_content/item_info_debug.yaml b/overcooked_simulator/game_content/item_info_debug.yaml index c2282253e9539c9686cd6746f536e0316c3b21ec..fd871d1d5685df542317bf772b67f3a3fd8ed7d8 100644 --- a/overcooked_simulator/game_content/item_info_debug.yaml +++ b/overcooked_simulator/game_content/item_info_debug.yaml @@ -177,3 +177,57 @@ Pizza: needs: [ PizzaBase, ChoppedTomato, GratedCheese, ChoppedSausage ] seconds: 0.1 equipment: Peel + +# -------------------------------------------------------------------------------- + +BurntCookedPatty: + type: Waste + seconds: 5.0 + needs: [ CookedPatty ] + equipment: Pan + +BurntChips: + type: Waste + seconds: 1.0 + needs: [ Chips ] + equipment: Basket + +BurntFriedFish: + type: Waste + seconds: 5.0 + needs: [ FriedFish ] + equipment: Basket + +BurntTomatoSoup: + type: Waste + needs: [ TomatoSoup ] + seconds: 6.0 + equipment: Pot + +BurntOnionSoup: + type: Waste + needs: [ OnionSoup ] + seconds: 6.0 + equipment: Pot + +BurntPizza: + type: Waste + needs: [ Pizza ] + seconds: 7.0 + equipment: Peel + +# -------------------------------------------------------------------------------- + +Fire: + type: Effect + seconds: 1.0 + needs: [ BurntCookedPatty, BurntChips, BurntFriedFish, BurntTomatoSoup, BurntOnionSoup, BurntPizza ] + manager: FireManager + effect_type: Unusable + +# -------------------------------------------------------------------------------- + +Extinguisher: + type: Tool + seconds: 0.1 + needs: [ Fire ] \ No newline at end of file diff --git a/overcooked_simulator/game_content/layouts/basic.layout b/overcooked_simulator/game_content/layouts/basic.layout index ccc4076303e985a8b60c9f2dd091f323b5d6e7a6..94e5fb1dc055cefacfa6ef562ad1fe04424e726b 100644 --- a/overcooked_simulator/game_content/layouts/basic.layout +++ b/overcooked_simulator/game_content/layouts/basic.layout @@ -1,6 +1,6 @@ #QU#FO#TNLB# #__________M -#__________K +|__________K W__________I #__A_____A_D C__________E diff --git a/overcooked_simulator/game_content/layouts/rot_test.layout b/overcooked_simulator/game_content/layouts/rot_test.layout new file mode 100644 index 0000000000000000000000000000000000000000..d3bd23d0a2dbe378bbc7a128995e8240ebb916fb --- /dev/null +++ b/overcooked_simulator/game_content/layouts/rot_test.layout @@ -0,0 +1,5 @@ +##S+# +S___# ++___S +#___+ +#+SP# \ No newline at end of file diff --git a/overcooked_simulator/game_items.py b/overcooked_simulator/game_items.py index a77f8d78159ee6e6542209230e7bc50a6c0e76df..94da1512d82c1f538a292359df63cfa3eccd0c73 100644 --- a/overcooked_simulator/game_items.py +++ b/overcooked_simulator/game_items.py @@ -26,18 +26,38 @@ import datetime import logging import uuid from enum import Enum -from typing import Optional, TypedDict +from typing import Optional, TypedDict, TYPE_CHECKING + +if TYPE_CHECKING: + from overcooked_simulator.effect_manager import EffectManager log = logging.getLogger(__name__) +"""The logger for this module.""" ITEM_CATEGORY = "Item" +"""The string for the `category` value in the json state representation for all normal items.""" + COOKING_EQUIPMENT_ITEM_CATEGORY = "ItemCookingEquipment" +"""The string for the `category` value in the json state representation for all cooking equipments.""" + + +class EffectType(Enum): + Unusable = "Unusable" class ItemType(Enum): Ingredient = "Ingredient" + """All ingredients and process ingredients.""" Meal = "Meal" + """All combined ingredients that can be served.""" Equipment = "Equipment" + """All counters and cooking equipments.""" + Waste = "Waste" + """Burnt ingredients and meals.""" + Effect = "Effect" + """Does not change the item but the object attributes, like adding fire.""" + Tool = "Tool" + """Item that remains in hands in extends the interactive abilities of the player.""" @dataclasses.dataclass @@ -80,16 +100,24 @@ class ItemInfo: """The name of the item, is set automatically by the "group" name of the item.""" seconds: float = dataclasses.field(compare=False, default=0) """If progress is needed this argument defines how long it takes to complete the process in seconds.""" + + # TODO maybe as a lambda/based on Prefix? needs: list[str] = dataclasses.field(compare=False, default_factory=list) """The ingredients/items which are needed to create the item/start the progress.""" equipment: ItemInfo | None = dataclasses.field(compare=False, default=None) """On which the item can be created. `null`, `~` (None) converts to Plate.""" + manager: str | None | EffectManager = None + """The manager for the effect.""" + effect_type: None | EffectType = None + """How does the effect effect interaction, combine actions etc.""" recipe: collections.Counter | None = None """Internally set in CookingEquipment""" def __post_init__(self): self.type = ItemType(self.type) + if self.effect_type: + self.effect_type = EffectType(self.effect_type) class ActiveTransitionTypedDict(TypedDict): @@ -97,7 +125,7 @@ class ActiveTransitionTypedDict(TypedDict): seconds: int | float """The needed seconds to progress for the transition.""" - result: str + result: str | Item | Effect """The new name of the item after the transition.""" @@ -105,15 +133,25 @@ class Item: """Base class for game items which can be held by a player.""" item_category = ITEM_CATEGORY + """Class dependent category (is changed for the `CookingEquipment` class). """ def __init__( self, name: str, item_info: ItemInfo, uid: str = None, *args, **kwargs ): - self.name = self.__class__.__name__ if name is None else name - self.item_info = item_info - self.progress_equipment = None - self.progress_percentage = 0.0 - self.uuid = uuid.uuid4().hex if uid is None else uid + self.name: str = self.__class__.__name__ if name is None else name + """The name of the item, e.g., `Tomato` or `ChoppedTomato`""" + self.item_info: ItemInfo = item_info + """The information about the item from the `item_info.yml` config.""" + self.progress_equipment: str | None = None + """The equipment with which the item was last progressed.""" + self.progress_percentage: float = 0.0 + """The current progress percentage of the item if it is progress-able.""" + self.inverse_progress: bool = False + """Whether the progress will produce waste.""" + self.uuid: str = uuid.uuid4().hex if uid is None else uid + """A unique identifier for the item. Useful for GUIs that handles specific asset instances.""" + self.active_effects: list[Effect] = [] + """The effects that affect the item.""" def __repr__(self): if self.progress_equipment is None: @@ -126,12 +164,15 @@ class Item: @property def extra_repr(self): + """Additional string to add to the representation of the item (in __repr__).""" return "" def can_combine(self, other) -> bool: + """Check if the item can be combined with the other. After it returned True the `combine` method is called.""" return False def combine(self, other) -> Item | None: + """Combine the item with another item based on possible transitions/needs.""" pass def progress(self, equipment: str, percent: float): @@ -148,26 +189,33 @@ class Item: ) def reset(self): + """Reset the progress.""" self.progress_equipment = None self.progress_percentage = 0.0 + self.inverse_progress = False def to_dict(self) -> dict: + """For the state representation. Only the relevant attributes are put into the dict.""" return { "id": self.uuid, "category": self.item_category, "type": self.name, "progress_percentage": self.progress_percentage, + "inverse_progress": self.inverse_progress, + "active_effects": [e.to_dict() for e in self.active_effects], } class CookingEquipment(Item): """Pot, Pan, ... that can hold items. It holds the progress of the content (e.g., the soup) in itself ( progress_percentage) and not in the items in the content list.""" - item_category = "Cooking Equipment" + + item_category = COOKING_EQUIPMENT_ITEM_CATEGORY def __init__(self, transitions: dict[str, ItemInfo], *args, **kwargs): super().__init__(*args, **kwargs) - self.transitions = transitions + self.transitions: dict[str, ItemInfo] = transitions + """What is needed to cook a meal / create another ingredient.""" self.active_transition: Optional[ActiveTransitionTypedDict] = None """The info how and when to convert the content_list to a new item.""" @@ -186,9 +234,17 @@ class CookingEquipment(Item): def can_combine(self, other) -> bool: # already cooking or nothing to combine - if other is None: + if other is None or ( + isinstance(other, CookingEquipment) and not other.content_list + ): return False + if any( + e.item_info.effect_type == EffectType.Unusable for e in other.active_effects + ) or any( + e.item_info.effect_type == EffectType.Unusable for e in self.active_effects + ): + return False if isinstance(other, CookingEquipment): other = other.content_list else: @@ -206,45 +262,82 @@ class CookingEquipment(Item): self.content_list.extend(other.content_list) return_value = other other.reset_content() + other.reset() elif isinstance(other, list): self.content_list.extend(other) else: self.content_list.append(other) - ingredients = collections.Counter(item.name for item in self.content_list) - for result, transition in self.transitions.items(): - if ingredients == transition.recipe: - if transition.seconds == 0: - self.content_ready = Item(name=result, item_info=transition) - else: - self.active_transition = { - "seconds": transition.seconds, - "result": Item(name=result, item_info=transition), - } - break - else: - self.content_ready = None + self.check_active_transition() return return_value def can_progress(self) -> bool: - return self.active_transition is not None + """Check if the cooking equipment can progress items at all.""" + return self.active_transition is not None and not any( + e.item_info.effect_type == EffectType.Unusable for e in self.active_effects + ) def progress(self, passed_time: datetime.timedelta, now: datetime.datetime): percent = passed_time.total_seconds() / self.active_transition["seconds"] super().progress(equipment=self.name, percent=percent) if self.progress_percentage == 1.0: - self.content_list = [self.active_transition["result"]] + if isinstance(self.active_transition["result"], Effect): + self.active_transition[ + "result" + ].item_info.manager.register_active_effect( + self.active_transition["result"], self + ) + else: + self.content_list = [self.active_transition["result"]] self.reset() + self.check_active_transition() # todo set active transition for fire/burnt? + def check_active_transition(self): + ingredients = collections.Counter(item.name for item in self.content_list) + for result, transition in self.transitions.items(): + if transition.type == ItemType.Effect: + if set(ingredients.keys()).issubset( + transition.needs + ) and transition.manager.can_start_effect_transition(transition, self): + if transition.seconds == 0: + transition.manager.register_active_effect( + Effect(name=transition.name, item_info=transition), self + ) + else: + self.active_transition = { + "seconds": transition.seconds, + "result": Effect( + name=transition.name, item_info=transition + ), + } + self.inverse_progress = True + break # ? + else: + if ingredients == transition.recipe: + if transition.seconds == 0: + self.content_ready = Item(name=result, item_info=transition) + else: + self.active_transition = { + "seconds": transition.seconds, + "result": Item(name=result, item_info=transition), + } + self.inverse_progress = transition.type == ItemType.Waste + break + else: + self.content_ready = None + def reset_content(self): + """Reset the content attributes after the content was picked up from the equipment.""" self.content_list = [] self.content_ready = None def release(self): + """Release the content when the player "picks up" the equipment with a plate in the hands""" content = self.content_list self.reset_content() + self.reset() return content @property @@ -256,6 +349,7 @@ class CookingEquipment(Item): self.active_transition = None def get_potential_meal(self) -> Item | None: + """The meal that could be served depends on the attributes `content_ready` and `content_list`""" if self.content_ready: return self.content_ready if len(self.content_list) == 1: @@ -281,8 +375,9 @@ class CookingEquipment(Item): class Plate(CookingEquipment): """The plate can have to states: clean and dirty. In the clean state it can hold content/other items.""" - def __init__(self, transitions, clean, *args, **kwargs): - self.clean = clean + def __init__(self, transitions: dict[str, ItemInfo], clean: bool, *args, **kwargs): + self.clean: bool = clean + """If the plate is clean or dirty.""" self.meals = set(transitions.keys()) """All meals can be hold by a clean plate""" super().__init__( @@ -292,14 +387,12 @@ class Plate(CookingEquipment): **kwargs, ) - def finished_call(self): - self.clean = True - self.name = self.create_name() - def progress(self, equipment: str, percent: float): Item.progress(self, equipment, percent) def create_name(self): + """The name depends on the clean or dirty state of the plate. Clean plates are `Plate`, otherwise + `DirtyPlate`.""" return "Plate" if self.clean else "DirtyPlate" def can_combine(self, other): @@ -317,3 +410,19 @@ class Plate(CookingEquipment): elif self.clean: return True return False + + +class Effect: + def __init__(self, name: str, item_info: ItemInfo, uid: str = None): + self.uuid: str = uuid.uuid4().hex if uid is None else uid + self.name = name + self.item_info = item_info + self.progres_percentage = 0.0 + + def to_dict(self) -> dict: + return { + "id": self.uuid, + "type": self.name, + "progress_percentage": self.progres_percentage, + "inverse_progress": True, + } diff --git a/overcooked_simulator/game_server.py b/overcooked_simulator/game_server.py index 03679cc4a293913f7df494ab92282df3994ef086..b6c2675bb1c74c8a750d12c65cb7717f5f70f8d6 100644 --- a/overcooked_simulator/game_server.py +++ b/overcooked_simulator/game_server.py @@ -125,6 +125,7 @@ class EnvironmentHandler: layout_config=environment_config.layout_config, item_info=environment_config.item_info_config, as_files=False, + env_name=env_id, ) player_info = {} for player_id in range(environment_config.number_players): @@ -256,7 +257,7 @@ class EnvironmentHandler: manager_id in self.manager_envs and env_id in self.manager_envs[manager_id] and self.envs[env_id].status - not in [EnvironmentStatus.STOPPED, Environment.PAUSED] + not in [EnvironmentStatus.STOPPED, EnvironmentStatus.PAUSED] ): self.envs[env_id].status = EnvironmentStatus.PAUSED @@ -272,7 +273,7 @@ class EnvironmentHandler: manager_id in self.manager_envs and env_id in self.manager_envs[manager_id] and self.envs[env_id].status - not in [EnvironmentStatus.STOPPED, Environment.PAUSED] + not in [EnvironmentStatus.STOPPED, EnvironmentStatus.PAUSED] ): self.envs[env_id].status = EnvironmentStatus.PAUSED self.envs[env_id].last_step_time = time.time_ns() @@ -671,6 +672,7 @@ class CreateEnvironmentConfig(BaseModel): item_info_config: str # file content environment_config: str # file content layout_config: str # file content + seed: int class AdditionalPlayer(BaseModel): @@ -740,7 +742,10 @@ async def websocket_player_endpoint(websocket: WebSocket, client_id: str): log.debug(f"Client #{client_id} disconnected") -def main(host: str, port: int, manager_ids: list[str]): +def main( + host: str, port: int, manager_ids: list[str], enable_websocket_logging: bool = False +): + setup_logging(enable_websocket_logging) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) environment_handler.extend_allowed_manager(manager_ids) @@ -761,8 +766,7 @@ if __name__ == "__main__": disable_websocket_logging_arguments(parser) add_list_of_manager_ids_arguments(parser) args = parser.parse_args() - setup_logging(args.enable_websocket_logging) - main(args.url, args.port, args.manager_ids) + main(args.url, args.port, args.manager_ids, args.enable_websocket_logging) """ Or in console: uvicorn overcooked_simulator.fastapi_game_server:app --reload diff --git a/overcooked_simulator/gui_2d_vis/drawing.py b/overcooked_simulator/gui_2d_vis/drawing.py index f0b0bbeee2b6fd2811d7a2c50e9062fb52040e0a..4c6bd198404743e6d3a2179d803015e57a582b94 100644 --- a/overcooked_simulator/gui_2d_vis/drawing.py +++ b/overcooked_simulator/gui_2d_vis/drawing.py @@ -1,7 +1,6 @@ import argparse import colorsys import json -import math from datetime import datetime, timedelta from pathlib import Path @@ -17,6 +16,7 @@ from overcooked_simulator.state_representation import ( PlayerState, CookingEquipmentState, ItemState, + EffectState, ) USE_PLAYER_COOK_SPRITES = True @@ -24,6 +24,27 @@ SHOW_INTERACTION_RANGE = False SHOW_COUNTER_CENTERS = False +def calc_angle(vec_a: list[float], vec_b: list[float]) -> float: + a = pygame.math.Vector2(vec_a) + b = pygame.math.Vector2(vec_b) + return a.angle_to(b) + + +def grayscale(img): + arr = pygame.surfarray.pixels3d(img) + mean_arr = np.dot(arr[:, :, :], [0.216, 0.587, 0.144]) + mean_arr3d = mean_arr[..., np.newaxis] + new_arr = np.repeat(mean_arr3d[:, :, :], 3, axis=2) + new_arr = new_arr.astype(np.int8) + surface = pygame.Surface(new_arr.shape[0:2], pygame.SRCALPHA, 32) + + # Copy the rgb part of array to the new surface. + pygame.pixelcopy.array_to_surface(surface, new_arr) + surface_alpha = np.array(surface.get_view("A"), copy=False) + surface_alpha[:, :] = pygame.surfarray.pixels_alpha(img) + return surface + + def create_polygon(n, length): if n == 1: return np.array([0, 0]) @@ -44,12 +65,29 @@ def create_polygon(n, length): class Visualizer: + """Class for visualizing the game state retrieved from the gameserver. + 2D game screen is drawn with pygame shapes and images. + + Args: + config: Visualization configuration (loaded from yaml file) given as a dict. + + """ + def __init__(self, config): self.image_cache_dict = {} self.player_colors = [] self.config = config + self.fire_state = 0 + self.fire_time_steps = 8 + def create_player_colors(self, n) -> None: + """Create different colors for the players. The color hues are sampled uniformly in HSV-Space, + then the corresponding colors from the defined colors list are looked up. + + Args: + n: Number of players to create colors for. + """ hue_values = np.linspace(0, 1, n + 1) colors_vec = np.array([col for col in colors.values()]) @@ -72,6 +110,14 @@ class Visualizer: grid_size: int, controlled_player_idxs: list[int], ): + """Draws the game state on the given surface. + + Args: + screen: The pygame surface to draw the game on. + state: The gamestate retrieved from the environment. + grid_size: The gridsize to base every object size in the game on. + """ + width = int(np.ceil(state["kitchen"]["width"] * grid_size)) height = int(np.ceil(state["kitchen"]["height"] * grid_size)) self.draw_background( @@ -100,8 +146,17 @@ class Visualizer: grid_size, ) - def draw_background(self, surface, width, height, grid_size): - """Visualizes a game background.""" + def draw_background( + self, surface: pygame.Surface, width: int, height: int, grid_size: int + ): + """Visualizes a game background. + + Args: + surface: The pygame surface to draw the background on. + width: The kitchen width. + height: The kitchen height. + grid_size: The gridsize to base the background shapes on. + """ block_size = grid_size // 2 # Set the size of the grid block surface.fill(colors[self.config["Kitchen"]["ground_tiles_color"]]) for x in range(0, width, block_size): @@ -121,16 +176,36 @@ class Visualizer: size: float, pos: npt.NDArray, rot_angle=0, + burnt: bool = False, ): + """Draws an image on the given screen. + + Args: + screen: The pygame surface to draw the image on. + img_path: The path to the image file, given relative to the gui_2d_vis directory. + size: The size of the image, given in pixels. + pos: The position of the center of the image, given in pixels. + rot_angle: Optional angle to rotate the image around. + """ cache_entry = f"{img_path}" - if cache_entry in self.image_cache_dict.keys(): - image = self.image_cache_dict[cache_entry] + if cache_entry + ("-burnt" if burnt else "") in self.image_cache_dict: + image = self.image_cache_dict[cache_entry + ("-burnt" if burnt else "")] else: - image = pygame.image.load( - ROOT_DIR / "gui_2d_vis" / img_path - ).convert_alpha() - self.image_cache_dict[cache_entry] = image - + if burnt: + if cache_entry in self.image_cache_dict: + normal_image = self.image_cache_dict[cache_entry] + else: + normal_image = pygame.image.load( + ROOT_DIR / "gui_2d_vis" / img_path + ).convert_alpha() + self.image_cache_dict[cache_entry] = normal_image + image = grayscale(normal_image) + self.image_cache_dict[cache_entry + "-burnt"] = image + else: + image = pygame.image.load( + ROOT_DIR / "gui_2d_vis" / img_path + ).convert_alpha() + self.image_cache_dict[cache_entry] = image image = pygame.transform.scale(image, (size, size)) if rot_angle != 0: image = pygame.transform.rotate(image, rot_angle) @@ -147,14 +222,18 @@ class Visualizer: ): """Visualizes the players as circles with a triangle for the facing direction. If the player holds something in their hands, it is displayed - Args: state: The game state returned by the environment. + + Args: + screen: The pygame surface to draw the players on. + players: The state of the players returned by the environment. + grid_size: The gridsize to rescale the drawn players to. """ for p_idx, player_dict in enumerate(players): player_dict: PlayerState pos = np.array(player_dict["pos"]) * grid_size pos += grid_size / 2 # correct for grid offset - facing = np.array(player_dict["facing_direction"]) + facing = np.array(player_dict["facing_direction"], dtype=float) if USE_PLAYER_COOK_SPRITES: pygame.draw.circle( @@ -165,8 +244,7 @@ class Visualizer: ) img_path = self.config["Cook"]["parts"][0]["path"] - rel_x, rel_y = facing - angle = -np.rad2deg(math.atan2(rel_y, rel_x)) + 90 + angle = calc_angle(facing.tolist(), [0, 1]) size = self.config["Cook"]["parts"][0]["size"] * grid_size self.draw_image(screen, img_path, size, pos, angle) @@ -239,6 +317,8 @@ class Visualizer: grid_size: float, parts: list[dict[str]], scale: float = 1.0, + burnt: bool = False, + orientation: list[float] | None = None, ): """Draws an item, based on its visual parts specified in the visualization config. @@ -248,23 +328,45 @@ class Visualizer: pos: Where to draw the item parts. parts: The visual parts to draw. scale: Rescale the item by this factor. + orientation: Rotate the item to face this direction. """ for part in parts: part_type = part["type"] + angle, angle_offset = 0, 0 draw_pos = pos.copy() - if "center_offset" in part: - draw_pos += np.array(part["center_offset"]) * grid_size + + if orientation is not None: + angle_offset = calc_angle(orientation, [0, 1]) + if "rotate_image" in part.keys(): + if part["rotate_image"]: + angle = calc_angle(orientation, [0, 1]) + else: + angle = angle_offset + # if "rotate_offset" in part.keys(): + # angle_offset = 0 match part_type: case "image": + if "center_offset" in part: + d = pygame.math.Vector2(part["center_offset"]) * grid_size + d.rotate_ip(angle_offset) + d[0] = -d[0] + draw_pos += np.array(d) self.draw_image( screen, part["path"], part["size"] * scale * grid_size, draw_pos, + burnt=burnt, + rot_angle=angle, ) + case "rect": + if "center_offset" in part: + d = pygame.math.Vector2(part["center_offset"]) * grid_size + d.rotate_ip(angle_offset) + draw_pos += np.array(d) height = part["height"] * grid_size width = part["width"] * grid_size color = part["color"] @@ -275,16 +377,22 @@ class Visualizer: width, ) pygame.draw.rect(screen, color, rect) + case "circle": + if "center_offset" in part: + d = pygame.math.Vector2(part["center_offset"]) * grid_size + d.rotate_ip(-angle_offset) + draw_pos += np.array(d) radius = part["radius"] * grid_size color = colors[part["color"]] + pygame.draw.circle(screen, color, draw_pos, radius) def draw_item( self, pos: npt.NDArray[float] | list[float], grid_size: float, - item: ItemState | CookingEquipmentState, + item: ItemState | CookingEquipmentState | EffectState, scale: float = 1.0, plate=False, screen=None, @@ -302,34 +410,64 @@ class Visualizer: plate: item is on a plate (soup are is different on a plate and pot) """ - if not isinstance(item, list): # can we remove this check? - if item["type"] in self.config: + if not isinstance(item, list): # can we remove this check?w + if item["type"] in self.config or ( + item["type"].startswith("Burnt") + and item["type"].replace("Burnt", "") in self.config + ): item_key = item["type"] if "Soup" in item_key and plate: item_key += "Plate" + if item_key.startswith("Burnt"): + item_key = item_key.replace("Burnt", "") + + if item_key == "Fire": + item_key = ( + f"{item_key}{int(self.fire_state/self.fire_time_steps)+1}" + ) + self.draw_thing( pos=pos, parts=self.config[item_key]["parts"], scale=scale, screen=screen, grid_size=grid_size, + burnt=item["type"].startswith("Burnt"), ) - # + if "progress_percentage" in item and item["progress_percentage"] > 0.0: + if item["inverse_progress"]: + percentage = 1 - item["progress_percentage"] + else: + percentage = item["progress_percentage"] self.draw_progress_bar( - screen, pos, item["progress_percentage"], grid_size=grid_size + screen, + pos, + percentage, + grid_size=grid_size, + attention=item["inverse_progress"], ) if ( "content_ready" in item and item["content_ready"] - and item["content_ready"]["type"] in self.config + and ( + item["content_ready"]["type"] in self.config + or ( + item["content_ready"]["type"].startswith("Burnt") + and item["content_ready"]["type"].replace("Burnt", "") + in self.config + ) + ) ): self.draw_thing( pos=pos, - parts=self.config[item["content_ready"]["type"]]["parts"], + parts=self.config[item["content_ready"]["type"].replace("Burnt", "")][ + "parts" + ], screen=screen, grid_size=grid_size, + burnt=item["type"].startswith("Burnt"), ) elif "content_list" in item and item["content_list"]: triangle_offsets = create_polygon(len(item["content_list"]), length=10) @@ -343,6 +481,9 @@ class Visualizer: screen=screen, grid_size=grid_size, ) + if "active_effects" in item and item["active_effects"]: + for effect in item["active_effects"]: + self.draw_item(pos=pos, item=effect, screen=screen, grid_size=grid_size) @staticmethod def draw_progress_bar( @@ -350,19 +491,27 @@ class Visualizer: pos: npt.NDArray[float], percent: float, grid_size: float, + attention: bool = False, ): - """Visualize progress of progressing item as a green bar under the item.""" - pos -= grid_size / 2 + """Visualize progress of progressing item as a green bar under the item. + + Args: + screen: The pygame surface to draw the progress bar on. + pos: The center position of a tile to draw the progress bar under. + percent: Progressed percent of the progress bar. + grid_size: Scaling of the progress bar given in pixels. + """ + bar_pos = pos - (grid_size / 2) bar_height = grid_size * 0.2 progress_width = percent * grid_size progress_bar = pygame.Rect( - pos[0], - pos[1] + grid_size - bar_height, + bar_pos[0], + bar_pos[1] + grid_size - bar_height, progress_width, bar_height, ) - pygame.draw.rect(screen, colors["green1"], progress_bar) + pygame.draw.rect(screen, colors["red" if attention else "green1"], progress_bar) def draw_counter( self, screen: pygame.Surface, counter_dict: dict, grid_size: float @@ -370,16 +519,31 @@ class Visualizer: """Visualization of a counter at its position. If it is occupied by an item, it is also shown. The visual composition of the counter is read in from visualization.yaml file, where it is specified as different parts to be drawn. - Args: counter: The counter to visualize. + Args: + screen: The pygame surface to draw the counter on. + counter_dict: The counter to visualize, given as a dict from the game state. + grid_size: Scaling of the counter given in pixels. """ pos = np.array(counter_dict["pos"], dtype=float) * grid_size counter_type = counter_dict["type"] pos += grid_size // 2 # correct for grid offset - self.draw_thing(screen, pos, grid_size, self.config["Counter"]["parts"]) + self.draw_thing( + screen, + pos, + grid_size, + self.config["Counter"]["parts"], + orientation=counter_dict["orientation"], + ) if counter_type in self.config: - self.draw_thing(screen, pos, grid_size, self.config[counter_type]["parts"]) + self.draw_thing( + screen, + pos, + grid_size, + self.config[counter_type]["parts"], + orientation=counter_dict["orientation"], + ) else: if counter_type in self.config: parts = self.config[counter_type]["parts"] @@ -392,6 +556,7 @@ class Visualizer: pos=pos, parts=parts, grid_size=grid_size, + orientation=counter_dict["orientation"], ) def draw_counter_occupier( @@ -400,7 +565,16 @@ class Visualizer: occupied_by: dict | list, grid_size, pos: npt.NDArray[float], + item_scale: float, ): + """Visualization of a thing lying on a counter. + Args: + screen: The pygame surface to draw the item on the counter on. + occupied_by: The thing that occupies the counter. + grid_size: Scaling of the object given in pixels. + pos: The position of the counter which the thing lies on. + item_scale: Relative scaling of the item. + """ # Multiple plates on plate return: if isinstance(occupied_by, list): for i, o in enumerate(occupied_by): @@ -409,6 +583,7 @@ class Visualizer: pos=np.abs([pos[0], pos[1] - (i * 3)]), grid_size=grid_size, item=o, + scale=item_scale, ) # All other items: else: @@ -417,35 +592,105 @@ class Visualizer: grid_size=grid_size, item=occupied_by, screen=screen, + scale=item_scale, ) - def draw_counters(self, screen: pygame, counters, grid_size): + def draw_counters(self, screen: pygame, counters: dict, grid_size: int): """Visualizes the counters in the environment. - Args: state: The game state returned by the environment. + Args: + screen: The pygame surface to draw the counters on. + counters: The counter state returned by the environment. + grid_size: Scaling of the object given in pixels. """ + global FIRE_STATE + for counter in counters: self.draw_counter(screen, counter, grid_size) for counter in counters: if counter["occupied_by"]: + item_pos = np.array(counter["pos"]) + item_scale = 1.0 + + counter_type = counter["type"] + + if counter_type.endswith("Dispenser") and "Plate" not in counter_type: + if "item_offset" in self.config["Dispenser"].keys(): + offset_vec = pygame.math.Vector2( + self.config["Dispenser"]["item_offset"] + ) + offset_vec.rotate_ip( + offset_vec.angle_to( + pygame.math.Vector2(counter["orientation"]) + ) + + 180 + ) + item_pos += offset_vec + if "item_scale" in self.config["Dispenser"].keys(): + item_scale = self.config["Dispenser"]["item_scale"] + self.draw_counter_occupier( - screen, - counter["occupied_by"], - grid_size, - np.array(counter["pos"]) * grid_size + (grid_size / 2), + screen=screen, + occupied_by=counter["occupied_by"], + grid_size=grid_size, + pos=item_pos * grid_size + (grid_size / 2), + item_scale=item_scale, ) + if counter["active_effects"]: + for effect in counter["active_effects"]: + self.draw_item( + pos=np.array(counter["pos"]) * grid_size + (grid_size / 2), + grid_size=grid_size, + screen=screen, + item=effect, + ) + if SHOW_COUNTER_CENTERS: - pygame.draw.circle( + pos = np.array(counter["pos"]) * grid_size + pygame.draw.circle(screen, colors["green1"], pos, 3) + pygame.draw.circle(screen, colors["green1"], pos, 3) + facing = np.array(counter["orientation"]) + pygame.draw.polygon( screen, - colors["green1"], - np.array(counter["pos"]) * grid_size + (grid_size / 2), - 3, + colors["red"], + ( + ( + pos[0] + (facing[1] * 0.1 * grid_size), + pos[1] - (facing[0] * 0.1 * grid_size), + ), + ( + pos[0] - (facing[1] * 0.1 * grid_size), + pos[1] + (facing[0] * 0.1 * grid_size), + ), + pos + (facing * 0.5 * grid_size), + ), ) + self.fire_state = (self.fire_state + 1) % (3 * self.fire_time_steps) + def draw_orders( - self, screen, state, grid_size, width, height, screen_margin, config + self, + screen: pygame.surface, + state: dict, + grid_size: int, + width: int, + height: int, + screen_margin: int, + config: dict, ): + """Visualization of the current orders. + + Args: + screen: pygame surface to draw the orders on, probably not the game screen itself. + state: The game state returned by the environment. + grid_size: Scaling of the drawn orders, given in pixels. + width: Width of the pygame window + height: Height of the pygame window. + screen_margin: Size of the space around the game screen, for buttons, ... . + config: Visualization configuration (loaded from yaml file) given as a dict. + + """ orders_width = width - 100 orders_height = screen_margin order_screen = pygame.Surface( @@ -500,6 +745,7 @@ class Visualizer: percent=percentage, screen=order_screen, grid_size=grid_size, + attention=percentage < 0.25, ) orders_rect = order_screen.get_rect() @@ -512,6 +758,14 @@ class Visualizer: def save_state_image( self, grid_size: int, state: dict, filename: str | Path ) -> None: + """Saves a screenshot of the visualization of the given state. + + Args: + grid_size: Scaling of the world elements given in pixels. + state: Game state returned by the environment. + filename: Filename to save the screenshot to. + + """ width = int(np.ceil(state["kitchen"]["width"] * grid_size)) height = int(np.ceil(state["kitchen"]["height"] * grid_size)) @@ -523,6 +777,15 @@ class Visualizer: def save_screenshot(state: dict, config: dict, filename: str | Path) -> None: + """Standalone function to save a screenshot. Creates a visualizer from the config and visualizes + the game state, saves it to the given filename. + + Args: + state: The gamestate to visualize. + config: Visualization config for the visualizer. + filename: Filename to save the image to. + + """ viz = Visualizer(config) viz.create_player_colors(len(state["players"])) pygame.init() @@ -550,7 +813,7 @@ if __name__ == "__main__": ) parser.add_argument( "-o", - "--output_filename", + "--output_file", type=str, default="screenshot.jpg", ) @@ -559,4 +822,4 @@ if __name__ == "__main__": viz_config = yaml.safe_load(f) with open(args.state, "r") as f: state = json.load(f) - save_screenshot(state, viz_config, args.output_filename) + save_screenshot(state, viz_config, args.output_file) diff --git a/overcooked_simulator/gui_2d_vis/images/arrow_right.png b/overcooked_simulator/gui_2d_vis/images/arrow_right.png index 522ec051e8f1ad938c8e53cd0e8b563f1e383cb1..a1ea0946b67e89bed858a0312ed6ee70ea68c1c7 100644 Binary files a/overcooked_simulator/gui_2d_vis/images/arrow_right.png and b/overcooked_simulator/gui_2d_vis/images/arrow_right.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/counter2.png b/overcooked_simulator/gui_2d_vis/images/counter2.png new file mode 100644 index 0000000000000000000000000000000000000000..8e88163e958c39f2186412e4838a3f9c08660ede Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/counter2.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/counter4.png b/overcooked_simulator/gui_2d_vis/images/counter4.png new file mode 100644 index 0000000000000000000000000000000000000000..ad21220dfcb320f3cb5cadafbfb36afc30337064 Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/counter4.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/counter5.png b/overcooked_simulator/gui_2d_vis/images/counter5.png new file mode 100644 index 0000000000000000000000000000000000000000..646589514d340a8a02a1ab6c8e6143d139c93ffb Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/counter5.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/fire.png b/overcooked_simulator/gui_2d_vis/images/fire.png new file mode 100644 index 0000000000000000000000000000000000000000..4d2e5237cbd6125eaeaa7781075a7dc382ab904e Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/fire.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/fire2.png b/overcooked_simulator/gui_2d_vis/images/fire2.png new file mode 100644 index 0000000000000000000000000000000000000000..1f28ad6e87d7e5985a1dbf9d72d13643a7936b00 Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/fire2.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/fire3.png b/overcooked_simulator/gui_2d_vis/images/fire3.png new file mode 100644 index 0000000000000000000000000000000000000000..65b883b7d5663f7c8f99032c9939e3f4479e03f0 Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/fire3.png differ diff --git a/overcooked_simulator/gui_2d_vis/images/fire_extinguisher.png b/overcooked_simulator/gui_2d_vis/images/fire_extinguisher.png new file mode 100644 index 0000000000000000000000000000000000000000..a03d2d39285c6e286df992826af27c4f8ffc8d16 Binary files /dev/null and b/overcooked_simulator/gui_2d_vis/images/fire_extinguisher.png differ diff --git a/overcooked_simulator/gui_2d_vis/overcooked_gui.py b/overcooked_simulator/gui_2d_vis/overcooked_gui.py index 70e666a2a4c8c33e3e956584315a766aee27a116..9f66332533000c02de2a640d8e55e4884e684588 100644 --- a/overcooked_simulator/gui_2d_vis/overcooked_gui.py +++ b/overcooked_simulator/gui_2d_vis/overcooked_gui.py @@ -25,10 +25,10 @@ from overcooked_simulator.overcooked_environment import ( ) from overcooked_simulator.utils import ( custom_asdict_factory, - setup_logging, url_and_port_arguments, disable_websocket_logging_arguments, add_list_of_manager_ids_arguments, + setup_logging, ) @@ -686,7 +686,7 @@ class PyGameGUI: def setup_environment(self): environment_config_path = ROOT_DIR / "game_content" / "environment_config.yaml" layout_path = self.layout_file_paths[self.layout_selection.selected_option] - item_info_path = ROOT_DIR / "game_content" / "item_info_debug.yaml" + item_info_path = ROOT_DIR / "game_content" / "item_info.yaml" with open(item_info_path, "r") as file: item_info = file.read() with open(layout_path, "r") as file: @@ -694,6 +694,7 @@ class PyGameGUI: with open(environment_config_path, "r") as file: environment_config = file.read() + seed = 161616161616 creation_json = CreateEnvironmentConfig( manager_id=self.manager_id, number_players=self.number_players, @@ -701,7 +702,9 @@ class PyGameGUI: item_info_config=item_info, environment_config=environment_config, layout_config=layout, + seed=seed, ).model_dump(mode="json") + # print(CreateEnvironmentConfig.model_validate_json(json_data=creation_json)) env_info = requests.post( f"{self.request_url}/manage/create_env/", @@ -1112,5 +1115,4 @@ if __name__ == "__main__": disable_websocket_logging_arguments(parser) add_list_of_manager_ids_arguments(parser) args = parser.parse_args() - setup_logging(enable_websocket_logging=args.enable_websocket_logging) - main(args.url, args.port, args.manager_ids) + main(args.url, args.port, args.manager_ids, args.enable_websocket_logging) diff --git a/overcooked_simulator/gui_2d_vis/sample_state.json b/overcooked_simulator/gui_2d_vis/sample_state.json index ae3fb24376049c533182d8a9dfdff17fcffe63c8..78d930781479e3d121f06abd8b5713dbc64edbde 100644 --- a/overcooked_simulator/gui_2d_vis/sample_state.json +++ b/overcooked_simulator/gui_2d_vis/sample_state.json @@ -50,7 +50,7 @@ ], "occupied_by": { "id": "240eb5d4e4dd4215a0ab9fbaa93f02f5", - "category": "Cooking Equipment", + "category": "ItemCookingEquipment", "type": "Pan", "progress_percentage": 0.0, "content_list": [], @@ -67,7 +67,7 @@ ], "occupied_by": { "id": "54124ff47f9a4b2abb6e2df96a9db919", - "category": "Cooking Equipment", + "category": "ItemCookingEquipment", "type": "Pot", "progress_percentage": 0.0, "content_list": [], @@ -94,7 +94,7 @@ ], "occupied_by": { "id": "987ea1da780c44cabc06883f5c721414", - "category": "Cooking Equipment", + "category": "ItemCookingEquipment", "type": "Basket", "progress_percentage": 0.0, "content_list": [], @@ -111,7 +111,7 @@ ], "occupied_by": { "id": "a8609b417c0a4c1f8a0d10ef042f2df6", - "category": "Cooking Equipment", + "category": "ItemCookingEquipment", "type": "Peel", "progress_percentage": 0.0, "content_list": [], @@ -391,7 +391,7 @@ "occupied_by": [ { "id": "db41a69e880d4d64b535b265199d4148", - "category": "Cooking Equipment", + "category": "ItemCookingEquipment", "type": "DirtyPlate", "progress_percentage": 0.0, "content_list": [], @@ -399,7 +399,7 @@ }, { "id": "13fd4df8399d48c490091bb9ed3e1302", - "category": "Cooking Equipment", + "category": "ItemCookingEquipment", "type": "DirtyPlate", "progress_percentage": 0.0, "content_list": [], @@ -407,7 +407,7 @@ }, { "id": "5075019e20ed45c1aa7aa01ca5198c50", - "category": "Cooking Equipment", + "category": "ItemCookingEquipment", "type": "Plate", "progress_percentage": 0.0, "content_list": [], diff --git a/overcooked_simulator/gui_2d_vis/visualization.yaml b/overcooked_simulator/gui_2d_vis/visualization.yaml index 87415a0ed41fa01c46c5fb8e830d370e4acd776b..428c76c01bd5567c2b420679671282bda7d74a4c 100644 --- a/overcooked_simulator/gui_2d_vis/visualization.yaml +++ b/overcooked_simulator/gui_2d_vis/visualization.yaml @@ -22,84 +22,97 @@ Kitchen: Counter: parts: - - type: rect - height: 1 - width: 1 - color: whitesmoke + # - type: rect + # height: 1 + # width: 1 + # color: whitesmoke + - type: image + path: images/counter5.png + size: 1 CuttingBoard: parts: - type: image path: images/cutting_board_large.png - size: 0.9 + size: 0.75 + center_offset: [ 0, -0.05 ] PlateDispenser: - parts: - - type: rect - height: 0.95 - width: 0.95 - color: cadetblue1 + parts: [ ] +# - type: rect +# height: 0.95 +# width: 0.95 +# color: cadetblue1 Trashcan: parts: - type: image path: images/trash3.png - size: 0.9 - center_offset: [ 0, 0 ] - -TomatoDispenser: - parts: - - color: orangered1 - type: rect - height: 0.8 - width: 0.8 - -LettuceDispenser: - parts: - - color: palegreen3 - type: rect - height: 0.8 - width: 0.8 + size: 0.88 + center_offset: [ 0, -0.05 ] -OnionDispenser: - parts: - - color: deeppink3 - type: rect - height: 0.8 - width: 0.8 +#TomatoDispenser: +# parts: +# - color: orangered1 +# type: rect +# height: 0.8 +# width: 0.8 +# +#LettuceDispenser: +# parts: +# - color: palegreen3 +# type: rect +# height: 0.8 +# width: 0.8 +# +#OnionDispenser: +# parts: +# - color: deeppink3 +# type: rect +# height: 0.8 +# width: 0.8 +# +#MeatDispenser: +# parts: +# - color: indianred1 +# type: rect +# height: 0.8 +# width: 0.8 +# +#BunDispenser: +# parts: +# - color: sandybrown +# type: rect +# height: 0.8 +# width: 0.8 -MeatDispenser: +Dispenser: parts: - - color: indianred1 - type: rect - height: 0.8 - width: 0.8 + - type: circle + color: black + radius: 0.35 + center_offset: [ 0, -0.05 ] + - type: circle + color: gray83 + radius: 0.33 + center_offset: [ 0, -0.05 ] -BunDispenser: - parts: - - color: sandybrown - type: rect - height: 0.8 - width: 0.8 -Dispenser: - parts: - - color: gray83 - type: rect - height: 0.8 - width: 0.8 + item_offset: [ 0, -0.05 ] + item_scale: 0.9 ServingWindow: parts: - - type: image - path: images/arrow_right.png - size: 1 - center_offset: [ 0, 0 ] + # - type: image + # path: images/arrow_right.png + # size: 1 + # center_offset: [ 0, 0 ] - type: image path: images/bell_gold.png size: 0.5 - center_offset: [ 0.1, -0.4 ] + center_offset: [ -0.4, 0.1 ] + rotate_image: False Stove: parts: @@ -115,15 +128,49 @@ Sink: parts: - type: image path: images/sink1.png - size: 1 - center_offset: [ 0, -0.05 ] + size: 0.85 + center_offset: [ 0, -0.12 ] SinkAddon: parts: - type: image path: images/drip2.png + size: 0.75 + center_offset: [ 0, -0.05 ] + +# Tools +Extinguisher: + parts: + - type: image + path: images/fire_extinguisher.png size: 0.85 - center_offset: [ 0, 0.03 ] + center_offset: [ 0, -0.05 ] + +# Effects +Fire: + parts: + - type: image + path: images/fire.png + size: 1 + +Fire1: + parts: + - type: image + path: images/fire.png + size: 1.0 + +Fire2: + parts: + - type: image + path: images/fire2.png + size: 1.0 + +Fire3: + parts: + - type: image + path: images/fire3.png + size: 1.0 + # Items Tomato: @@ -275,6 +322,7 @@ Oven: color: black height: 0.8 width: 0.3 + center_offset: [ 0, -0.1 ] Basket: parts: diff --git a/overcooked_simulator/hooks.py b/overcooked_simulator/hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..285ed735fb58190183dce24f051d2c4f1b8bcd24 --- /dev/null +++ b/overcooked_simulator/hooks.py @@ -0,0 +1,149 @@ +from collections import defaultdict +from functools import partial +from typing import Callable + +# TODO add player_id as kwarg to all hooks -> pass player id to all methods + +ITEM_INFO_LOADED = "item_info_load" +"""Called after the item info is loaded and stored in the env attribute `item_info`. The kwargs are the passed config +(`item_info`) to the environment from which it was loaded and if it is a file path or the config string (`as_files`)""" + +LAYOUT_FILE_PARSED = "layout_file_parsed" +"""After the layout file was parsed. No additional kwargs. Everything is stored in the env.""" + +ITEM_INFO_CONFIG = "item_info_config" + +ENV_INITIALIZED = "env_initialized" +"""At the end of the __init__ method. No additional kwargs. Everything is stored in the env.""" + +PRE_PERFORM_ACTION = "pre_perform_action" +"""Before an action is performed / entered into the environment. `action` kwarg with the entered action.""" + +POST_PERFORM_ACTION = "post_perform_action" +"""After an action is performed / entered into the environment. `action` kwarg with the entered action.""" + +# TODO Pre and Post Perform Movement + +PLAYER_ADDED = "player_added" +"""Called after a player has been added. Kwargs: `player_name` and `pos`.""" + +GAME_ENDED_STEP = "game_ended_step" + +PRE_STATE = "pre_state" + +STATE_DICT = "state_dict" + +JSON_STATE = "json_state" + +PRE_RESET_ENV_TIME = "pre_reset_env_time" + +POST_RESET_ENV_TIME = "post_reset_env_time" + +PRE_COUNTER_PICK_UP = "pre_counter_pick_up" +POST_COUNTER_PICK_UP = "post_counter_pick_up" + +PRE_COUNTER_DROP_OFF = "pre_counter_drop_off" +POST_COUNTER_DROP_OFF = "post_counter_drop_off" + +PRE_DISPENSER_PICK_UP = "dispenser_pick_up" +POST_DISPENSER_PICK_UP = "dispenser_pick_up" + +CUTTING_BOARD_PROGRESS = "cutting_board_progress" +CUTTING_BOARD_100 = "cutting_board_100" + +CUTTING_BOARD_START_INTERACT = "cutting_board_start_interaction" +CUTTING_BOARD_END_INTERACT = "cutting_board_end_interact" + +PRE_SERVING = "pre_serving" +POST_SERVING = "post_serving" +NO_SERVING = "no_serving" + +# TODO drop off + +PLATE_OUT_OF_KITCHEN_TIME = "plate_out_of_kitchen_time" + +DIRTY_PLATE_ARRIVES = "dirty_plate_arrives" + +TRASHCAN_USAGE = "trashcan_usage" + +PLATE_CLEANED = "plate_cleaned" + +SINK_START_INTERACT = "sink_start_interact" + +SINK_END_INTERACT = "sink_end_interact" + +ADDED_PLATE_TO_SINK = "added_plate_to_sink" + +DROP_ON_SINK_ADDON = "drop_on_sink_addon" + +PICK_UP_FROM_SINK_ADDON = "pick_up_from_sink_addon" + +SERVE_NOT_ORDERED_MEAL = "serve_not_ordered_meal" + +SERVE_WITHOUT_PLATE = "serve_without_plate" + +ORDER_DURATION_SAMPLE = "order_duration_sample" + +COMPLETED_ORDER = "completed_order" + +INIT_ORDERS = "init_orders" + +NEW_ORDERS = "new_orders" + +ACTION_ON_NOT_REACHABLE_COUNTER = "action_on_not_reachable_counter" + +ACTION_PUT = "action_put" + +ACTION_INTERACT_START = "action_interact_start" + + +class Hooks: + def __init__(self, env): + self.hooks = defaultdict(list) + self.env = env + + def __call__(self, hook_ref, **kwargs): + for callback in self.hooks[hook_ref]: + callback(hook_ref=hook_ref, env=self.env, **kwargs) + + def register_callback(self, hook_ref: str | list[str], callback: Callable): + if isinstance(hook_ref, (tuple, list, set)): # TODO check for iterable + for ref in hook_ref: + self.hooks[ref].append(callback) + else: + self.hooks[hook_ref].append(callback) + + +def print_hook_callback(text, env, **kwargs): + print(env.env_time, text) + + +def add_dummy_callbacks(env): + env.register_callback_for_hook( + SERVE_NOT_ORDERED_MEAL, + partial( + print_hook_callback, + text="You tried to served a meal that was not ordered!", + ), + ) + env.register_callback_for_hook( + SINK_START_INTERACT, + partial( + print_hook_callback, + text="You started to use the Sink!", + ), + ) + env.register_callback_for_hook( + COMPLETED_ORDER, + partial( + print_hook_callback, + text="You completed an order!", + ), + ) + env.register_callback_for_hook( + TRASHCAN_USAGE, + partial( + print_hook_callback, + text="You used the trashcan!", + ), + ) diff --git a/overcooked_simulator/order.py b/overcooked_simulator/order.py index b44d014d24a1df78cefcc1b5cf55b536f166b465..6cedaaab7b1165a60c48c39a3ae54c408839d305 100644 --- a/overcooked_simulator/order.py +++ b/overcooked_simulator/order.py @@ -36,6 +36,9 @@ For an easier usage of the random orders, also some classes for type hints and d - `ScoreCalcGenFuncType` - `ExpiredPenaltyFuncType` +For the scoring of using the trashcan the `penalty_for_each_item` example function is defined. You can set/replace it +in the `environment_config`. + ## Code Documentation """ @@ -43,18 +46,40 @@ from __future__ import annotations import dataclasses import logging -import random import uuid from abc import abstractmethod from collections import deque from datetime import datetime, timedelta -from typing import Callable, Tuple, Any, Deque, Protocol, TypedDict +from random import Random +from typing import Callable, Tuple, Any, Deque, Protocol, TypedDict, Type from overcooked_simulator.game_items import Item, Plate, ItemInfo +from overcooked_simulator.hooks import ( + Hooks, + SERVE_NOT_ORDERED_MEAL, + SERVE_WITHOUT_PLATE, + COMPLETED_ORDER, + INIT_ORDERS, + NEW_ORDERS, + ORDER_DURATION_SAMPLE, +) log = logging.getLogger(__name__) +"""The logger for this module.""" ORDER_CATEGORY = "Order" +"""The string for the `category` value in the json state representation for all orders.""" + + +class OrderConfig(TypedDict): + """The configuration of the order in the `environment_config`under the `order` key.""" + + order_gen_class: Type[OrderGeneration] + """The class that should handle the order generation.""" + order_gen_kwargs: dict[str, Any] + """The additional kwargs for the order gen class.""" + serving_not_ordered_meals: Callable[[Item], Tuple[bool, float]] + """""" @dataclasses.dataclass @@ -76,7 +101,7 @@ class Order: expired_penalty: float """The penalty to the score if the order expires""" uuid: str = dataclasses.field(default_factory=lambda: uuid.uuid4().hex) - + """The unique identifier for the order.""" finished_info: dict[str, Any] = dataclasses.field(default_factory=dict) """Is set after the order is completed.""" _timed_penalties: list[Tuple[datetime, float]] = dataclasses.field( @@ -84,10 +109,9 @@ class Order: ) """Converted penalties the env is working with from the `timed_penalties`""" - def order_time(self, env_time: datetime) -> timedelta: - return self.start_time - env_time - def create_penalties(self, env_time: datetime): + """Create the general timed penalties list to check for penalties after some time the order is still not + fulfilled.""" for penalty_info in self.timed_penalties: match penalty_info: case (offset, penalty): @@ -114,8 +138,13 @@ class OrderGeneration: ``` """ - def __init__(self, available_meals: dict[str, ItemInfo], **kwargs): + def __init__(self, available_meals: dict[str, ItemInfo], hook: Hooks, random: Random, **kwargs): self.available_meals: list[ItemInfo] = list(available_meals.values()) + """Available meals restricted through the `environment_config.yml`.""" + self.hook = hook + """Reference to the hook manager.""" + self.random = random + """Random instance.""" @abstractmethod def init_orders(self, now) -> list[Order]: @@ -137,11 +166,24 @@ class OrderGeneration: class OrderAndScoreManager: """The Order and Score Manager that is called from the serving window.""" - def __init__(self, order_config, available_meals: dict[str, ItemInfo]): - self.score = 0 + def __init__( + self, + order_config, + available_meals: dict[str, ItemInfo], + hook: Hooks, + random: Random, + ): + self.random = random + """Random instance.""" + self.score: float = 0.0 + """The current score of the environment.""" self.order_gen: OrderGeneration = order_config["order_gen_class"]( - available_meals=available_meals, kwargs=order_config["order_gen_kwargs"] + available_meals=available_meals, + hook=hook, + random=random, + kwargs=order_config["order_gen_kwargs"], ) + """The order generation.""" self.serving_not_ordered_meals: Callable[ [Item], Tuple[bool, float] ] = order_config["serving_not_ordered_meals"] @@ -169,7 +211,11 @@ class OrderAndScoreManager: self.last_expired: list[Order] = [] """Cache last expired orders for `OrderGeneration.get_orders` call.""" + self.hook = hook + """Reference to the hook manager.""" + def update_next_relevant_time(self): + """For more efficient checking when to do something in the progress call.""" next_relevant_time = datetime.max for order in self.open_orders: next_relevant_time = min( @@ -180,6 +226,8 @@ class OrderAndScoreManager: self.next_relevant_time = next_relevant_time def serve_meal(self, item: Item, env_time: datetime) -> bool: + """Is called by the ServingWindow to serve a meal. Returns True if the meal can be served and should be + "deleted" from the hands of the player.""" if isinstance(item, Plate): meal = item.get_potential_meal() if meal is not None: @@ -188,6 +236,12 @@ class OrderAndScoreManager: if order is None: if self.serving_not_ordered_meals: accept, score = self.serving_not_ordered_meals(meal) + self.hook( + SERVE_NOT_ORDERED_MEAL, + accept=accept, + score=score, + meal=meal, + ) if accept: log.info( f"Serving meal without order {meal.name!r} with score {score}" @@ -215,17 +269,22 @@ class OrderAndScoreManager: self.last_finished.append(order) del self.open_orders[index] self.served_meals.append((meal, env_time)) + self.hook(COMPLETED_ORDER, score=score, order=order, meal=meal) return True + else: + self.hook(SERVE_WITHOUT_PLATE, item=item) log.info(f"Do not serve item {item}") return False def increment_score(self, score: int | float): + """Add a value to the current score and log it.""" self.score += score log.debug(f"Score: {self.score}") def create_init_orders(self, env_time): """Create the initial orders in an environment.""" init_orders = self.order_gen.init_orders(env_time) + self.hook(INIT_ORDERS) self.setup_penalties(new_orders=init_orders, env_time=env_time) self.open_orders.extend(init_orders) @@ -237,6 +296,8 @@ class OrderAndScoreManager: new_finished_orders=self.last_finished, expired_orders=self.last_expired, ) + if new_orders: + self.hook(NEW_ORDERS, new_orders=new_orders) self.setup_penalties(new_orders=new_orders, env_time=now) self.open_orders.extend(new_orders) self.last_finished = [] @@ -255,6 +316,7 @@ class OrderAndScoreManager: for i, (penalty_time, penalty) in enumerate(order.timed_penalties): # check penalties if penalty_time < now: + # TODO add hook self.score -= penalty remove_penalties.append(i) @@ -271,6 +333,8 @@ class OrderAndScoreManager: self.update_next_relevant_time() def find_order_for_meal(self, meal) -> Tuple[Order, int] | None: + """Get the order that will be fulfilled for a meal. At the moment the oldest order in the list that has the + same meal (name).""" for index, order in enumerate(self.open_orders): if order.meal.name == meal.name: return order, index @@ -282,6 +346,7 @@ class OrderAndScoreManager: order.create_penalties(env_time) def order_state(self) -> list[dict]: + """Similar to the `to_dict` in `Item` and `Counter`. Relevant for the state of the environment""" return [ { "id": order.uuid, @@ -293,8 +358,11 @@ class OrderAndScoreManager: for order in self.open_orders ] - def apply_penalty_for_using_trash(self, remove: Item | list[Item]): - self.increment_score(self.penalty_for_trash(remove)) + def apply_penalty_for_using_trash(self, remove: Item | list[Item]) -> float: + """Is called if a item is put into the trashcan.""" + penalty = self.penalty_for_trash(remove) + self.increment_score(penalty) + return penalty class ScoreCalcFuncType(Protocol): @@ -459,11 +527,11 @@ class RandomOrderGeneration(OrderGeneration): ``` """ - def __init__(self, available_meals: dict[str, ItemInfo], **kwargs): - super().__init__(available_meals, **kwargs) + def __init__(self, available_meals: dict[str, ItemInfo], hook: Hooks, random: Random, **kwargs): + super().__init__(available_meals, hook, random, **kwargs) self.kwargs: RandomOrderKwarg = RandomOrderKwarg(**kwargs["kwargs"]) self.next_order_time: datetime | None = datetime.max - self.number_cur_orders = 0 + self.number_cur_orders: int = 0 self.needed_orders: int = 0 """For the sample on dur but when it was restricted due to max order number.""" @@ -472,7 +540,7 @@ class RandomOrderGeneration(OrderGeneration): if not self.kwargs.sample_on_serving: self.create_random_next_time_delta(now) return self.create_orders_for_meals( - random.choices(self.available_meals, k=self.kwargs.num_start_meals), + self.random.choices(self.available_meals, k=self.kwargs.num_start_meals), now, self.kwargs.sample_on_serving, ) @@ -495,7 +563,7 @@ class RandomOrderGeneration(OrderGeneration): self.needed_orders = max(self.needed_orders, 0) self.number_cur_orders += len(new_finished_orders) return self.create_orders_for_meals( - random.choices(self.available_meals, k=len(new_finished_orders)), + self.random.choices(self.available_meals, k=len(new_finished_orders)), now, ) if self.next_order_time <= now: @@ -508,7 +576,7 @@ class RandomOrderGeneration(OrderGeneration): self.next_order_time = datetime.max self.number_cur_orders += 1 return self.create_orders_for_meals( - [random.choice(self.available_meals)], + [self.random.choice(self.available_meals)], now, ) return [] @@ -523,9 +591,13 @@ class RandomOrderGeneration(OrderGeneration): else: duration = timedelta( seconds=getattr( - random, self.kwargs.order_duration_random_func["func"] + self.random, self.kwargs.order_duration_random_func["func"] )(**self.kwargs.order_duration_random_func["kwargs"]) ) + self.hook( + ORDER_DURATION_SAMPLE, + duration=duration, + ) log.info(f"Create order for meal {meal} with duration {duration}") orders.append( Order( @@ -549,7 +621,7 @@ class RandomOrderGeneration(OrderGeneration): def create_random_next_time_delta(self, now: datetime): self.next_order_time = now + timedelta( - seconds=getattr(random, self.kwargs.sample_on_dur_random_func["func"])( + seconds=getattr(self.random, self.kwargs.sample_on_dur_random_func["func"])( **self.kwargs.sample_on_dur_random_func["kwargs"] ) ) diff --git a/overcooked_simulator/overcooked_environment.py b/overcooked_simulator/overcooked_environment.py index 48ff903a932d913e2c3cb0783152ab2412d5ccf8..45a1f7c3bd177773500c4a04a628a04b4e6b745b 100644 --- a/overcooked_simulator/overcooked_environment.py +++ b/overcooked_simulator/overcooked_environment.py @@ -4,12 +4,12 @@ import dataclasses import inspect import json import logging -import random import sys from datetime import timedelta, datetime from enum import Enum from pathlib import Path -from typing import Literal +from random import Random +from typing import Literal, TypedDict, Callable, Tuple import numpy as np import numpy.typing as npt @@ -21,11 +21,34 @@ from overcooked_simulator.counters import ( Counter, PlateConfig, ) +from overcooked_simulator.effect_manager import EffectManager from overcooked_simulator.game_items import ( ItemInfo, ItemType, ) -from overcooked_simulator.order import OrderAndScoreManager +from overcooked_simulator.hooks import ( + ITEM_INFO_LOADED, + LAYOUT_FILE_PARSED, + ENV_INITIALIZED, + PRE_PERFORM_ACTION, + POST_PERFORM_ACTION, + PLAYER_ADDED, + GAME_ENDED_STEP, + PRE_STATE, + STATE_DICT, + JSON_STATE, + PRE_RESET_ENV_TIME, + POST_RESET_ENV_TIME, + Hooks, + ACTION_ON_NOT_REACHABLE_COUNTER, + ACTION_PUT, + ACTION_INTERACT_START, + ITEM_INFO_CONFIG, +) +from overcooked_simulator.order import ( + OrderAndScoreManager, + OrderConfig, +) from overcooked_simulator.player import Player, PlayerConfig from overcooked_simulator.state_representation import StateRepresentation from overcooked_simulator.utils import create_init_env_time, get_closest @@ -83,6 +106,17 @@ class Action: # TODO Abstract base class for different environments +class EnvironmentConfig(TypedDict): + plates: PlateConfig + game: dict[Literal["time_limit_seconds"], int] + meals: dict[Literal["all"] | Literal["list"], bool | list[str]] + orders: OrderConfig + player_config: PlayerConfig + layout_chars: dict[str, str] + extra_setup_functions: dict[str, dict] + effect_manager: dict + + class Environment: """Environment class which handles the game logic for the overcooked-inspired environment. @@ -97,7 +131,20 @@ class Environment: layout_config: Path | str, item_info: Path | str, as_files: bool = True, + env_name: str = "overcooked_sim", + seed: int = 56789223842348, ): + self.env_name = env_name + """Reference to the run. E.g, the env id.""" + self.env_time: datetime = create_init_env_time() + """the internal time of the environment. An environment starts always with the time from + `create_init_env_time`.""" + + self.random: Random = Random(seed) + """Random instance.""" + self.hook: Hooks = Hooks(self) + """Hook manager. Register callbacks and create hook points with additional kwargs.""" + self.players: dict[str, Player] = {} """the player, keyed by their id/name.""" @@ -105,15 +152,22 @@ class Environment: """Are the configs just the path to the files.""" if self.as_files: with open(env_config, "r") as file: - self.environment_config = yaml.load(file, Loader=yaml.Loader) - else: - self.environment_config = yaml.load(env_config, Loader=yaml.Loader) + env_config = file.read() + self.environment_config: EnvironmentConfig = yaml.load( + env_config, Loader=yaml.Loader + ) + """The config of the environment. All environment specific attributes is configured here.""" + + self.extra_setup_functions() + self.layout_config = layout_config """The layout config for the environment""" # self.counter_side_length = 1 # -> this changed! is 1 now self.item_info: dict[str, ItemInfo] = self.load_item_info(item_info) """The loaded item info dict. Keys are the item names.""" + self.hook(ITEM_INFO_LOADED, item_info=item_info, as_files=as_files) + # self.validate_item_info() if self.environment_config["meals"]["all"]: self.allowed_meal_names = set( @@ -135,6 +189,8 @@ class Environment: for item, info in self.item_info.items() if info.type == ItemType.Meal and item in self.allowed_meal_names }, + hook=self.hook, + random=self.random, ) """The manager for the orders and score update.""" @@ -158,6 +214,9 @@ class Environment: ) ), order_and_score=self.order_and_score, + effect_manager_config=self.environment_config["effect_manager"], + hook=self.hook, + random=self.random, ) ( @@ -165,6 +224,7 @@ class Environment: self.designated_player_positions, self.free_positions, ) = self.parse_layout_file() + self.hook(LAYOUT_FILE_PARSED) self.counter_positions = np.array([c.pos for c in self.counters]) @@ -196,9 +256,6 @@ class Environment: ) """Counters that needs to be called in the step function via the `progress` method.""" - self.env_time: datetime = 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.start_time = self.env_time """The relative env time when it started.""" @@ -208,6 +265,18 @@ class Environment: """The relative env time when it will stop/end""" log.debug(f"End time: {self.env_time_end}") + self.effect_manager: dict[ + str, EffectManager + ] = self.counter_factory.setup_effect_manger(self.counters) + + self.hook( + ENV_INITIALIZED, + environment_config=env_config, + layout_config=self.layout_config, + seed=seed, + env_start_time_worldtime=datetime.now(), + ) + @property def game_ended(self) -> bool: """Whether the game is over or not based on the calculated `Environment.env_time_end`""" @@ -232,9 +301,9 @@ class Environment: """Load `item_info.yml`, create ItemInfo classes and replace equipment strings with item infos.""" if self.as_files: with open(data, "r") as file: - item_lookup = yaml.safe_load(file) - else: - item_lookup = yaml.safe_load(data) + data = file.read() + self.hook(ITEM_INFO_CONFIG, item_info_config=data) + item_lookup = yaml.safe_load(data) for item_name in item_lookup: item_lookup[item_name] = ItemInfo(name=item_name, **item_lookup[item_name]) @@ -300,7 +369,9 @@ class Environment: # TODO add colors for ingredients, equipment and meals # plt.show() - def parse_layout_file(self): + def parse_layout_file( + self, + ) -> Tuple[list[Counter], list[npt.NDArray], list[npt.NDArray]]: """Creates layout of kitchen counters in the environment based on layout file. Counters are arranged in a fixed size grid starting at [0,0]. The center of the first counter is at [counter_size/2, counter_size/2], counters are directly next to each other (of no empty space is specified @@ -315,16 +386,19 @@ class Environment: if self.as_files: with open(self.layout_config, "r") as layout_file: - lines = layout_file.readlines() - else: - lines = self.layout_config.split("\n") + self.layout_config = layout_file.read() + lines = self.layout_config.split("\n") + + grid = [] for line in lines: line = line.replace("\n", "").replace(" ", "") # remove newline char current_x: float = starting_at + grid_line = [] for character in line: character = character.capitalize() pos = np.array([current_x, current_y]) + assert self.counter_factory.can_map( character ), f"{character=} in layout file can not be mapped" @@ -332,7 +406,9 @@ class Environment: counters.append( self.counter_factory.get_counter_object(character, pos) ) + grid_line.append(1) else: + grid_line.append(0) match self.counter_factory.map_not_counter(character): case "Agent": designated_player_positions.append(pos) @@ -340,14 +416,76 @@ class Environment: free_positions.append(np.array([current_x, current_y])) current_x += 1 + + grid.append(grid_line) current_y += 1 self.kitchen_width: float = len(lines[0]) + starting_at self.kitchen_height = len(lines) + starting_at + + self.determine_counter_orientations( + counters, grid, np.array([self.kitchen_width / 2, self.kitchen_height / 2]) + ) + self.counter_factory.post_counter_setup(counters) return counters, designated_player_positions, free_positions + def determine_counter_orientations(self, counters, grid, kitchen_center): + grid = np.array(grid).T + + grid_width = grid.shape[0] + grid_height = grid.shape[1] + + last_counter = None + fst_counter_in_row = None + for c in counters: + grid_idx = np.floor(c.pos).astype(int) + neighbour_offsets = np.array([[0, 1], [0, -1], [1, 0], [-1, 0]], dtype=int) + + neighbours_free = [] + for offset in neighbour_offsets: + neighbour_pos = grid_idx + offset + if ( + neighbour_pos[0] > (grid_width - 1) + or neighbour_pos[0] < 0 + or neighbour_pos[1] > (grid_height - 1) + or neighbour_pos[1] < 0 + ): + pass + else: + if grid[neighbour_pos[0]][neighbour_pos[1]] == 0: + neighbours_free.append(offset) + if len(neighbours_free) > 0: + vector_to_center = c.pos - kitchen_center + vector_to_center /= np.linalg.norm(vector_to_center) + n_idx = np.argmin( + np.linalg.norm(vector_to_center - n) for n in neighbours_free + ) + nearest_vec = neighbours_free[n_idx] + # print(nearest_vec, type(nearest_vec)) + c.set_orientation(nearest_vec) + + elif grid_idx[0] == 0: + if grid_idx[1] == 0: + # counter top left + c.set_orientation(np.array([1, 0])) + else: + c.set_orientation(fst_counter_in_row.orientation) + fst_counter_in_row = c + else: + c.set_orientation(last_counter.orientation) + + last_counter = c + + # for c in counters: + # near_counters = [ + # other + # for other in counters + # if np.isclose(np.linalg.norm(c.pos - other.pos), 1) + # ] + # # print(c.pos, len(near_counters)) + def perform_action(self, action: Action): """Performs an action of a player in the environment. Maps different types of action inputs to the correct execution of the players. @@ -357,6 +495,7 @@ class Environment: action: The action to be performed """ assert action.player in self.players.keys(), "Unknown player." + self.hook(PRE_PERFORM_ACTION, action=action) player = self.players[action.player] if action.action_type == ActionType.MOVEMENT: @@ -368,15 +507,20 @@ class Environment: counter = self.get_facing_counter(player) if player.can_reach(counter): if action.action_type == ActionType.PUT: - player.pick_action(counter) - + player.put_action(counter) + self.hook(ACTION_PUT, action=action, counter=counter) elif action.action_type == ActionType.INTERACT: if action.action_data == InterActionData.START: - player.perform_interact_hold_start(counter) - player.last_interacted_counter = counter + player.perform_interact_start(counter) + self.hook(ACTION_INTERACT_START, action=action, counter=counter) + else: + self.hook( + ACTION_ON_NOT_REACHABLE_COUNTER, action=action, counter=counter + ) if action.action_data == InterActionData.STOP: - if player.last_interacted_counter: - player.perform_interact_hold_stop(player.last_interacted_counter) + player.perform_interact_stop() + + self.hook(POST_PERFORM_ACTION, action=action) def get_facing_counter(self, player: Player): """Determines the counter which the player is looking at. @@ -534,11 +678,13 @@ class Environment: self.players[player.name] = player if player.pos is None: if len(self.designated_player_positions) > 0: - free_idx = random.randint(0, len(self.designated_player_positions) - 1) + free_idx = self.random.randint( + 0, len(self.designated_player_positions) - 1 + ) player.move_abs(self.designated_player_positions[free_idx]) del self.designated_player_positions[free_idx] elif len(self.free_positions) > 0: - free_idx = random.randint(0, len(self.free_positions) - 1) + free_idx = self.random.randint(0, len(self.free_positions) - 1) player.move_abs(self.free_positions[free_idx]) del self.free_positions[free_idx] else: @@ -546,19 +692,47 @@ class Environment: player.update_facing_point() self.set_collision_arrays() + self.hook(PLAYER_ADDED, player_name=player_name, pos=pos) + + def detect_collision_world_bounds(self, player: Player): + """Checks for detections of the player and the world bounds. + + Args: + player: The player which to not let escape the world. + + Returns: True if the player touches the world bounds, False if not. + """ + collisions_lower = any( + (player.pos - (player.radius)) + < [self.world_borders_x[0], self.world_borders_y[0]] + ) + collisions_upper = any( + (player.pos + (player.radius)) + > [self.world_borders_x[1], self.world_borders_y[1]] + ) + return collisions_lower or collisions_upper def step(self, passed_time: timedelta): """Performs a step of the environment. Affects time based events such as cooking or cutting things, orders and time limits. """ + # self.hook(PRE_STEP, passed_time=passed_time) self.env_time += passed_time - if not self.game_ended: + if self.game_ended: + self.hook(GAME_ENDED_STEP) + else: + for player in self.players.values(): + player.progress(passed_time, self.env_time) + self.perform_movement(passed_time) for counter in self.progressing_counters: counter.progress(passed_time=passed_time, now=self.env_time) self.order_and_score.progress(passed_time=passed_time, now=self.env_time) + for effect_manager in self.effect_manager.values(): + effect_manager.progress(passed_time=passed_time, now=self.env_time) + # self.hook(POST_STEP, passed_time=passed_time) def get_state(self): """Get the current state of the game environment. The state here is accessible by the current python objects. @@ -577,6 +751,7 @@ class Environment: } def get_json_state(self, player_id: str = None): + self.hook(PRE_STATE, player_id=player_id) state = { "players": [p.to_dict() for p in self.players.values()], "counters": [c.to_dict() for c in self.counters], @@ -589,11 +764,28 @@ class Environment: self.env_time_end - self.env_time, timedelta(0) ).total_seconds(), } + self.hook(STATE_DICT, state=state, player_id=player_id) json_data = json.dumps(state) + self.hook(JSON_STATE, json_data=json_data, player_id=player_id) assert StateRepresentation.model_validate_json(json_data=json_data) return json_data def reset_env_time(self): """Reset the env time to the initial time, defined by `create_init_env_time`.""" + self.hook(PRE_RESET_ENV_TIME) self.env_time = create_init_env_time() + self.hook(POST_RESET_ENV_TIME) log.debug(f"Reset env time to {self.env_time}") + + def register_callback_for_hook(self, hook_ref: str | list[str], callback: Callable): + self.hook.register_callback(hook_ref, callback) + + def extra_setup_functions(self): + if self.environment_config["extra_setup_functions"]: + for function_name, function_def in self.environment_config[ + "extra_setup_functions" + ].items(): + log.info(f"Setup function {function_name}") + function_def["func"]( + name=function_name, env=self, **function_def["kwargs"] + ) diff --git a/overcooked_simulator/player.py b/overcooked_simulator/player.py index d4d3844f4aeafafed27d8e578b87afd6fbbf3687..5633a491248ae8b9448d457607b86b8e74121c08 100644 --- a/overcooked_simulator/player.py +++ b/overcooked_simulator/player.py @@ -7,19 +7,20 @@ holding object**. If so, it picks up the content and combines it on its hands. """ import dataclasses -import datetime import logging from collections import deque +from datetime import datetime, timedelta from typing import Optional import numpy as np import numpy.typing as npt from overcooked_simulator.counters import Counter -from overcooked_simulator.game_items import Item, Plate +from overcooked_simulator.game_items import Item, ItemType from overcooked_simulator.state_representation import PlayerState log = logging.getLogger(__name__) +"""The logger for this module.""" @dataclasses.dataclass @@ -73,9 +74,15 @@ class Player: calculated with.""" self.current_movement: npt.NDArray[float] = np.zeros(2, float) - self.movement_until: datetime.datetime = datetime.datetime.min + """The movement vector that will be used to calculate the movement in the next step call.""" + self.movement_until: datetime = datetime.min + """The env time until the player wants to move.""" + + self.interacting: bool = False def set_movement(self, move_vector, move_until): + """Called by the `perform_action` method. Movements will be performed (pos will be updated) in the `step` + function of the environment""" self.current_movement = move_vector self.movement_until = move_until @@ -86,6 +93,8 @@ class Player: Args: movement: 2D-Vector of length 1 """ + if self.interacting and np.any(movement): + self.perform_interact_stop() self.pos += movement if np.linalg.norm(movement) != 0: self.turn(movement) @@ -123,7 +132,8 @@ class Player: Args: counter: The counter, can the player reach it? - Returns: True if the counter is in range of the player, False if not. + Returns: + True if the counter is in range of the player, False if not. """ return ( @@ -131,7 +141,7 @@ class Player: <= self.player_config.interaction_range ) - def pick_action(self, counter: Counter): + def put_action(self, counter: Counter): """Performs the pickup-action with the counter. Handles the logic of what the player is currently holding, what is currently on the counter and what can be picked up or combined in hand. @@ -154,33 +164,45 @@ class Player: log.debug( f"Self: {self.holding}, {counter.__class__.__name__}: {counter.occupied_by}" ) - if isinstance(self.holding, Plate): - log.debug(self.holding.clean) + # if isinstance(self.holding, Plate): + # log.debug(self.holding.clean) - @staticmethod - def perform_interact_hold_start(counter: Counter): + def perform_interact_start(self, counter: Counter): """Starts an interaction with the counter. Should be called for a keydown event, for holding down a key on the keyboard. Args: counter: The counter to start the interaction with. """ - counter.interact_start() + self.interacting = True + self.last_interacted_counter = counter - @staticmethod - def perform_interact_hold_stop(counter: Counter): + def perform_interact_stop(self): """Stops an interaction with the counter. Should be called for a keyup event, for letting go of a keyboard key. Args: counter: The counter to stop the interaction with. """ - counter.interact_stop() + self.interacting = False + self.last_interacted_counter = None + + def progress(self, passed_time: timedelta, now: datetime): + if self.interacting and self.last_interacted_counter: + # TODO only interact on counter (Sink/CuttingBoard) if hands are free configure in config? + if self.holding: + if self.holding.item_info.type == ItemType.Tool: + self.last_interacted_counter.do_tool_interaction( + passed_time, self.holding + ) + else: + self.last_interacted_counter.do_hand_free_interaction(passed_time, now) def __repr__(self): return f"Player(name:{self.name},pos:{str(self.pos)},holds:{self.holding})" def to_dict(self) -> PlayerState: + """For the state representation. Only the relevant attributes are put into the dict.""" # TODO add color to player class for vis independent player color return { "id": self.name, diff --git a/overcooked_simulator/recording.py b/overcooked_simulator/recording.py new file mode 100644 index 0000000000000000000000000000000000000000..17a65ce0fa233a4013b6872bde7afbc33ccb584c --- /dev/null +++ b/overcooked_simulator/recording.py @@ -0,0 +1,71 @@ +import json +import logging +import os +import traceback +from pathlib import Path +from typing import Any + +import platformdirs + +from overcooked_simulator import ROOT_DIR +from overcooked_simulator.overcooked_environment import Environment +from overcooked_simulator.utils import NumpyAndDataclassEncoder + +log = logging.getLogger(__name__) + + +def class_recording_with_hooks( + name: str, + env: Environment, + hooks: list[str], + log_class, + log_class_kwargs: dict[str, Any], +): + recorder = log_class(name=name, env=env, **log_class_kwargs) + for hook in hooks: + env.register_callback_for_hook(hook, recorder) + + +class LogRecorder: + def __init__( + self, + name: str, + env: Environment, + log_path: str = "USER_LOG_DIR/ENV_NAME/LOG_RECORD_NAME.jsonl", + add_hook_ref: bool = False, + ): + self.add_hook_ref = add_hook_ref + log_path = log_path.replace("ENV_NAME", env.env_name).replace( + "LOG_RECORD_NAME", name + ) + if log_path.startswith("USER_LOG_DIR/"): + log_path = ( + Path(platformdirs.user_log_dir("overcooked_simulator")) + / log_path[len("USER_LOG_DIR/") :] + ) + elif log_path.startswith("ROOT_DIR/"): + log_path = ROOT_DIR / log_path[len("ROOT_DIR/") :] + else: + log_path = Path(log_path) + self.log_path = log_path + log.info(f"Recorder record for {name} in file://{log_path}") + os.makedirs(log_path.parent, exist_ok=True) + + def __call__(self, hook_ref: str, env: Environment, **kwargs): + try: + record = ( + json.dumps( + { + "env_time": env.env_time.isoformat(), + **kwargs, + **({"hook_ref": hook_ref} if self.add_hook_ref else {}), + }, + cls=NumpyAndDataclassEncoder, + ) + + "\n" + ) + with open(self.log_path, "a") as log_file: + log_file.write(record) + except TypeError as e: + traceback.print_exception(e) + log.info(f"Not JSON serializable Record {kwargs}") diff --git a/overcooked_simulator/state_representation.py b/overcooked_simulator/state_representation.py index 4fc0a137a097a81047c790c1a8741399c2ad220d..a35f8cd0577e0c231d63e55bcec080c46d39385b 100644 --- a/overcooked_simulator/state_representation.py +++ b/overcooked_simulator/state_representation.py @@ -1,3 +1,6 @@ +""" +Type hint classes for the representation of the json state. +""" from datetime import datetime from pydantic import BaseModel @@ -12,11 +15,20 @@ class OrderState(TypedDict): max_duration: float +class EffectState(TypedDict): + id: str + type: str + progress_percentage: float | int + inverse_progress: bool + + class ItemState(TypedDict): id: str category: Literal["Item"] | Literal["ItemCookingEquipment"] type: str progress_percentage: float | int + inverse_progress: bool + active_effects: list[EffectState] # add ItemType Meal ? @@ -30,17 +42,14 @@ class CounterState(TypedDict): category: Literal["Counter"] type: str pos: list[float] + orientation: list[float] occupied_by: None | list[ ItemState | CookingEquipmentState ] | ItemState | CookingEquipmentState + active_effects: list[EffectState] # list[ItemState] -> type in ["Sink", "PlateDispenser"] -class CuttingBoardAndSinkState(TypedDict): - type: Literal["CuttingBoard"] | Literal["Sink"] - progressing: bool - - class PlayerState(TypedDict): id: str pos: list[float] @@ -51,11 +60,15 @@ class PlayerState(TypedDict): class KitchenInfo(BaseModel): + """Basic information of the kitchen.""" + width: float height: float class StateRepresentation(BaseModel): + """The format of the returned state representation.""" + players: list[PlayerState] counters: list[CounterState] kitchen: KitchenInfo diff --git a/overcooked_simulator/utils.py b/overcooked_simulator/utils.py index 15397dff9122a226515f4921f237f2215b1d5c8e..b78d44af869d9a53ec17f5f2cd8eda191e0b4e17 100644 --- a/overcooked_simulator/utils.py +++ b/overcooked_simulator/utils.py @@ -1,16 +1,27 @@ +""" +Some utility functions. +""" +from __future__ import annotations + +import dataclasses +import json import logging import os import sys import uuid -from datetime import datetime +from collections import deque +from datetime import datetime, timedelta from enum import Enum +from typing import TYPE_CHECKING import numpy as np import numpy.typing as npt from scipy.spatial import distance_matrix from overcooked_simulator import ROOT_DIR -from overcooked_simulator.counters import Counter + +if TYPE_CHECKING: + from overcooked_simulator.counters import Counter from overcooked_simulator.player import Player @@ -48,7 +59,29 @@ def get_collided_players( return [players[idx] for idx, val in enumerate(collisions) if val] +def get_touching_counters(target: Counter, counters: list[Counter]) -> list[Counter]: + return list( + filter( + lambda counter: np.linalg.norm(counter.pos - target.pos) == 1.0, counters + ) + ) + + +def find_item_on_counters(item_uuid: str, counters: list[Counter]) -> Counter | None: + for counter in counters: + if counter.occupied_by: + if isinstance(counter.occupied_by, deque): + for item in counter.occupied_by: + if item.uuid == item_uuid: + return counter + else: + if item_uuid == counter.occupied_by.uuid: + return counter + + def custom_asdict_factory(data): + """Convert enums to their value.""" + def convert_value(obj): if isinstance(obj, Enum): return obj.value @@ -113,3 +146,25 @@ def add_list_of_manager_ids_arguments(parser): default=[uuid.uuid4().hex], help="List of manager IDs that can create environments.", ) + + +class NumpyAndDataclassEncoder(json.JSONEncoder): + """Special json encoder for numpy types""" + + def default(self, obj): + if isinstance(obj, np.integer): + return int(obj) + elif isinstance(obj, np.floating): + return float(obj) + elif isinstance(obj, np.ndarray): + return obj.tolist() + elif isinstance(obj, timedelta): + return obj.total_seconds() + elif isinstance(obj, datetime): + return obj.isoformat() + elif dataclasses.is_dataclass(obj): + return dataclasses.asdict(obj, dict_factory=custom_asdict_factory) + # elif callable(obj): + # return getattr(obj, "__name__", "Unknown") + + return json.JSONEncoder.default(self, obj) diff --git a/setup.py b/setup.py index d57d47c30a8eefd7e427ff97be2c4884bd1a4cfb..8daf673c953090b4bbf6ce910949487412e300a5 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ requirements = [ "uvicorn", "websockets", "requests", + "platformdirs", ] test_requirements = [ diff --git a/tests/test_start.py b/tests/test_start.py index b737f66cee2b0c97ee3c71a74202c2a5743da936..8efe8da9ecf1c67974f88a18b1bcdb0b1cd3786a 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -6,6 +6,7 @@ import pytest from overcooked_simulator import ROOT_DIR from overcooked_simulator.counters import Counter, CuttingBoard from overcooked_simulator.game_items import Item, ItemInfo, ItemType +from overcooked_simulator.hooks import Hooks from overcooked_simulator.overcooked_environment import ( Action, Environment, @@ -122,7 +123,7 @@ def test_player_reach(env_config, layout_empty_config, item_info): env = Environment(env_config, layout_empty_config, item_info, as_files=False) counter_pos = np.array([2, 2]) - counter = Counter(counter_pos) + counter = Counter(pos=counter_pos, hook=Hooks(env)) env.counters = [counter] env.add_player("1", np.array([2, 4])) env.player_movement_speed = 1 @@ -141,7 +142,7 @@ def test_pickup(env_config, layout_config, item_info): env = Environment(env_config, layout_config, item_info, as_files=False) counter_pos = np.array([2, 2]) - counter = Counter(counter_pos) + counter = Counter(pos=counter_pos, hook=Hooks(env)) counter.occupied_by = Item(name="Tomato", item_info=None) env.counters = [counter] @@ -192,7 +193,8 @@ def test_processing(env_config, layout_config, item_info): env = Environment(env_config, layout_config, item_info, as_files=False) counter_pos = np.array([2, 2]) counter = CuttingBoard( - counter_pos, + pos=counter_pos, + hook=Hooks(env), transitions={ "ChoppedTomato": ItemInfo( name="ChoppedTomato", @@ -204,7 +206,6 @@ def test_processing(env_config, layout_config, item_info): }, ) env.counters.append(counter) - env.progressing_counters.append(counter) tomato = Item(name="Tomato", item_info=None) env.add_player("1", np.array([2, 3]))