extends Node2D const DISH_SCENE := preload("res://scenes/restaurant/dish.tscn") const CUSTOMER_SCENE := preload("res://scenes/restaurant/customer.tscn") const MONEY_POPUP_SCENE := preload("res://scenes/ui/damage_number.tscn") # La salle démarre sous le bandeau du HUD pour lui laisser la bande du # trottoir. Le bas reste collé à l'écran: c'est la hauteur qui absorbe le # décalage, pas la position basse. const ROOM_RECT := Rect2(60, 200, 1800, 870) const WALL_THICKNESS := 44.0 const DOOR_CENTER_X := 1330.0 const DOOR_WIDTH := 200.0 const EATING_SECONDS := 8.0 # Finished meals are plated directly on the stove. const PLATING_OFFSET := Vector2.ZERO # Une table est un tabouret: une seule place, un seul client. Les emplacements # se déduisent d'une grille pour que passer de 2 à 25 tables ne demande aucune # position écrite à la main. const MAX_TABLES := 25 const TABLE_COLUMNS := 5 # La grille descend pour dégager un couloir le long du mur nord: le joueur doit # pouvoir traverser la salle par le haut sans slalomer entre les tables. const TABLE_ORIGIN := Vector2(1000, 380) const TABLE_SPACING := Vector2(160, 140) # Le tabouret est sous la table: le client s'y assoit sans couper l'accès que le # joueur emprunte depuis la cuisine, à gauche. const SEAT_OFFSET := 70.0 const TABLE_RADIUS := 42.0 const CHAIR_RADIUS := 19.0 # A served plate sits on the table right in front of its customer. const PLATE_TABLE_OFFSET := 33.0 # La cuisine tient deux colonnes: les postes de cuisson à gauche, le service # (étagère, plonge, poubelle) à droite. Un poste de plus ne demande donc aucune # position écrite à la main, exactement comme un tabouret. const KITCHEN_RECT := Rect2(410, 346, 295, 438) const STATION_ORIGIN := Vector2(470, 400) const STATION_SPACING := 110.0 const MAX_STATIONS := 4 const STATION_TENDING_RANGE := 153.0 const CONSOLE_POSITION := Vector2(860, 300) # La fenêtre s'ouvre loin de la porte (x 1330) pour que les deux files ne se # marchent pas dessus sur un trottoir qui va de 180 à 1820. const WINDOW_CENTER_X := 620.0 const WINDOW_POSITION := Vector2(WINDOW_CENTER_X, 245) const DINING_RECT := Rect2(950, 320, 780, 720) const SHELF_SLOT_SPACING := 42.0 const ORDER_BOARD_POSITION := Vector2(250, 400) # Le trottoir passe devant la porte, entre le bandeau du HUD et le mur nord. # Les passants le traversent de bout en bout; la file s'étire vers la gauche # depuis la porte. La porte est sur le trottoir: les deux valeurs sont liées # pour qu'un déplacement de bande ne laisse pas l'entrée derrière. const SIDEWALK_Y := 145.0 const SIDEWALK_BAND := 52.0 const SIDEWALK_LEFT := 180.0 const SIDEWALK_RIGHT := 1820.0 const ENTRANCE := Vector2(DOOR_CENTER_X, SIDEWALK_Y) const QUEUE_FIRST_OFFSET := 78.0 const QUEUE_SLOT_SPACING := 46.0 @onready var player: Player = %Player @onready var hud: GameHUD = %HUD @onready var recipe_menu: RecipeMenu = %RecipeMenu @onready var initial_customer: Customer = %Customer @onready var cooking_station: InteractiveProp = $CookingStation @onready var dishwasher_station: InteractiveProp = $DishwasherStation @onready var shelf_station: InteractiveProp = $ShelfStation @onready var trash_station: InteractiveProp = $TrashStation @onready var cancel_button: CancelButton = $CancelCookButton # Un poste: {kind, prop, recipe, remaining, total}. Les trois champs uniques # d'avant ne pouvaient pas décrire deux cuissons en parallèle. var stations: Array[Dictionary] = [] # Poste dont le menu de recettes est ouvert, pour savoir où lancer la cuisson. var pending_station := -1 var console_prop: InteractiveProp var window_prop: InteractiveProp # Fermeture volontaire, décidée à la console. Distincte de `day_closed`, qui est # subie: plus d'ingrédients, plus de service. Fermer soi-même permet de # réapprovisionner sans laisser une file s'accumuler dehors. var manually_closed := false var customers: Array[Customer] = [] var tables: Array[Dictionary] = [] var arrival_timer := 6.0 var day_closed := false var transitioning := false var door_collision: CollisionShape2D var cancel_guard := 0.0 # File d'attente devant la porte, du premier au dernier arrivé. var queue: Array[Customer] = [] # Horodatages des repas servis, pour que le passant juge le débit réel plutôt # qu'une promesse. Fenêtre glissante, purgée à la lecture. var served_times: Array[float] = [] var sidewalk_timer := 1.0 var order_board_panel: OrderBoard func _ready() -> void: add_to_group("level") _build_tables() GameState.state_changed.connect(_refresh_tables) recipe_menu.recipe_selected.connect(_on_recipe_selected) recipe_menu.menu_closed.connect(_on_menu_closed) player.died.connect(_on_player_died) hud.upgrades_requested.connect(_on_hud_upgrades_requested) hud.dungeon_requested.connect(_on_hud_dungeon_requested) _create_boundaries() _build_stations() _build_console() _build_window() _build_order_board() _refresh_tables() _restore_customers() _restore_plates() cancel_button.visible = false # Le convive posé dans la scène n'est qu'une amorce: si la salle est revenue # peuplée, il ferait un doublon sur une table déjà prise. if customers.is_empty() and _find_open_table() == 0 and _can_accept_arrivals(): _spawn_existing_customer(initial_customer, 0, 0) else: initial_customer.queue_free() call_deferred("evaluate_restaurant_closure") hud.set_objective(Loc.ui("ui.restaurant.intro")) _settle_unattended() var arrival_message := GameState.consume_pending_toast() if not arrival_message.is_empty(): GameState.toast_requested.emit(arrival_message) queue_redraw() func _process(delta: float) -> void: if hud == null or not is_instance_valid(hud): return cancel_guard = maxf(0.0, cancel_guard - delta) if not day_closed: sidewalk_timer -= delta if sidewalk_timer <= 0.0: _spawn_passerby() sidewalk_timer = _next_passerby_delay() _seat_from_queue() # Chaque poste avance pour son compte. Tenir l'action près d'un poste # n'accélère que celui-là: le joueur ne peut toujours en pousser qu'un seul. for index: int in range(visible_station_count()): var station: Dictionary = stations[index] if float(station.remaining) <= 0.0: continue station.remaining = maxf(0.0, float(station.remaining) - delta * get_cooking_rate(index)) if float(station.remaining) <= 0.0: _finish_dish(index) station.recipe = "" call_deferred("evaluate_restaurant_closure") _refresh_cancel_button() # Orders are read on the customers themselves and production on the stove, so # the top line only carries the plate budget. hud.set_objective(Loc.ui("ui.restaurant.plates") % [_plates_in_use(), get_plate_capacity()]) func _unhandled_input(event: InputEvent) -> void: if not (event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed): return if cancel_button.contains_point(get_global_mouse_position()): cancel_cooking(cancel_button_station) get_viewport().set_input_as_handled() # --- Les postes --------------------------------------------------------------- # Emplacement d'un poste, en colonne. Déduit comme celui des tables: ouvrir un # poste de plus ne demande aucune position écrite à la main. static func station_center(index: int) -> Vector2: return STATION_ORIGIN + Vector2(0, float(index) * STATION_SPACING) func visible_station_count() -> int: return mini(MAX_STATIONS, unlocked_stoves() + unlocked_spice_stations()) func unlocked_stoves() -> int: return clampi(int(GameState.resolve_game_variable("stove_count")), 0, MAX_STATIONS) func unlocked_spice_stations() -> int: return clampi(int(GameState.resolve_game_variable("spice_station_count")), 0, MAX_STATIONS) # Le genre d'un poste dépend du rang: les fours d'abord, les épiceries ensuite. # Un poste n'a donc pas de genre écrit à la main, et ouvrir une épicerie ne # déplace aucun four. func station_kind(index: int) -> String: return "stove" if index < unlocked_stoves() else "spices" func _build_stations() -> void: # Le poste de la scène devient le premier de la colonne: il garde ses # réglages d'apparence, et les suivants sont taillés dessus. cooking_station.position = station_center(0) cooking_station.station_index = 0 stations.append({"kind": "stove", "prop": cooking_station, "recipe": "", "remaining": 0.0, "total": 0.0, "refining": false}) for index: int in range(1, MAX_STATIONS): var prop := InteractiveProp.new() prop.name = "CookingStation%d" % index prop.action = cooking_station.action prop.prompt = cooking_station.prompt prop.label = cooking_station.label prop.color = cooking_station.color prop.size = cooking_station.size prop.solid = cooking_station.solid prop.station_index = index prop.position = station_center(index) add_child(prop) stations.append({"kind": "stove", "prop": prop, "recipe": "", "remaining": 0.0, "total": 0.0, "refining": false}) _refresh_stations() # Un poste verrouillé n'existe pas: ni dessin, ni collision, ni interaction. func _refresh_stations() -> void: var unlocked := visible_station_count() for index: int in range(stations.size()): var prop: InteractiveProp = stations[index].prop if not is_instance_valid(prop): continue var kind := station_kind(index) stations[index].kind = kind var spices := kind == "spices" prop.label = Loc.ui("ui.station.spices") if spices else Loc.ui("ui.station.stove") prop.color = Color("b5179e") if spices else Color("ffbe0b") prop.visible = index < unlocked prop.set_process(index < unlocked) if index < unlocked: if not prop.is_in_group("interactable"): prop.add_to_group("interactable") else: prop.remove_from_group("interactable") if prop.body != null and is_instance_valid(prop.body): for child: Node in prop.body.get_children(): if child is CollisionShape2D: (child as CollisionShape2D).set_deferred("disabled", index >= unlocked) func station_is_busy(index: int) -> bool: return index >= 0 and index < stations.size() and float(stations[index].remaining) > 0.0 func get_superior_payout() -> float: return maxf(1.0, float(GameState.resolve_game_variable("superior_payout"))) func busy_station_count() -> int: var count := 0 for index: int in range(visible_station_count()): if station_is_busy(index): count += 1 return count # Premier poste libre du genre demandé, ou -1. C'est ce qui rend un deuxième # four utile sans qu'aucun appelant ait à savoir combien il y en a. func free_station(kind: String = "stove") -> int: for index: int in range(visible_station_count()): if station_kind(index) == kind and not station_is_busy(index): return index return -1 func player_is_tending_station(index: int) -> bool: if index < 0 or index >= stations.size() or player == null or not is_instance_valid(player): return false var prop: InteractiveProp = stations[index].prop if not is_instance_valid(prop) or not prop.visible: return false return player.controls_enabled \ and player.global_position.distance_to(prop.global_position) <= STATION_TENDING_RANGE \ and Input.is_action_pressed("action") func get_cooking_rate(index: int) -> float: if player_is_tending_station(index): return 1.0 return maxf(0.0, float(GameState.resolve_game_variable("cook_idle_rate"))) var cancel_button_station := -1 # Le bouton d'annulation se pose sur le poste que le joueur est en train de # tenir, sinon sur le premier poste occupé: il n'y en a qu'un à l'écran. func _refresh_cancel_button() -> void: var target := -1 for index: int in range(visible_station_count()): if not station_is_busy(index): continue if target < 0: target = index if player_is_tending_station(index): target = index break cancel_button_station = target cancel_button.visible = target >= 0 if target >= 0: var prop: InteractiveProp = stations[target].prop cancel_button.position = prop.position + Vector2(-prop.size.x * 0.5 - 6.0, -prop.size.y * 0.5 - 6.0) func get_indexed_station_status(action: String, index: int) -> Dictionary: if action != "cooking" or not station_is_busy(index): return {} var station: Dictionary = stations[index] var progress := 1.0 - maxf(float(station.remaining), 0.0) / maxf(float(station.total), 0.01) var rate := get_cooking_rate(index) var heading := Loc.ui("ui.station.stove_cooking") if not player_is_tending_station(index): heading = (Loc.ui("ui.station.stove_auto") % rate) if rate > 0.0 else Loc.ui("ui.station.stove_hold") return {"lines": [heading, DataCatalog.recipe_name(str(station.recipe)), "%d%%" % roundi(progress * 100.0)], "progress": progress} func get_station_status(action: String) -> Dictionary: match action: "console": if not console_is_installed(): return {} var served := served_rate() * 60.0 return {"lines": [ Loc.ui("ui.station.console_closed") if manually_closed else Loc.ui("ui.station.console_open"), Loc.ui("ui.station.console_menu") % [GameState.menu_enabled_recipes.size(), GameState.get_menu_slots()], Loc.ui("ui.station.console_rate") % served, ]} "window": if not GameState.counter_is_open(): return {} return {"lines": [Loc.ui("ui.station.window_stock") % [GameState.counter_drinks, GameState.counter_capacity()]]} "shelf": var stored := _plates_on_shelf() return {"lines": [Loc.ui("ui.station.shelf"), "%d/%d" % [stored, get_shelf_capacity()]]} return {} func handle_interaction(action: String) -> void: match action: "dungeon": if transitioning: return transitioning = true player.controls_enabled = false _persist_room() GameState.record_stat("dungeon_runs") GameState.save_game() get_tree().change_scene_to_file("res://scenes/dungeon/dungeon.tscn") "cooking": handle_station_interaction("cooking", maxi(0, free_station("stove"))) "upgrades": if transitioning: return transitioning = true player.controls_enabled = false _persist_room() GameState.save_game() get_tree().change_scene_to_file("res://scenes/progression/upgrade_tree.tscn") "console": if not console_is_installed(): return player.controls_enabled = false recipe_menu.open() "window": fill_counter_from_pantry(GameState.counter_capacity()) "dishwasher": _wash_held_plate() "shelf": _store_held_plate() "trash": _trash_held_plate() # Un poste nommément désigné: c'est celui-là qu'on ouvre, et c'est sur celui-là # que la cuisson partira. func handle_station_interaction(action: String, index: int) -> void: if action != "cooking": handle_interaction(action) return # L'épicerie ne cuisine pas: elle reprend un plat déjà cuit et lui ajoute une # étape. On y dépose donc une assiette, on n'y choisit pas une recette. if station_kind(index) == "spices": _refine_held_dish(index) return if station_is_busy(index) or cancel_guard > 0.0: return if _plates_in_use() >= get_plate_capacity(): GameState.toast_requested.emit(Loc.ui("ui.restaurant.no_clean_plate")) return pending_station = index player.controls_enabled = false recipe_menu.open() # Raffinage: l'assiette portée entre au poste et en ressort supérieure. C'est # du temps et un poste occupé contre un prix de vente multiplié. func _refine_held_dish(index: int) -> void: if station_is_busy(index): return var dish := player.held_dish as Dish if dish == null: GameState.toast_requested.emit(Loc.ui("ui.restaurant.spices_needs_dish")) return if dish.state != Dish.State.FILLED: GameState.toast_requested.emit(Loc.ui("ui.restaurant.spices_needs_meal")) return if dish.superior: GameState.toast_requested.emit(Loc.ui("ui.restaurant.spices_already")) return var station: Dictionary = stations[index] station.recipe = dish.recipe_id station.total = maxf(0.5, float(DataCatalog.RECIPES[dish.recipe_id].cook_time) * 0.5 * float(GameState.resolve_game_variable("cook_speed"))) station.remaining = float(station.total) station.refining = true # L'assiette est consommée par le poste: il en rendra une supérieure. Sans # ça, le joueur en garderait une en main et le compte serait faussé. dish.consume() GameState.toast_requested.emit(Loc.ui("ui.restaurant.spices_started") % DataCatalog.recipe_name(str(station.recipe))) func get_station_prompt(action: String, index: int, fallback: String) -> String: if action == "cooking" and station_kind(index) == "spices": return Loc.ui("ui.prompt.spices_put") if player.held_dish is Dish else Loc.ui("ui.prompt.spices") if action == "cooking" and station_is_busy(index): return Loc.ui("ui.prompt.cook") % DataCatalog.recipe_name(str(stations[index].recipe)) return get_interaction_prompt(action, fallback) func handle_player_action(current_player: Player) -> void: current_player.interact() func get_interaction_prompt(action: String, fallback: String) -> String: if action == "cooking" and _plates_in_use() >= get_plate_capacity(): return Loc.ui("ui.prompt.no_plate") if action == "dishwasher": return Loc.ui("ui.prompt.wash") if player.held_dish is Dish else Loc.ui("ui.prompt.dishwasher") if action == "shelf": return Loc.ui("ui.prompt.shelf_put") if player.held_dish is Dish else Loc.ui("ui.prompt.shelf") if action == "trash": return Loc.ui("ui.prompt.trash_put") if player.held_dish is Dish else Loc.ui("ui.prompt.trash") return fallback # Le menu se souvient du poste qui l'a ouvert: sans ça, choisir une recette # lancerait la cuisson n'importe où. func _on_recipe_selected(recipe_id: String) -> void: start_cooking(recipe_id, pending_station) func _on_menu_closed() -> void: pending_station = -1 call_deferred("_restore_player_controls") func _restore_player_controls() -> void: if not recipe_menu.visible: player.controls_enabled = true # Lance une cuisson. Sans poste désigné, elle prend le premier libre — ce qui # garde l'ancien appel à un seul argument valable partout. func start_cooking(recipe_id: String, index: int = -1) -> void: var target := index if index >= 0 else free_station("stove") if target < 0 or station_is_busy(target): return if _plates_in_use() >= get_plate_capacity(): GameState.toast_requested.emit(Loc.ui("ui.restaurant.no_clean_plate_short")) return if not GameState.consume_recipe(recipe_id): GameState.toast_requested.emit(Loc.ui("ui.restaurant.missing_ingredients")) return var station: Dictionary = stations[target] station.recipe = recipe_id var duration_multiplier := float(GameState.resolve_game_variable("cook_speed")) station.total = float(DataCatalog.RECIPES[recipe_id].cook_time) * duration_multiplier station.remaining = float(station.total) GameState.toast_requested.emit(Loc.ui("ui.restaurant.cooking_started") % DataCatalog.recipe_name(recipe_id)) func cancel_cooking(index: int = -1) -> bool: var target := index if target < 0: for candidate: int in range(visible_station_count()): if station_is_busy(candidate): target = candidate break if target < 0 or not station_is_busy(target): return false var station: Dictionary = stations[target] # Cancelling refunds the ingredients: they were consumed when the order # started and nothing was produced, so the player is not charged for it. if bool(station.get("refining", false)): # Un raffinage annulé rend le plat tel qu'il est entré, pas ses matériaux: # il était déjà cuit. var returned: Dish = DISH_SCENE.instantiate() returned.setup(str(station.recipe)) returned.position = get_plating_point(target) - global_position + PLATING_OFFSET add_child(returned) station.refining = false else: var recipe: Dictionary = DataCatalog.RECIPES.get(str(station.recipe), {}) for ingredient_id: String in recipe.get("ingredients", {}): GameState.add_ingredient(ingredient_id, int(recipe.ingredients[ingredient_id])) GameState.toast_requested.emit(Loc.ui("ui.restaurant.cancelled") % DataCatalog.recipe_name(str(station.recipe))) station.recipe = "" station.remaining = 0.0 station.total = 0.0 cancel_guard = 0.3 _refresh_cancel_button() var prop: InteractiveProp = station.prop if is_instance_valid(prop): prop.queue_redraw() call_deferred("evaluate_restaurant_closure") return true func _finish_dish(index: int) -> void: var station: Dictionary = stations[index] var recipe_id := str(station.recipe) var dish: Dish = DISH_SCENE.instantiate() dish.setup(recipe_id) # Sorti de l'épicerie: supérieur à coup sûr, c'est toute sa raison d'être. dish.superior = bool(station.get("refining", false)) station.refining = false dish.position = get_plating_point(index) - global_position + PLATING_OFFSET add_child(dish) GameState.add_entity_counter("recipes", recipe_id, "total_cooked") Registry.run_hook("recipes", recipe_id, "on_cooked") _persist_room() GameState.toast_requested.emit(Loc.ui("ui.restaurant.dish_ready") % DataCatalog.recipe_name(recipe_id)) func serve_customer(current_customer: Customer, dish: Dish) -> void: if current_customer.state != Customer.State.WAITING or dish.recipe_id != current_customer.requested_recipe: return var price := int(DataCatalog.RECIPES[dish.recipe_id].sale_price) if dish.superior: price = roundi(float(price) * get_superior_payout()) if current_customer.cash < price: current_customer.begin_leaving() return current_customer.cash -= price var payout := roundi(price * float(GameState.resolve_game_variable("payout"))) var service_ratio := clampf(current_customer.patience / maxf(current_customer.max_patience, 0.1), 0.0, 1.0) var tip := roundi(float(payout) * current_customer.generosity * service_ratio * 0.5) dish.place_for_customer(current_customer, payout, tip, get_serving_plate_position(current_customer)) current_customer.begin_eating(EATING_SECONDS) # Le repas est servi: la ligne sort de l'ardoise. GameState.clear_order_line(current_customer.table_index) adjust_popularity(float(GameState.resolve_game_variable("popularity_gain"))) GameState.record_entity_event("recipes", dish.recipe_id, "total_served", "dishes_served") note_meal_served() GameState.add_entity_counter("customer_profiles", current_customer.profile_id, "total_served") Registry.run_hook("recipes", dish.recipe_id, "on_served") Registry.run_hook("customer_profiles", current_customer.profile_id, "on_served") _persist_room() GameState.toast_requested.emit(Loc.ui("ui.restaurant.meal_served")) # Handing over the plate removes it from the pool: anyone still waiting has to # re-check whether their own order is still obtainable. call_deferred("evaluate_restaurant_closure") # Popularity is a stored running total, so it is written through the address # space like any other fact. The balance of the amounts stays in the tool. func adjust_popularity(amount: float) -> float: var updated := maxf(0.0, float(GameState.get_fact_value("restaurant_popularity")) + amount) GameState.set_fact_value("restaurant_popularity", updated) return updated func get_popularity() -> float: return float(GameState.get_fact_value("restaurant_popularity")) # Called whenever a customer leaves. Someone who already ate is not a walkout, # so only a customer who never got a meal costs popularity. func customer_left_unserved(current_customer: Customer) -> void: if current_customer.meals_eaten > 0: return adjust_popularity(-float(GameState.resolve_game_variable("popularity_loss"))) GameState.add_entity_counter("customer_profiles", current_customer.profile_id, "total_left_angry") Registry.run_hook("customer_profiles", current_customer.profile_id, "on_left_unserved") func get_serving_plate_position(current_customer: Customer) -> Vector2: # The plate lands on the table on the customer's own side, within reach. if current_customer.table_index < 0 or current_customer.table_index >= tables.size(): return current_customer.seat_position # Le tabouret est sous la table: l'assiette se pose du même côté, à portée. var center: Vector2 = tables[current_customer.table_index].center return center + Vector2(0, PLATE_TABLE_OFFSET) func customer_finished_meal(current_customer: Customer) -> void: var serving_dish := _find_serving_dish(current_customer) if serving_dish != null: serving_dish.make_dirty() var table: Dictionary = tables[current_customer.table_index] table.dirty_count = int(table.dirty_count) + 1 current_customer.meals_eaten += 1 var next_recipe := _choose_available_recipe(current_customer) if current_customer.meals_eaten < current_customer.appetite and not next_recipe.is_empty(): current_customer.assign_order(next_recipe) else: current_customer.begin_leaving() _persist_room() call_deferred("evaluate_restaurant_closure") func customer_seated(current_customer: Customer) -> void: var recipe_id := _choose_available_recipe(current_customer) if recipe_id.is_empty(): GameState.toast_requested.emit(Loc.ui("ui.restaurant.customer_left_nothing") % current_customer.display_name) customer_left_unserved(current_customer) current_customer.begin_leaving() call_deferred("evaluate_restaurant_closure") return current_customer.assign_order(recipe_id) func _choose_available_recipe(current_customer: Customer) -> String: var available: Array[String] = [] for recipe_id: String in DataCatalog.RECIPES: # A recipe taken off the menu is never ordered, whatever the pantry holds. if not GameState.is_recipe_on_menu(recipe_id): continue var price := int(DataCatalog.RECIPES[recipe_id].sale_price) if price <= current_customer.cash and _recipe_is_available(recipe_id): available.append(recipe_id) if available.is_empty(): return "" if available.has(current_customer.favorite_meal) and randf() <= current_customer.favorite_chance: return current_customer.favorite_meal return available.pick_random() func _recipe_is_available(recipe_id: String) -> bool: if GameState.can_cook(recipe_id): return true for index: int in range(visible_station_count()): if station_is_busy(index) and str(stations[index].recipe) == recipe_id: return true for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and node.recipe_id == recipe_id and node.state == Dish.State.FILLED: return true return false func _find_serving_dish(current_customer: Customer) -> Dish: for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and node.state == Dish.State.SERVING and node.served_customer_id == current_customer.get_instance_id(): return node as Dish return null func _wash_held_plate() -> void: var dish := player.held_dish as Dish if dish == null or dish.state != Dish.State.DIRTY: GameState.toast_requested.emit(Loc.ui("ui.restaurant.bring_dirty_plate")) return # La table a déjà été libérée au ramassage: le lavage ne fait qu'encaisser. dish.wash() _persist_room() GameState.toast_requested.emit(Loc.ui("ui.restaurant.plate_washed")) call_deferred("evaluate_restaurant_closure") func _store_held_plate() -> void: var dish := player.held_dish as Dish if dish == null: GameState.toast_requested.emit(Loc.ui("ui.restaurant.shelf_holds")) return var slot := _free_shelf_slot() if slot < 0: GameState.toast_requested.emit(Loc.ui("ui.restaurant.shelf_full")) return dish.place_on(_shelf_slot_position(slot)) _persist_room() GameState.toast_requested.emit(Loc.ui("ui.restaurant.plate_stored")) func _trash_held_plate() -> void: var dish := player.held_dish as Dish if dish == null: GameState.toast_requested.emit(Loc.ui("ui.restaurant.trash_prompt_empty")) return # Une assiette déjà sale n'a plus rien à jeter: la poubelle ne mange pas la # vaisselle, sinon le joueur perdrait une assiette sans le vouloir. if dish.state == Dish.State.DIRTY: GameState.toast_requested.emit(Loc.ui("ui.restaurant.trash_already_empty")) return var label := DataCatalog.recipe_name(dish.recipe_id) dish.discard_meal() _persist_room() GameState.toast_requested.emit(Loc.ui("ui.restaurant.trashed") % label) call_deferred("evaluate_restaurant_closure") func get_shelf_capacity() -> int: return maxi(1, int(GameState.resolve_game_variable("shelf_slots"))) func _shelf_slot_position(index: int) -> Vector2: var slots := get_shelf_capacity() var start := -float(slots - 1) * SHELF_SLOT_SPACING * 0.5 return shelf_station.global_position + Vector2(start + float(index) * SHELF_SLOT_SPACING, -4.0) func _free_shelf_slot() -> int: for index: int in range(get_shelf_capacity()): var slot_position := _shelf_slot_position(index) var occupied := false for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and node.carrier == null and node.global_position.distance_to(slot_position) < SHELF_SLOT_SPACING * 0.5: occupied = true break if not occupied: return index return -1 func _plates_on_shelf() -> int: var count := 0 for index: int in range(get_shelf_capacity()): var slot_position := _shelf_slot_position(index) for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and node.carrier == null and node.global_position.distance_to(slot_position) < SHELF_SLOT_SPACING * 0.5: count += 1 break return count # Une assiette sale libère sa table dès qu'on la ramasse, pas au lavage: le # client suivant n'a aucune raison d'attendre la plonge. `table_index` remis à # -1 rend l'opération idempotente si l'assiette est reposée puis reprise. func release_table_plate(dish: Dish) -> void: var index := dish.table_index if index < 0 or index >= tables.size(): return var table: Dictionary = tables[index] table.dirty_count = maxi(0, int(table.dirty_count) - 1) dish.table_index = -1 _seat_from_queue() func claim_dirty_plate_payment(dish: Dish) -> void: if dish.payment_claimed or dish.state != Dish.State.DIRTY: return dish.payment_claimed = true var total := dish.pending_payout + dish.pending_tip GameState.add_coins(total) var popup := MONEY_POPUP_SCENE.instantiate() popup.setup_currency(total, dish.pending_tip) popup.global_position = dish.global_position + Vector2(randf_range(-8.0, 8.0), -30.0) add_child(popup) if dish.pending_tip > 0: GameState.toast_requested.emit(Loc.ui("ui.restaurant.payment_tip") % [total, dish.pending_tip]) else: GameState.toast_requested.emit(Loc.ui("ui.restaurant.payment") % total) # Assiettes et convives s'écrivent ensemble: séparés, un aller-retour pouvait # rendre une assiette dont le mangeur avait disparu. func _persist_room() -> void: if not is_inside_tree() or get_tree() == null: return var stored: Array = [] for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and not node.is_queued_for_deletion(): var plate := node as Dish var record := plate.to_save_dictionary() record["carried"] = plate.carrier != null and is_instance_valid(plate.carrier) stored.append(record) GameState.capture_restaurant_plates(stored) var guests: Array = [] # La file d'abord, dans l'ordre: c'est elle qui décide qui entre ensuite. for waiting: Customer in queue: if is_instance_valid(waiting) and not waiting.is_queued_for_deletion(): guests.append(waiting.to_save_dictionary()) for guest: Customer in customers: if not is_instance_valid(guest) or guest.is_queued_for_deletion(): continue # Un passant du trottoir ne se stocke pas: le flux le régénère en une # seconde, et il n'a encore rien engagé dans le restaurant. if guest.state == Customer.State.STROLLING or guest.state == Customer.State.LEAVING: continue if queue.has(guest): continue guests.append(guest.to_save_dictionary()) GameState.capture_restaurant_customers(guests) # Les convives reviennent avant les assiettes: une assiette en cours de repas a # besoin de retrouver son mangeur. func _restore_customers() -> void: for entry: Variant in GameState.take_restaurant_customers(): if not entry is Dictionary: continue var stored: Dictionary = entry var profile_id := str(stored.get("profile_id", "")) if not DataCatalog.CUSTOMER_PROFILES.has(profile_id): continue var stored_state := int(stored.get("state", int(Customer.State.WAITING))) var table_index := int(stored.get("table_index", -1)) var seated := stored_state == int(Customer.State.WAITING) or stored_state == int(Customer.State.EATING) or stored_state == int(Customer.State.ARRIVING) # Une table qui n'existe plus — le joueur a pu perdre l'upgrade — ne peut # pas rendre sa place: le convive est abandonné plutôt que placé nulle part. if seated and (table_index < 0 or table_index >= get_unlocked_table_count()): continue var guest: Customer = CUSTOMER_SCENE.instantiate() add_child(guest) var seat := _queue_slot(queue.size()) if seated: seat = tables[table_index].seats[0] guest.setup(profile_id, self, table_index if seated else -1, 0 if seated else -1, seat, ENTRANCE) guest.apply_save_dictionary(stored) guest.departed.connect(_on_customer_departed) customers.append(guest) if seated: tables[table_index].customers[0] = guest else: guest.move_in_queue(_queue_slot(queue.size())) queue.append(guest) _reflow_queue() func _restore_plates() -> void: var stored := GameState.take_restaurant_plates() for entry: Variant in stored: if not entry is Dictionary: continue var recipe_id := str((entry as Dictionary).get("recipe_id", "")) if not DataCatalog.RECIPES.has(recipe_id): continue var dish: Dish = DISH_SCENE.instantiate() add_child(dish) dish.setup(recipe_id) dish.apply_save_dictionary(entry) # Une assiette qu'on portait reste en main: la reposer par terre à chaque # aller-retour est exactement le genre d'oubli qu'on corrige ici. if bool((entry as Dictionary).get("carried", false)) and player != null and is_instance_valid(player): dish.pick_up(player) if dish.state == Dish.State.DIRTY and dish.table_index >= 0 and dish.table_index < tables.size(): var table: Dictionary = tables[dish.table_index] table.dirty_count = int(table.dirty_count) + 1 elif dish.state == Dish.State.SERVING: _relink_serving_dish(dish) _persist_room() # Un tabouret par table: l'indice de table suffit à retrouver le mangeur. Une # assiette qui ne retrouve personne redevient sale, sinon elle serait à jamais # hors d'atteinte du joueur. func _relink_serving_dish(dish: Dish) -> void: if dish.table_index >= 0 and dish.table_index < tables.size(): var occupant: Variant = tables[dish.table_index].customers[0] if occupant != null and is_instance_valid(occupant): dish.served_customer_id = (occupant as Customer).get_instance_id() dish.remove_from_group("interactable") return dish.make_dirty() if dish.table_index >= 0 and dish.table_index < tables.size(): var table: Dictionary = tables[dish.table_index] table.dirty_count = int(table.dirty_count) + 1 # Emplacement d'une table sur la grille, en partant du haut à gauche. static func table_center(index: int) -> Vector2: return TABLE_ORIGIN + Vector2( float(index % TABLE_COLUMNS) * TABLE_SPACING.x, float(index / TABLE_COLUMNS) * TABLE_SPACING.y) func _build_tables() -> void: for index: int in range(MAX_TABLES): var center := table_center(index) var table := { "center": center, # Un seul tabouret, sous la table. "seats": [center + Vector2(0, SEAT_OFFSET)], "customers": [null], "dirty_count": 0, "collision": null, } var body := StaticBody2D.new() body.name = "TableBody%d" % tables.size() body.position = center body.collision_layer = 2 body.collision_mask = 0 var collision := CollisionShape2D.new() var shape := CircleShape2D.new() shape.radius = TABLE_RADIUS collision.shape = shape body.add_child(collision) add_child(body) table.collision = collision tables.append(table) func _refresh_tables() -> void: # Only unlocked tables are physically present; a locked one is a floor marking. var unlocked := get_unlocked_table_count() # Une table qui disparaît emporte sa ligne d'ardoise: sans ça la commande # resterait affichée sans personne pour la réclamer. GameState.prune_order_board(unlocked) _refresh_stations() _refresh_console() _refresh_window() for index: int in range(tables.size()): var collision: CollisionShape2D = tables[index].get("collision") if collision != null and is_instance_valid(collision): collision.set_deferred("disabled", index >= unlocked) queue_redraw() if day_closed: call_deferred("evaluate_restaurant_closure") # Tables réellement présentes dans la salle. Une table verrouillée n'est pas un # marquage au sol: elle n'existe pas. Le dessin passe par ici pour que la règle # soit vérifiable au lieu d'être enfouie dans `_draw`. func visible_table_indices() -> Array[int]: var result: Array[int] = [] for index: int in range(get_unlocked_table_count()): result.append(index) return result func get_unlocked_table_count() -> int: return clampi(int(GameState.resolve_game_variable("table_count")), 0, MAX_TABLES) # L'ardoise est adossée au mur ouest, face à la cuisine: le joueur la lit sans # quitter son fourneau des yeux. Créée en code comme les murs, pour qu'ajouter # une ligne ne demande aucune retouche de scène. # La console est adossée au mur nord, entre la cuisine et la salle: on la voit # en entrant, et elle n'est sur le chemin de personne. func _build_console() -> void: console_prop = InteractiveProp.new() console_prop.name = "ManagementConsole" console_prop.action = "console" console_prop.prompt = Loc.ui("ui.prompt.console") console_prop.label = Loc.ui("ui.station.console") console_prop.color = Color("6cc4da") console_prop.size = Vector2(104, 76) console_prop.position = CONSOLE_POSITION add_child(console_prop) _refresh_console() # Au retour, le temps passé ailleurs devient des verres vendus. Le compte rendu # dit aussi quand la réserve s'est épuisée: c'est ce qui apprend à la remplir # avant de descendre. func _settle_unattended() -> void: var report := GameState.settle_unattended_sales() if int(report.drinks) <= 0: return GameState.toast_requested.emit(Loc.ui("ui.restaurant.away_sales") % [int(report.drinks), int(report.coins)]) if bool(report.ran_dry): GameState.toast_requested.emit(Loc.ui("ui.restaurant.away_dry")) func console_is_installed() -> bool: return int(GameState.resolve_game_variable("management_console")) > 0 func _refresh_console() -> void: if console_prop == null or not is_instance_valid(console_prop): return var installed := console_is_installed() console_prop.visible = installed console_prop.set_process(installed) if installed: if not console_prop.is_in_group("interactable"): console_prop.add_to_group("interactable") else: console_prop.remove_from_group("interactable") # Une console démontée ne peut pas laisser le magasin fermé derrière elle. manually_closed = false if console_prop.body != null and is_instance_valid(console_prop.body): for child: Node in console_prop.body.get_children(): if child is CollisionShape2D: (child as CollisionShape2D).set_deferred("disabled", not installed) # L'interrupteur. Fermer volontairement disperse la file sans pénalité: ceux qui # attendaient n'ont rien abandonné, c'est le magasin qui a baissé le rideau. func toggle_open_closed() -> bool: if not console_is_installed(): return false manually_closed = not manually_closed GameState.toast_requested.emit(Loc.ui("ui.restaurant.closed_by_hand") if manually_closed else Loc.ui("ui.restaurant.opened_by_hand")) evaluate_restaurant_closure() return true # Le comptoir est percé dans le mur nord: le joueur le remplit de l'intérieur, # les passants achètent de l'autre côté. Personne ne le tient. func _build_window() -> void: window_prop = InteractiveProp.new() window_prop.name = "StreetWindow" window_prop.action = "window" window_prop.prompt = Loc.ui("ui.prompt.window") window_prop.label = Loc.ui("ui.station.window") window_prop.color = Color("7fcea5") window_prop.size = Vector2(120, 58) window_prop.position = WINDOW_POSITION add_child(window_prop) _refresh_window() func _refresh_window() -> void: if window_prop == null or not is_instance_valid(window_prop): return var open := GameState.counter_is_open() window_prop.visible = open window_prop.set_process(open) if open: if not window_prop.is_in_group("interactable"): window_prop.add_to_group("interactable") else: window_prop.remove_from_group("interactable") if window_prop.body != null and is_instance_valid(window_prop.body): for child: Node in window_prop.body.get_children(): if child is CollisionShape2D: (child as CollisionShape2D).set_deferred("disabled", not open) # Remplir le comptoir. Un verre coûte un matériau liquide — le premier venu du # garde-manger — et rien d'autre: c'est le poste qui ne dépend pas du butin rare. func fill_counter_from_pantry(amount: int = 1) -> int: if not GameState.counter_is_open(): return 0 var poured := 0 for index: int in range(maxi(0, amount)): if GameState.counter_drinks >= GameState.counter_capacity(): break if GameState.fill_counter(1) <= 0: break poured += 1 if poured > 0: GameState.toast_requested.emit(Loc.ui("ui.restaurant.counter_filled") % [GameState.counter_drinks, GameState.counter_capacity()]) else: GameState.toast_requested.emit(Loc.ui("ui.restaurant.counter_full")) return poured # Un passant s'arrête au comptoir. Aucune table, aucune vaisselle, aucun drame: # s'il n'y a plus rien derrière la vitre, il hausse les épaules et continue — # **sans pénalité de réputation**, contrairement à un client attablé qui renonce. func try_sell_at_window(candidate: Customer) -> bool: if day_closed or not GameState.counter_is_open() or GameState.counter_drinks <= 0: return false if candidate.cash < int(GameState.resolve_game_variable("drink_price")): return false if randf() > clampf(float(GameState.resolve_game_variable("window_appeal")), 0.0, 1.0): return false if not GameState.sell_counter_drink(): return false candidate.cash -= int(GameState.resolve_game_variable("drink_price")) note_meal_served() GameState.add_entity_counter("customer_profiles", candidate.profile_id, "total_served") return true func _build_order_board() -> void: order_board_panel = OrderBoard.new() order_board_panel.name = "OrderBoard" order_board_panel.position = ORDER_BOARD_POSITION add_child(order_board_panel) func get_plate_capacity() -> int: return int(GameState.resolve_game_variable("plates")) func get_plating_point(index: int = 0) -> Vector2: var target := clampi(index, 0, maxi(0, stations.size() - 1)) if stations.is_empty(): return global_position var prop: InteractiveProp = stations[target].prop return prop.global_position + PLATING_OFFSET func _plates_in_use() -> int: # Chaque poste occupé mobilise une assiette qui n'existe pas encore. return get_tree().get_nodes_in_group("dish").size() + busy_station_count() # Délai avant le prochain passant. Le trafic est une statistique par minute; on # tire autour de sa moyenne pour que le trottoir ne batte pas la mesure. func _next_passerby_delay() -> float: var per_minute := maxf(0.0, float(GameState.resolve_game_variable("sidewalk_traffic"))) if per_minute <= 0.0: return 9999.0 var average := 60.0 / per_minute return maxf(0.4, average * randf_range(0.6, 1.4)) func _spawn_passerby() -> void: if day_closed: return var walking_right := randf() < 0.5 var start := Vector2(SIDEWALK_LEFT if walking_right else SIDEWALK_RIGHT, SIDEWALK_Y) var finish := Vector2(SIDEWALK_RIGHT if walking_right else SIDEWALK_LEFT, SIDEWALK_Y) var passerby: Customer = CUSTOMER_SCENE.instantiate() add_child(passerby) passerby.departed.connect(_on_customer_departed) customers.append(passerby) passerby.setup_passerby(_pick_customer_profile(), self, start, finish, DOOR_CENTER_X) # Un passant arrive devant la porte: il décide, et le restaurant ne fait # qu'appliquer sa décision. func passerby_reached_door(candidate: Customer) -> void: # Le comptoir d'abord: un verre pris sur le trottoir n'occupe ni table ni # file, et c'est ce qui fait vivre le magasin quand la salle est saturée. if try_sell_at_window(candidate): return var verdict := evaluate_queue_join(candidate) if not bool(verdict.join): return queue.append(candidate) candidate.join_queue(_queue_slot(queue.size() - 1), float(verdict.acceptable_wait)) _seat_from_queue() func _queue_slot(index: int) -> Vector2: return Vector2(DOOR_CENTER_X - QUEUE_FIRST_OFFSET - float(index) * QUEUE_SLOT_SPACING, SIDEWALK_Y) # --- Décision du passant ---------------------------------------------------- # # Rien d'aléatoire: devant le même restaurant, le même passant décide toujours # pareil. Cinq raisons de passer son chemin, retournées telles quelles pour que # le test puisse dire laquelle a joué. func evaluate_queue_join(candidate: Customer) -> Dictionary: var acceptable := maxf(0.0, candidate.max_patience * candidate.queue_tolerance * (0.5 + candidate.hunger)) var verdict := {"join": false, "reason": "", "acceptable_wait": acceptable, "estimated_wait": 0.0} if day_closed: verdict.reason = "closed" return verdict if queue.size() >= int(GameState.resolve_game_variable("queue_capacity")): verdict.reason = "queue_full" return verdict # Ce qu'il pourrait commander: sur le menu, dans son budget. var affordable := affordable_recipes(candidate.cash) if affordable.is_empty(): verdict.reason = "too_expensive" return verdict # Il compte les gens devant lui: si le stock ne suffit pas pour eux plus lui, # il ne s'arrête pas. C'est ce qui empêche une file d'attendre pour rien. if not _stock_covers(affordable, queue.size() + 1): verdict.reason = "out_of_stock" return verdict var wait := estimated_wait_for(queue.size() + 1) verdict.estimated_wait = wait if wait > acceptable: verdict.reason = "too_slow" return verdict verdict.join = true verdict.reason = "joins" return verdict # Plats du menu qu'un budget donné peut payer. func affordable_recipes(budget: int) -> Array[String]: var result: Array[String] = [] for recipe_id: String in DataCatalog.RECIPES: if not GameState.is_recipe_on_menu(recipe_id): continue if int(DataCatalog.RECIPES[recipe_id].get("sale_price", 0)) <= budget: result.append(recipe_id) return result # Le stock suffit-il à servir `servings` repas d'un même plat abordable? On # raisonne plat par plat: un client commande un plat, pas un panachage. func _stock_covers(recipes: Array[String], servings: int) -> bool: for recipe_id: String in recipes: var enough := true for ingredient_id: String in DataCatalog.RECIPES[recipe_id].get("ingredients", {}): var needed := int(DataCatalog.RECIPES[recipe_id].ingredients[ingredient_id]) * servings if GameState.get_ingredient_amount(ingredient_id) < needed: enough = false break if enough: return true return false # Débit observé, en repas par seconde, sur la fenêtre déclarée. func served_rate() -> float: var window := maxf(1.0, float(GameState.resolve_game_variable("throughput_window"))) var horizon := GameState.game_time - window while not served_times.is_empty() and served_times[0] < horizon: served_times.remove_at(0) if served_times.size() < 2: return 0.0 return float(served_times.size()) / window # Attente estimée pour la `position`-ième personne. Sans historique suffisant, le # passant accorde le bénéfice du doute: sinon aucun restaurant neuf n'ouvrirait. func estimated_wait_for(position: int) -> float: var rate := served_rate() if rate <= 0.0: return 0.0 return float(position) / rate func note_meal_served() -> void: served_times.append(GameState.game_time) # --- Sortie de file --------------------------------------------------------- func _seat_from_queue() -> void: # Sans de quoi cuisiner, faire entrer la file ne produit qu'un défilé de # clients qui franchissent la porte et repartent aussitôt, un par un. if not _can_accept_arrivals(): return while not queue.is_empty(): var table_index := _find_open_table() if table_index < 0: return var next_customer: Customer = queue[0] if not is_instance_valid(next_customer) or next_customer.state != Customer.State.QUEUED: queue.remove_at(0) continue queue.remove_at(0) var table: Dictionary = tables[table_index] table.customers[0] = next_customer next_customer.take_seat(table_index, 0, table.seats[0], ENTRANCE) _reflow_queue() func _reflow_queue() -> void: for index: int in range(queue.size()): if is_instance_valid(queue[index]): queue[index].move_in_queue(_queue_slot(index)) # Le magasin ferme: ceux qui patientaient s'en vont sans entrer. Ce n'est pas un # abandon de leur part, donc aucune pénalité de réputation ne s'applique — la # perte est déjà celle des clients qu'on ne servira pas. func _disperse_queue() -> void: for waiting: Customer in queue.duplicate(): if is_instance_valid(waiting): waiting.begin_leaving() queue.clear() func customer_left_queue(leaving: Customer) -> void: queue.erase(leaving) _reflow_queue() adjust_popularity(-float(GameState.resolve_game_variable("popularity_loss_queue"))) func _spawn_arrival_batch() -> void: # Do not create a customer outside the door unless a recipe can actually be # ordered. A meal already being eaten cannot feed a new arrival, so it must # not let one cross the threshold. if not _can_accept_arrivals(): return var open_table := _find_open_table() if open_table < 0: return _spawn_customer(open_table, 0, 0) # Une table est un tabouret: elle s'offre si personne n'y est assis et si # aucune assiette sale n'y traîne. func _find_open_table(_unused: bool = false) -> int: for table_index: int in range(get_unlocked_table_count()): if int(tables[table_index].dirty_count) > 0: continue if not _free_seats(table_index).is_empty(): return table_index return -1 func _free_seats(table_index: int) -> Array[int]: var result: Array[int] = [] var occupant: Variant = tables[table_index].customers[0] if occupant == null or not is_instance_valid(occupant): result.append(0) return result func _spawn_existing_customer(existing: Customer, table_index: int, seat_index: int) -> void: _register_customer(existing, table_index, seat_index, 0) func _spawn_customer(table_index: int, seat_index: int, entrance_offset: int = 0) -> void: var new_customer: Customer = CUSTOMER_SCENE.instantiate() add_child(new_customer) _register_customer(new_customer, table_index, seat_index, entrance_offset) func _register_customer(new_customer: Customer, table_index: int, seat_index: int, entrance_offset: int) -> void: if not _can_accept_arrivals(): new_customer.queue_free() return var table: Dictionary = tables[table_index] var occupants: Array = table.customers occupants[seat_index] = new_customer customers.append(new_customer) new_customer.departed.connect(_on_customer_departed) var profile_id := _pick_customer_profile() var entrance := ENTRANCE + Vector2(float(entrance_offset * 42), 0) new_customer.setup(profile_id, self, table_index, seat_index, table.seats[seat_index], entrance) func _pick_customer_profile() -> String: var total_weight := 0.0 for profile: Dictionary in DataCatalog.CUSTOMER_PROFILES.values(): total_weight += float(profile.get("arrival_weight", 1.0)) var roll := randf() * total_weight for profile_id: String in DataCatalog.CUSTOMER_PROFILES: roll -= float(DataCatalog.CUSTOMER_PROFILES[profile_id].get("arrival_weight", 1.0)) if roll <= 0.0: return profile_id return str(DataCatalog.CUSTOMER_PROFILES.keys()[0]) func _on_customer_departed(current_customer: Customer) -> void: forget_customer(current_customer) # Sort un client des listes du restaurant. Appelé par le départ normal et par # Customer._exit_tree(), donc volontairement idempotent: une référence morte # ferait échouer l'assignation de la variable typée des boucles clients, avant # même que leur garde is_instance_valid() n'ait la chance de s'exécuter. func _drop_from_queue(gone: Customer) -> void: if queue.has(gone): queue.erase(gone) _reflow_queue() func forget_customer(current_customer: Customer) -> void: _drop_from_queue(current_customer) if current_customer.table_index >= 0 and current_customer.table_index < tables.size() and current_customer.seat_index >= 0: var occupants: Array = tables[current_customer.table_index].customers if occupants[current_customer.seat_index] == current_customer: occupants[current_customer.seat_index] = null customers.erase(current_customer) func evaluate_restaurant_closure() -> void: # State changes can queue this callback during a scene transition. Do not # inspect the SceneTree after the restaurant has already been detached. if not is_inside_tree() or is_queued_for_deletion() or hud == null or not is_instance_valid(hud): return _release_unservable_waiting_customers() # Une fermeture décidée à la console ne se discute pas: elle prime sur # l'approvisionnement. var can_continue := not manually_closed \ and (_has_food_supply() or GameState.restaurant_operations_can_continue()) if can_continue: _set_door_closed(false) if day_closed: day_closed = false hud.set_restaurant_closed(false) arrival_timer = minf(arrival_timer, 1.0) GameState.toast_requested.emit(Loc.ui("ui.restaurant.reopened")) return if day_closed: return day_closed = true _set_door_closed(true) hud.set_restaurant_closed(true) _disperse_queue() for current_customer: Customer in customers.duplicate(): # Whoever already has a meal in front of them finishes it and leaves on # their own, so closing never cancels a plate that is still being paid for. if is_instance_valid(current_customer) and current_customer.state != Customer.State.EATING: current_customer.begin_leaving() GameState.toast_requested.emit(Loc.ui("ui.restaurant.closed")) func _release_unservable_waiting_customers() -> void: # Called whenever supply changes. A waiting customer whose order can no longer # be produced switches to something else that is actually available, or gives # up and leaves when nothing is. for current_customer: Customer in customers.duplicate(): if not is_instance_valid(current_customer) or current_customer.state != Customer.State.WAITING: continue if _recipe_is_available(current_customer.requested_recipe): continue var alternative := _choose_available_recipe(current_customer) if alternative.is_empty(): GameState.toast_requested.emit(Loc.ui("ui.restaurant.customer_left_last_meal") % current_customer.display_name) customer_left_unserved(current_customer) current_customer.begin_leaving() else: current_customer.assign_order(alternative) func _has_food_supply() -> bool: # A plate a customer is actively eating is never available to anyone else, so # a SERVING dish is not counted as supply: once the last meal is handed over, # the remaining customers have nothing left to wait for. if not is_inside_tree() or get_tree() == null: return false if busy_station_count() > 0: return true for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and node.state == Dish.State.FILLED: return true # Only what is actually on the menu counts: ingredients for a withdrawn dish # cannot feed anyone, so they must not keep the restaurant open. for recipe_id: String in DataCatalog.RECIPES: if GameState.is_recipe_on_menu(recipe_id) and GameState.can_cook(recipe_id): return true return false func _can_accept_arrivals() -> bool: return not day_closed and (_has_food_supply() or GameState.restaurant_operations_can_continue()) func can_customer_cross_door() -> bool: return _can_accept_arrivals() func _on_hud_upgrades_requested() -> void: if not transitioning: handle_interaction("upgrades") func _on_hud_dungeon_requested() -> void: if not transitioning: handle_interaction("dungeon") func _on_player_died() -> void: player.heal_full() GameState.toast_requested.emit(Loc.ui("ui.restaurant.snack")) func _create_boundaries() -> void: var top_y := ROOM_RECT.position.y var bottom_y := ROOM_RECT.end.y var left_x := ROOM_RECT.position.x var right_x := ROOM_RECT.end.x var door_left := DOOR_CENTER_X - DOOR_WIDTH * 0.5 var door_right := DOOR_CENTER_X + DOOR_WIDTH * 0.5 _add_wall(Vector2((left_x + door_left) * 0.5, top_y), Vector2(door_left - left_x, WALL_THICKNESS), "NorthWallLeft") _add_wall(Vector2((door_right + right_x) * 0.5, top_y), Vector2(right_x - door_right, WALL_THICKNESS), "NorthWallRight") _add_wall(Vector2((left_x + right_x) * 0.5, bottom_y), Vector2(right_x - left_x, WALL_THICKNESS), "SouthWall") _add_wall(Vector2(left_x, (top_y + bottom_y) * 0.5), Vector2(WALL_THICKNESS, bottom_y - top_y), "WestWall") _add_wall(Vector2(right_x, (top_y + bottom_y) * 0.5), Vector2(WALL_THICKNESS, bottom_y - top_y), "EastWall") var door := StaticBody2D.new() door.name = "RestaurantDoor" door.position = Vector2(DOOR_CENTER_X, top_y) door.collision_layer = 2 door_collision = CollisionShape2D.new() var door_shape := RectangleShape2D.new() door_shape.size = Vector2(DOOR_WIDTH - 18.0, WALL_THICKNESS) door_collision.shape = door_shape door.add_child(door_collision) add_child(door) _set_door_closed(day_closed) func _add_wall(wall_position: Vector2, wall_size: Vector2, wall_name: String) -> void: var body := StaticBody2D.new() body.name = wall_name body.add_to_group("restaurant_wall") body.position = wall_position body.collision_layer = 2 var collision := CollisionShape2D.new() var shape := RectangleShape2D.new() shape.size = wall_size collision.shape = shape body.add_child(collision) add_child(body) func _set_door_closed(closed: bool) -> void: if door_collision != null: door_collision.set_deferred("disabled", not closed) queue_redraw() func _draw() -> void: draw_rect(Rect2(ROOM_RECT.position.x - 190, ROOM_RECT.position.y - 180, ROOM_RECT.size.x + 380, ROOM_RECT.size.y + 360), Color("3b2f4a")) for x: int in range(-107, 2027, 107): for y: int in range(-107, 1173, 107): draw_rect(Rect2(x + 3, y + 3, 100, 100), Color("443653"), false, 3.0) # Le trottoir: sans bande dessinée, la file d'attente flottait dans le vide. draw_rect(Rect2(SIDEWALK_LEFT - 60.0, SIDEWALK_Y - SIDEWALK_BAND * 0.5, SIDEWALK_RIGHT - SIDEWALK_LEFT + 120.0, SIDEWALK_BAND), Color("564a68")) draw_line(Vector2(SIDEWALK_LEFT - 60.0, SIDEWALK_Y - SIDEWALK_BAND * 0.5), Vector2(SIDEWALK_RIGHT + 60.0, SIDEWALK_Y - SIDEWALK_BAND * 0.5), Color("7d6d93"), 3.0) draw_line(Vector2(SIDEWALK_LEFT - 60.0, SIDEWALK_Y + SIDEWALK_BAND * 0.5), Vector2(SIDEWALK_RIGHT + 60.0, SIDEWALK_Y + SIDEWALK_BAND * 0.5), Color("7d6d93"), 3.0) # The kitchen runs as a vertical column: stove, shelf, dishwasher, trash. draw_rect(KITCHEN_RECT, Color(0.14, 0.22, 0.28, 0.62)) draw_rect(KITCHEN_RECT, Color("76cfe0"), false, 3.0) draw_string(ThemeDB.fallback_font, KITCHEN_RECT.position + Vector2(0, -10), "KITCHEN", HORIZONTAL_ALIGNMENT_CENTER, KITCHEN_RECT.size.x, 15, Color("dff8ff")) draw_rect(DINING_RECT, Color(0.44, 0.31, 0.22, 0.34)) draw_rect(DINING_RECT, Color(0.85, 0.63, 0.4, 0.7), false, 3.0) draw_string(ThemeDB.fallback_font, DINING_RECT.position + Vector2(0, -10), "DINING", HORIZONTAL_ALIGNMENT_CENTER, DINING_RECT.size.x, 15, Color("ffe8c2")) _draw_restaurant_walls() for index: int in visible_table_indices(): _draw_table(table_center(index), true) func _draw_table(table_position: Vector2, unlocked: bool) -> void: var color := Color("c58c5b") if unlocked else Color(0.2, 0.16, 0.24, 0.65) var chair_color := Color("8f5f3b") if unlocked else Color(0.18, 0.15, 0.2, 0.65) var chair_offset := Vector2(0, SEAT_OFFSET) draw_circle(table_position + chair_offset, CHAIR_RADIUS, chair_color) draw_circle(table_position + chair_offset, CHAIR_RADIUS, Color("e0ad74") if unlocked else Color("55475e"), false, 3.0) draw_circle(table_position, TABLE_RADIUS, color) draw_circle(table_position, TABLE_RADIUS, Color("f3c98b") if unlocked else Color("5a4b63"), false, 4.0) if not unlocked: draw_string(ThemeDB.fallback_font, table_position + Vector2(-30, 5), "LOCKED", HORIZONTAL_ALIGNMENT_CENTER, 60, 10, Color("b8a9c9")) func _draw_restaurant_walls() -> void: var wall_color := Color("7a5235") var trim_color := Color("d9a066") var left := ROOM_RECT.position.x var right := ROOM_RECT.end.x var top := ROOM_RECT.position.y var bottom := ROOM_RECT.end.y var door_left := DOOR_CENTER_X - DOOR_WIDTH * 0.5 var door_right := DOOR_CENTER_X + DOOR_WIDTH * 0.5 draw_rect(Rect2(left, top - WALL_THICKNESS * 0.5, door_left - left, WALL_THICKNESS), wall_color) draw_rect(Rect2(door_right, top - WALL_THICKNESS * 0.5, right - door_right, WALL_THICKNESS), wall_color) draw_rect(Rect2(left, bottom - WALL_THICKNESS * 0.5, right - left, WALL_THICKNESS), wall_color) draw_rect(Rect2(left - WALL_THICKNESS * 0.5, top, WALL_THICKNESS, bottom - top), wall_color) draw_rect(Rect2(right - WALL_THICKNESS * 0.5, top, WALL_THICKNESS, bottom - top), wall_color) for x: float in range(int(left), int(right), 72): if x < door_left or x > door_right: draw_line(Vector2(x, top - 18), Vector2(x, top + 18), trim_color.darkened(0.25), 2.0) draw_line(Vector2(x, bottom - 18), Vector2(x, bottom + 18), trim_color.darkened(0.25), 2.0) draw_line(Vector2(left, top), Vector2(left, bottom), trim_color, 3.0) draw_line(Vector2(right, top), Vector2(right, bottom), trim_color, 3.0) draw_line(Vector2(left, bottom), Vector2(right, bottom), trim_color, 3.0) draw_line(Vector2(left, top), Vector2(door_left, top), trim_color, 3.0) draw_line(Vector2(door_right, top), Vector2(right, top), trim_color, 3.0) # Door frame and threshold remain visible while the two leaves fold open. draw_rect(Rect2(door_left - 9, top - 31, 18, 62), trim_color) draw_rect(Rect2(door_right - 9, top - 31, 18, 62), trim_color) draw_line(Vector2(door_left, top + 23), Vector2(door_right, top + 23), trim_color, 5.0) var door_color := Color("4a2d22") if day_closed: draw_rect(Rect2(door_left + 7, top - 20, DOOR_WIDTH - 14, 40), door_color) draw_string(ThemeDB.fallback_font, Vector2(door_left + 20, top + 6), "CLOSED", HORIZONTAL_ALIGNMENT_CENTER, DOOR_WIDTH - 40, 14, Color("ffd166")) else: draw_rect(Rect2(door_left + 7, top - 20, 22, 40), door_color) draw_rect(Rect2(door_right - 29, top - 20, 22, 40), door_color) draw_string(ThemeDB.fallback_font, Vector2(door_left + 35, top - 30), "WELCOME", HORIZONTAL_ALIGNMENT_CENTER, DOOR_WIDTH - 70, 12, Color("ffe8c2"))