diff --git a/overcooked_simulator/counter_factory.py b/overcooked_simulator/counter_factory.py index 02abcf47a6e4f1ce408bd673528934a96f127585..757cddfe27582ba50880a26f72fb8a624561e40c 100644 --- a/overcooked_simulator/counter_factory.py +++ b/overcooked_simulator/counter_factory.py @@ -50,7 +50,14 @@ 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 @@ -112,6 +119,7 @@ class CounterFactory: serving_window_additional_kwargs: dict[str, Any], plate_config: PlateConfig, order_and_score: OrderAndScoreManager, + effect_manager_config: dict, hook: Hooks, ) -> None: """Constructor for the `CounterFactory` class. Set up the attributes necessary to instantiate the counters. @@ -142,6 +150,8 @@ class CounterFactory: """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[str] = set( c @@ -190,13 +200,20 @@ class CounterFactory: 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, 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]] @@ -206,13 +223,14 @@ class CounterFactory: } 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__], @@ -249,21 +267,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]): @@ -289,11 +318,33 @@ 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, + **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 d4b72eec6029dd96ca6c8d5604deb64b12b0c970..697a3394385a2019c8b49233df334f20fdc3300b 100644 --- a/overcooked_simulator/counters.py +++ b/overcooked_simulator/counters.py @@ -48,8 +48,6 @@ from overcooked_simulator.hooks import ( PRE_DISPENSER_PICK_UP, CUTTING_BOARD_PROGRESS, CUTTING_BOARD_100, - CUTTING_BOARD_START_INTERACT, - CUTTING_BOARD_END_INTERACT, PRE_COUNTER_PICK_UP, POST_COUNTER_PICK_UP, PRE_SERVING, @@ -58,14 +56,13 @@ from overcooked_simulator.hooks import ( DIRTY_PLATE_ARRIVES, TRASHCAN_USAGE, PLATE_CLEANED, - SINK_START_INTERACT, - SINK_END_INTERACT, ADDED_PLATE_TO_SINK, DROP_ON_SINK_ADDON, PICK_UP_FROM_SINK_ADDON, ) if TYPE_CHECKING: + from overcooked_simulator.effect_manager import Effect from overcooked_simulator.overcooked_environment import ( OrderAndScoreManager, ) @@ -78,6 +75,7 @@ from overcooked_simulator.game_items import ( CookingEquipment, Plate, ItemInfo, + EffectType, ) @@ -114,6 +112,8 @@ class Counter: """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.""" @@ -190,19 +190,45 @@ 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 { @@ -217,6 +243,7 @@ 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], } @@ -233,8 +260,6 @@ class CuttingBoard(Counter): """ def __init__(self, transitions: dict[str, ItemInfo], **kwargs): - self.progressing: bool = False - """Is a player progressing/cutting on the board.""" 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.""" @@ -245,7 +270,7 @@ class CuttingBoard(Counter): 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: @@ -258,8 +283,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() @@ -281,29 +313,6 @@ class CuttingBoard(Counter): ].name self.hook(CUTTING_BOARD_100, counter=self) - 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() - self.hook(CUTTING_BOARD_START_INTERACT, counter=self) - - def interact_stop(self): - """Handles player interaction, stopping to hold key down.""" - self.pause_progress() - self.hook(CUTTING_BOARD_END_INTERACT, counter=self) - - def to_dict(self) -> dict: - d = super().to_dict() - d.update((("progressing", self.progressing),)) - return d - class ServingWindow(Counter): """The orders and scores are updated based on completed and dropped off meals. The plate dispenser is pinged for @@ -349,6 +358,14 @@ class ServingWindow(Counter): 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) @@ -573,6 +590,12 @@ class Trashcan(Counter): 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): penalty = self.order_and_score.apply_penalty_for_using_trash( item.content_list @@ -625,6 +648,10 @@ class CookingCounter(Counter): and isinstance(self.occupied_by, CookingEquipment) 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) @@ -657,8 +684,6 @@ class Sink(Counter): **kwargs, ): super().__init__(**kwargs) - self.progressing: bool = False - """If a player currently cleans a plate.""" self.sink_addon: SinkAddon = sink_addon """The connected sink addon which will receive the clean plates""" self.occupied_by: deque[Plate] = deque() @@ -679,12 +704,30 @@ class Sink(Counter): """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: @@ -701,24 +744,6 @@ 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() - self.hook(SINK_START_INTERACT, counter=self) - - def interact_stop(self): - """Handles player interaction, stopping to hold key down.""" - self.pause_progress() - self.hook(SINK_END_INTERACT, counter=self) - def can_drop_off(self, item: Item) -> bool: return isinstance(item, Plate) and not item.clean @@ -734,11 +759,6 @@ class Sink(Counter): """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` diff --git a/overcooked_simulator/effect_manager.py b/overcooked_simulator/effect_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..905fef957bf5f200b84ab84c141681838d0a9241 --- /dev/null +++ b/overcooked_simulator/effect_manager.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import random +from collections import deque +from datetime import timedelta, datetime +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): + self.effects = [] + self.counters = [] + self.hook = hook + self.new_effects: list[Tuple[Effect, Item | Counter]] = [] + + 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=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=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 c0de86d2afc35fad41a5c206b448a0f1eecdad6e..3cf44456a89edc420129101ff912b5ba4203c7d4 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,11 @@ player_config: radius: 0.4 player_speed_units_per_seconds: 8 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 \ No newline at end of file diff --git a/overcooked_simulator/game_content/item_info.yaml b/overcooked_simulator/game_content/item_info.yaml index 639f855132b57b7ccef78b6b865b9942b00f5820..1266f61ebd611cd5c2a9097b1be9dd7eff65b7f7 100644 --- a/overcooked_simulator/game_content/item_info.yaml +++ b/overcooked_simulator/game_content/item_info.yaml @@ -213,4 +213,20 @@ BurntPizza: type: Waste needs: [ Pizza ] seconds: 7.0 - equipment: Peel \ No newline at end of file + 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 6340f564820daec1316060bc2d549b8aeca389c4..15d3b606480a232ffce7de3ef1ba61c930686cf4 100644 --- a/overcooked_simulator/game_content/item_info_debug.yaml +++ b/overcooked_simulator/game_content/item_info_debug.yaml @@ -216,3 +216,18 @@ BurntPizza: seconds: 7.0 equipment: Peel +# -------------------------------------------------------------------------------- + +Fire: + type: Effect + seconds: 10.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_items.py b/overcooked_simulator/game_items.py index 163a2d136ad8f54d1e314ac9ff72a53abe3adce8..0b7c6d6db4f6a687dd587bed7a0bdfb733ecc9c7 100644 --- a/overcooked_simulator/game_items.py +++ b/overcooked_simulator/game_items.py @@ -26,7 +26,10 @@ 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.""" @@ -38,6 +41,10 @@ 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.""" @@ -47,6 +54,10 @@ class ItemType(Enum): """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 @@ -89,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): @@ -106,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.""" @@ -127,10 +146,12 @@ class Item: """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.waste_progress: bool = False + 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: @@ -171,7 +192,7 @@ class Item: """Reset the progress.""" self.progress_equipment = None self.progress_percentage = 0.0 - self.waste_progress = False + self.inverse_progress = False def to_dict(self) -> dict: """For the state representation. Only the relevant attributes are put into the dict.""" @@ -180,7 +201,8 @@ class Item: "category": self.item_category, "type": self.name, "progress_percentage": self.progress_percentage, - "waste_progress": self.waste_progress, + "inverse_progress": self.inverse_progress, + "active_effects": [e.to_dict() for e in self.active_effects], } @@ -215,6 +237,12 @@ class CookingEquipment(Item): if other is None: 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: @@ -242,13 +270,22 @@ class CookingEquipment(Item): def can_progress(self) -> bool: """Check if the cooking equipment can progress items at all.""" - return self.active_transition is not None + 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() @@ -257,16 +294,34 @@ class CookingEquipment(Item): def check_active_transition(self): 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), - } - self.waste_progress = transition.type == ItemType.Waste - break + 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 @@ -351,3 +406,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/gui_2d_vis/drawing.py b/overcooked_simulator/gui_2d_vis/drawing.py index 00d4e32f6cc76b03179af72192482ca5b5407dd4..7515b212dd0c45b0e6c84d0228c86cea6df53ebd 100644 --- a/overcooked_simulator/gui_2d_vis/drawing.py +++ b/overcooked_simulator/gui_2d_vis/drawing.py @@ -17,6 +17,7 @@ from overcooked_simulator.state_representation import ( PlayerState, CookingEquipmentState, ItemState, + EffectState, ) USE_PLAYER_COOK_SPRITES = True @@ -303,7 +304,7 @@ class Visualizer: 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, @@ -341,7 +342,7 @@ class Visualizer: ) # if "progress_percentage" in item and item["progress_percentage"] > 0.0: - if item["waste_progress"]: + if item["inverse_progress"]: percentage = 1 - item["progress_percentage"] else: percentage = item["progress_percentage"] @@ -350,7 +351,7 @@ class Visualizer: pos, percentage, grid_size=grid_size, - attention=item["waste_progress"], + attention=item["inverse_progress"], ) if ( @@ -386,6 +387,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( @@ -479,6 +483,14 @@ class Visualizer: grid_size, np.array(counter["pos"]) * grid_size + (grid_size / 2), ) + 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( screen, diff --git a/overcooked_simulator/gui_2d_vis/visualization.yaml b/overcooked_simulator/gui_2d_vis/visualization.yaml index 87415a0ed41fa01c46c5fb8e830d370e4acd776b..00310805584e8bb56fc5b4413399f269308b249c 100644 --- a/overcooked_simulator/gui_2d_vis/visualization.yaml +++ b/overcooked_simulator/gui_2d_vis/visualization.yaml @@ -125,6 +125,26 @@ SinkAddon: size: 0.85 center_offset: [ 0, 0.03 ] +# Tools +Extinguisher: + parts: + - type: rect + color: red + height: 0.2 + width: 0.6 + - type: rect + color: black + width: 0.1 + height: 0.2 + center_offset: [ 0.1, -0.3 ] + +# Effects +Fire: + parts: + - type: circle + color: red + radius: 0.3 + # Items Tomato: parts: diff --git a/overcooked_simulator/overcooked_environment.py b/overcooked_simulator/overcooked_environment.py index 53d8559f0be0642f3fa376789bea66e495eafb14..afbe8160a618c92400d82db9d2195e5d776e0c56 100644 --- a/overcooked_simulator/overcooked_environment.py +++ b/overcooked_simulator/overcooked_environment.py @@ -9,7 +9,7 @@ import sys from datetime import timedelta, datetime from enum import Enum from pathlib import Path -from typing import Literal, TypedDict, Callable +from typing import Literal, TypedDict, Callable, Tuple import numpy as np import numpy.typing as npt @@ -20,6 +20,7 @@ from overcooked_simulator.counters import ( Counter, PlateConfig, ) +from overcooked_simulator.effect_manager import EffectManager from overcooked_simulator.game_items import ( ItemInfo, ItemType, @@ -107,6 +108,7 @@ class EnvironmentConfig(TypedDict): orders: OrderConfig player_config: PlayerConfig layout_chars: dict[str, str] + effect_manager: dict class Environment: @@ -197,6 +199,7 @@ class Environment: ) ), order_and_score=self.order_and_score, + effect_manager_config=self.environment_config["effect_manager"], hook=self.hook, ) @@ -240,6 +243,10 @@ 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) @property @@ -325,7 +332,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 @@ -398,16 +407,14 @@ class Environment: 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) @@ -642,13 +649,15 @@ class Environment: self.hook(GAME_ENDED_STEP) else: for player in self.players.values(): + player.progress(passed_time, self.env_time) if self.env_time <= player.movement_until: self.perform_movement(player, 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): diff --git a/overcooked_simulator/player.py b/overcooked_simulator/player.py index 0507e279ab80b765ead610623cd29991e37b0332..e2284ae0658f82064cfb690dc6bb0629fcb6ae21 100644 --- a/overcooked_simulator/player.py +++ b/overcooked_simulator/player.py @@ -7,16 +7,16 @@ 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, Plate, ItemType from overcooked_simulator.state_representation import PlayerState log = logging.getLogger(__name__) @@ -81,9 +81,11 @@ class Player: self.current_movement: npt.NDArray[float] = np.zeros(2, float) """The movement vector that will be used to calculate the movement in the next step call.""" - self.movement_until: datetime.datetime = datetime.datetime.min + 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""" @@ -97,6 +99,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) @@ -164,25 +168,36 @@ class Player: 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})" diff --git a/overcooked_simulator/state_representation.py b/overcooked_simulator/state_representation.py index 1949e6248832d6e66ba10895f8ec1a1816c65eae..6f61be46d54e5a941cc31fb96ea4f5b322b1b8ad 100644 --- a/overcooked_simulator/state_representation.py +++ b/overcooked_simulator/state_representation.py @@ -15,12 +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 - waste_progress: bool + inverse_progress: bool + active_effects: list[EffectState] # add ItemType Meal ? @@ -37,14 +45,10 @@ class CounterState(TypedDict): 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] diff --git a/overcooked_simulator/utils.py b/overcooked_simulator/utils.py index 2754f08912d31ff513ed891646d29571800e8960..8f22d82daee96902a56799aad905d8996a0ea15f 100644 --- a/overcooked_simulator/utils.py +++ b/overcooked_simulator/utils.py @@ -1,20 +1,25 @@ """ Some utility functions. """ +from __future__ import annotations import logging import os import sys import uuid +from collections import deque from datetime import datetime 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 def create_init_env_time(): @@ -39,6 +44,26 @@ def get_closest(point: npt.NDArray[float], counters: list[Counter]): ] +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."""