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 CONSOLE_POSITION := Vector2(860, 300) const CONSOLE_INTERACTION_OFFSET := Vector2(0, 82) # The takeout counter straddles the north wall, close enough for passersby to # make their takeout decision at the counter instead of at the main door. const TAKEOUT_CENTER_X := 620.0 const TAKEOUT_POSITION := Vector2(TAKEOUT_CENTER_X, 220) const TAKEOUT_PREP_SPOT_POSITION := TAKEOUT_POSITION + Vector2(-62, 86) const TAKEOUT_SERVICE_SPOT_POSITION := TAKEOUT_POSITION + Vector2(62, 86) const TAKEOUT_SPOT_RADIUS := 52.0 const TAKEOUT_QUEUE_Y := 145.0 const TAKEOUT_QUEUE_FIRST_OFFSET := 86.0 const TAKEOUT_QUEUE_SLOT_SPACING := 48.0 const TAKEOUT_SERVICE_ASSIST_BONUS := 0.3 const TAKEOUT_SERVER_OFFSET := Vector2(50.0, 0.0) const DINING_RECT := Rect2(950, 320, 780, 720) const SHELF_SLOT_SPACING := 42.0 # L'étagère est dressée **verticalement** devant la colonne des fours et couvre # la hauteur des deux premiers postes: le cuisinier travaille entre les deux et # son va-et-vient tient en quelques pas. Sa géométrie se déduit des postes — # déplacer la colonne l'emmène avec elle. const SHELF_STOVE_SPAN := 2 const SHELF_WIDTH := 70.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) # Un meuble ne se colle jamais au mur: il faut pouvoir passer derrière, et un # poste dos au mur n'offrirait plus ses faces. Seul un élément **ancré** au mur # y échappe, et c'est explicite dans sa fiche. # Sans ces quatre postes, le magasin ne peut pas servir un seul repas: cuisiner, # laver, jeter, asseoir. La console n'est pas dans la liste — elle est protégée # autrement, parce que la perdre bloquerait la partie au lieu de la gêner. const ESSENTIAL_FUNCTIONS := ["stove", "dishwasher", "trash", "table"] const EMPLOYEE_LOCKER_KIND := "employee_locker" const WALL_CLEARANCE := 16.0 # Le comptoir takeout reste sur le mur nord, à gauche de l'entrée: c'est la rue # qu'il sert. Cette distance garde la porte dégagée pour la file de la salle. const TAKEOUT_DOOR_CLEARANCE := 120.0 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 takeout_prop: InteractiveProp var takeout_prepare_spot: InteractiveProp var takeout_service_spot: InteractiveProp # Salle et comptoir ont chacun leur rideau, tenus par `GameState`: ils survivent # au changement de scène et à la simulation hors salle. Quatre combinaisons — # tout fermé, salle seule, comptoir seul, tout ouvert — et **aucune réouverture # automatique**: le contexte baisse le rideau, le gérant le relève. var customers: Array[Customer] = [] var tables: Array[Dictionary] = [] var arrival_timer := 6.0 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] = [] # Takeout customers never share the dining line or cross the restaurant door. var takeout_queue: Array[Customer] = [] var takeout_service_timer := 0.0 # 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 # Le plan de la salle. Chaque chose déplaçable a sa fiche; le mode construction # ne manipule que ces fiches, jamais les noeuds directement. var elements: Array[RestaurantElement] = [] var elements_by_id: Dictionary = {} var build_mode: BuildMode # Employés au travail, et le cuisinier affecté à chaque poste. var employees: Array[Employee] = [] var station_cooks: Dictionary = {} var takeout_cook: Employee var takeout_server: Employee func _ready() -> void: add_to_group("level") # Le plan d'abord: postes et tabourets naissent déjà à leur place. _build_elements() _bind_scene_elements() # Tant que la salle est à l'écran, elle se simule elle-même. Le relais hors # scène se tait pendant ce temps, sinon le temps compterait deux fois. GameState.restaurant_scene_active = true _build_tables() GameState.state_changed.connect(_refresh_tables) GameState.state_changed.connect(_restack_shelf) GameState.state_changed.connect(_refresh_employees) recipe_menu.recipe_selected.connect(_on_recipe_selected) recipe_menu.menu_closed.connect(_on_menu_closed) recipe_menu.open_toggle_requested.connect(_on_console_switch) recipe_menu.takeout_toggle_requested.connect(_on_console_takeout_switch) recipe_menu.build_mode_requested.connect(_on_console_build_mode) recipe_menu.hire_requested.connect(_on_console_hire) recipe_menu.employee_work_requested.connect(_on_console_employee_work) recipe_menu.employee_standby_requested.connect(_on_console_employee_standby) recipe_menu.takeout_recipe_requested.connect(_on_takeout_recipe_requested) 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() _place_shelf() _restore_stations() _build_console() _build_takeout_counter() _build_order_board() _build_unbound_element_props() _build_build_mode() apply_layout() _refresh_tables() _refresh_employees() _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) # La rue vit même rideau baissé: des clients potentiels continuent de passer # devant la vitrine, ils ne font qu'y renoncer. Un magasin fermé ne doit pas # donner l'impression que la ville s'est arrêtée avec lui. sidewalk_timer -= delta if sidewalk_timer <= 0.0: _spawn_passerby() sidewalk_timer = _next_passerby_delay() if dining_is_open(): _seat_from_queue() _process_takeout(delta) # 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 build_mode_is_active(): return 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() # --- Le plan de la salle ------------------------------------------------------- # La grille d'origine. Elle reste la référence quand rien n'a été déplacé, et # c'est aussi elle qui sert de position de départ à chaque fiche. static func default_station_center(index: int) -> Vector2: return STATION_ORIGIN + Vector2(0, float(index) * STATION_SPACING) static func default_table_center(index: int) -> Vector2: return TABLE_ORIGIN + Vector2( float(index % TABLE_COLUMNS) * TABLE_SPACING.x, float(index / TABLE_COLUMNS) * TABLE_SPACING.y) static func default_shelf_height() -> float: return (default_station_center(SHELF_STOVE_SPAN - 1).y - default_station_center(0).y) + 80.0 func _register_element(source_id: String, index: int) -> RestaurantElement: var definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS.get(source_id, {}) if definition.is_empty(): return null var element := RestaurantElement.new() element.id = RestaurantElement.instance_id(source_id, index) element.source_id = source_id element.instance_index = index element.label = DataCatalog.restaurant_element_name(source_id) element.base_size = Vector2(float(definition.get("width", 72.0)), float(definition.get("height", 72.0))) element.base_usable_faces = int(definition.get("usable_faces", 0)) element.anchor = str(definition.get("anchor", "")) element.kind = str(definition.get("kind", "none")) element.position = _default_position(source_id, index) # Un meuble gratuit fait partie de la salle: il est posé d'office, et une # partie neuve retrouve exactement la disposition d'avant le mode # construction. Un meuble qui coûte quelque chose attend dans la palette: # c'est le joueur qui décide où il va. var definition_cost := int(DataCatalog.RESTAURANT_ELEMENTS.get(source_id, {}).get("cost", 0)) element.placed = definition_cost <= 0 and index < resolved_max_count(source_id) if element.kind == "table": # La table occupe aussi son tabouret, qui est dessous. element.footprint_offset = Vector2(0.0, (SEAT_OFFSET + CHAIR_RADIUS - TABLE_RADIUS) * 0.5) elements.append(element) elements_by_id[element.id] = element return element # Combien d'exemplaires la salle prépare. Le plafond écrit borne tout, et la # statistique nommée décide combien sont réellement ouverts — exactement la # règle des postes et des tabourets, étendue à tous les meubles. func element_ceiling(source_id: String) -> int: var definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS.get(source_id, {}) return clampi(int(definition.get("max_count", 1)), 0, 99) func resolved_max_count(source_id: String) -> int: var definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS.get(source_id, {}) var ceiling := element_ceiling(source_id) var variable := str(definition.get("max_count_variable", "")) if variable.is_empty(): return ceiling return clampi(int(GameState.resolve_game_variable(variable)), 0, ceiling) # Les positions d'origine. Le catalogue ne les porte pas: une position appartient # au plan d'une partie, pas à la fiche d'un meuble. func _default_position(source_id: String, index: int) -> Vector2: match source_id: "basic_stove": return default_station_center(index) "basic_table": return default_table_center(index) "basic_shelf": return Vector2(620.0, (default_station_center(0).y + default_station_center(SHELF_STOVE_SPAN - 1).y) * 0.5) "basic_dishwasher": return Vector2(620.0, 620.0) "basic_trash": return Vector2(620.0, 730.0) "basic_console": return CONSOLE_POSITION "basic_takeout_counter": return TAKEOUT_POSITION "basic_order_board": return ORDER_BOARD_POSITION # Un meuble que le code ne connaît pas — tout le décor à venir — se pose sur # la première case libre plutôt que de naître dans un mur. return Vector2.ZERO func _build_elements() -> void: elements.clear() elements_by_id.clear() for source_id: String in DataCatalog.RESTAURANT_ELEMENTS: for index: int in range(element_ceiling(source_id)): var element := _register_element(source_id, index) if element != null and element.position.is_equal_approx(Vector2.ZERO): element.position = first_free_position(element) # Le plan sauvegardé écrase les positions d'origine, jamais l'inverse. for element: RestaurantElement in elements: var record := GameState.restaurant_layout_entry(element.id) if not record.is_empty(): element.apply_record(record) # La première case où ce meuble tiendrait. Sert aux éléments sans position # d'origine écrite, et au bouton « poser » du mode construction. func first_free_position(element: RestaurantElement) -> Vector2: var area := free_placement_rect() var step := BuildMode.CELL var y := snappedf(area.position.y + element.size().y * 0.5, step) while y <= area.end.y: var x := snappedf(area.position.x + element.size().x * 0.5, step) while x <= area.end.x: if str(placement_blocker(element, Vector2(x, y))).is_empty(): return Vector2(x, y) x += step y += step return element.position # Les quatre meubles posés dans la scène sont les premières instances de leur # entrée. Le reste naît en code: c'est la donnée qui dit combien. func _bind_scene_elements() -> void: for source_id: String in ["basic_stove", "basic_shelf", "basic_dishwasher", "basic_trash"]: var element := element_of(RestaurantElement.instance_id(source_id, 0)) if element == null: continue match source_id: "basic_stove": element.node = cooking_station "basic_shelf": element.node = shelf_station "basic_dishwasher": element.node = dishwasher_station "basic_trash": element.node = trash_station func element_of(id: String) -> RestaurantElement: var found: Variant = elements_by_id.get(id) return found if found is RestaurantElement else null # Un élément verrouillé n'existe pas: on ne le prend pas, il ne bloque personne, # et il ne se dessine pas. Même règle que pour les postes et les tabourets. func element_is_live(element: RestaurantElement) -> bool: if element == null: return false # Les épiceries partagent encore la colonne des fours: tant qu'elles n'ont # pas leur propre entrée au catalogue, le nombre de postes visibles fait foi. if not element.placed: return false if element.source_id == "basic_stove": if element.instance_index >= visible_station_count(): return false elif element.instance_index >= resolved_max_count(element.source_id): return false return missing_placement_requirement(element.source_id).is_empty() # --- La palette du mode construction ------------------------------------------ # Ce que le joueur a le droit de poser: une entrée débloquée dont il reste au # moins un exemplaire. Le déblocage passe par `conditions`, jamais par un champ # dédié: une upgrade s'écrit déjà comme condition. func element_is_unlocked(source_id: String) -> bool: var definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS.get(source_id, {}) if definition.is_empty(): return false return GameState.conditions_met(definition.get("conditions", [])) func placed_count(source_id: String) -> int: var total := 0 for element: RestaurantElement in elements: if element.source_id == source_id and element.placed: total += 1 return total # Une dépendance parle d'une source de meuble, pas d'une instance précise. Si un # jour deux exemplaires d'étagère sont permis, retirer l'un d'eux reste donc # possible tant que l'autre satisfait encore le prérequis. func source_has_live_element(source_id: String, excluding: RestaurantElement = null) -> bool: for element: RestaurantElement in elements: if element != excluding and element.source_id == source_id and element_is_live(element): return true return false func placement_requirements(source_id: String) -> Array[String]: var result: Array[String] = [] var definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS.get(source_id, {}) for requirement: Variant in definition.get("placement_requires", []): result.append(str(requirement)) return result func missing_placement_requirement(source_id: String) -> String: for requirement: String in placement_requirements(source_id): if not source_has_live_element(requirement): return requirement return "" func employee_locker_is_live() -> bool: for element: RestaurantElement in elements: if element.kind == EMPLOYEE_LOCKER_KIND and element_is_live(element): return true return false func placeable_sources() -> Array[String]: var result: Array[String] = [] for source_id: String in DataCatalog.RESTAURANT_ELEMENTS: if element_is_unlocked(source_id) and resolved_max_count(source_id) > 0: result.append(source_id) return result func remaining_count(source_id: String) -> int: return maxi(0, resolved_max_count(source_id) - placed_count(source_id)) # Le premier exemplaire encore au magasin, ou null. Poser ne crée rien: la fiche # existait déjà, elle sort de la réserve. func spare_element(source_id: String) -> RestaurantElement: if remaining_count(source_id) <= 0: return null # **Dans la limite ouverte**, sinon la palette rendait un exemplaire que # `element_is_live()` refuse: on le posait, et rien n'apparaissait. Un # tabouret retiré semblait alors impossible à remettre. var ceiling := resolved_max_count(source_id) for element: RestaurantElement in elements: if element.source_id == source_id and not element.placed and element.instance_index < ceiling: return element return null # Pourquoi ce meuble ne peut pas être retiré, ou une chaîne vide. Retirer un # four qui cuisine ou un tabouret occupé ferait disparaître un plat payé. # Les postes manquants pour pouvoir ouvrir, dans l'ordre de la liste. func missing_essentials() -> Array[String]: var missing: Array[String] = [] for kind: String in ESSENTIAL_FUNCTIONS: var found := false for element: RestaurantElement in elements: if element.kind == kind and element_is_live(element): found = true break if not found: missing.append(kind) return missing # Pourquoi le magasin ne peut pas ouvrir, ou une chaîne vide. La règle vit ici # et pas dans `GameState`: elle parle de meubles posés, donc de la salle. func opening_blocker() -> String: var missing := missing_essentials() if missing.is_empty(): return "" var names: Array[String] = [] for kind: String in missing: names.append(Loc.ui("ui.build.essential_%s" % kind)) return Loc.ui("ui.restaurant.missing_essentials") % ", ".join(names) func removal_blocker(element: RestaurantElement) -> String: if element == null or not element.placed: return Loc.ui("ui.build.nothing_held") # La console ne se retire jamais. C'est le seul chemin vers l'interrupteur # du magasin **et** vers ce mode: la perdre bloquerait la partie pour de bon. # Elle reste déplaçable, seulement pas démontable. if element.kind == "console": return Loc.ui("ui.build.keep_console") # Le dernier exemplaire d'un prérequis ne part pas sous le meuble qui en # dépend. Le message nomme le dépendant à enlever en premier. if not source_has_live_element(element.source_id, element): for dependent: RestaurantElement in elements: if dependent != element and element_is_live(dependent) \ and placement_requirements(dependent.source_id).has(element.source_id): return Loc.ui("ui.build.required_by") % dependent.label if element.kind == "stove" and station_is_busy(element.instance_index): return Loc.ui("ui.build.busy_station") if element.kind == "table": var index := element.instance_index if index >= 0 and index < tables.size(): for occupant: Variant in tables[index].customers: if occupant != null and is_instance_valid(occupant): return Loc.ui("ui.build.busy_table") if int(tables[index].dirty_count) > 0: return Loc.ui("ui.build.busy_table") return "" func remove_element(element: RestaurantElement) -> bool: if not str(removal_blocker(element)).is_empty(): GameState.toast_requested.emit(removal_blocker(element)) return false element.placed = false element.dismissed = true apply_layout() save_layout() return true # Les bonus passifs des meubles posés. Recalculés en bloc: la liste des # modificateurs est la vérité, l'accumulateur n'est qu'un cache (§3). Retirer un # meuble retire donc son bonus sans que personne ait à s'en souvenir. func _refresh_decor_modifiers() -> void: for source_id: String in DataCatalog.RESTAURANT_ELEMENTS: GameState.remove_modifiers_by_source("decor:%s" % source_id) for source_id: String in DataCatalog.RESTAURANT_ELEMENTS: var count := placed_count(source_id) if count <= 0: continue var definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS[source_id] for modifier: Variant in definition.get("modifiers", []): if not modifier is Dictionary: continue var entry: Dictionary = modifier # Chaque exemplaire posé compte: deux plantes valent deux fois. for _copy: int in range(count): GameState.add_modifier("decor:%s" % source_id, str(entry.get("variable", "")), str(entry.get("layer", "flat")), float(entry.get("value", 0.0))) func movable_elements() -> Array[RestaurantElement]: var result: Array[RestaurantElement] = [] for element: RestaurantElement in elements: if element.movable and element_is_live(element): result.append(element) return result # Ce qu'on attrape sous le curseur. Le dernier posé gagne: deux emprises qui se # touchent renvoient celle du dessus, comme à l'écran. func element_at(world: Vector2) -> RestaurantElement: var found: RestaurantElement = null for element: RestaurantElement in elements: if not element.movable or not element_is_live(element): continue if element.footprint().has_point(world): found = element return found # L'intérieur des murs, puis le jeu qui interdit de s'y coller. func room_inner_rect() -> Rect2: return ROOM_RECT.grow(-WALL_THICKNESS * 0.5) func free_placement_rect() -> Rect2: return room_inner_rect().grow(-WALL_CLEARANCE) # La zone que le mode construction balaie pour peindre ses cases vertes. func placement_area(element: RestaurantElement) -> Rect2: if element != null and element.anchor == RestaurantElement.ANCHOR_NORTH_WALL: var band := element.size().y + CELL_BAND return Rect2(Vector2(ROOM_RECT.position.x, element.position.y - band * 0.5), Vector2(ROOM_RECT.size.x, band)) return free_placement_rect() const CELL_BAND := 64.0 # Pourquoi ce meuble ne peut pas aller là, ou une chaîne vide quand il le peut. # Une raison plutôt qu'un booléen: la couche verte et le fantôme rouge disent # tous les deux la même chose, et le joueur apprend la règle en la voyant. func placement_blocker(element: RestaurantElement, at: Vector2) -> String: if element == null: return Loc.ui("ui.build.nothing_held") var missing_requirement := missing_placement_requirement(element.source_id) if not missing_requirement.is_empty(): return Loc.ui("ui.build.needs_fixture") % DataCatalog.restaurant_element_name(missing_requirement) var box := element.footprint_at(at) if element.anchor == RestaurantElement.ANCHOR_NORTH_WALL: if absf(at.y - element.position.y) > 0.5: return Loc.ui("ui.build.stay_on_wall") if box.end.x > DOOR_CENTER_X - DOOR_WIDTH * 0.5 - TAKEOUT_DOOR_CLEARANCE: return Loc.ui("ui.build.left_of_door") if box.position.x < ROOM_RECT.position.x + WALL_THICKNESS: return Loc.ui("ui.build.inside_room") elif not RestaurantElement.fits_inside(box, free_placement_rect()): return Loc.ui("ui.build.off_the_wall") for other: RestaurantElement in elements: if other == element or not element_is_live(other): continue if RestaurantElement.overlaps(box, other.footprint()): return Loc.ui("ui.build.overlap") % other.kind return "" # Le plan devient la salle. Un seul point de passage: sans lui, déplacer un four # laisserait sa collision, son tapis de faces ou son cuisinier derrière. # Ce qu'une statistique vient d'ouvrir se pose tout seul, ce qu'elle vient de # fermer se range. Un meuble retiré à la main n'est jamais reposé d'office: sinon # le × du mode construction n'aurait aucun effet durable. func _sync_placement_with_limits() -> void: # Plusieurs passages rendent l'ordre des fiches sans importance: fermer un # prérequis range aussi ses dépendants, et l'ouvrir peut faire apparaître une # chaîne complète dans le même `apply_layout()`. var changed := true var passes := 0 while changed and passes <= elements.size(): changed = false passes += 1 for source_id: String in DataCatalog.RESTAURANT_ELEMENTS: var definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS[source_id] var automatic := int(definition.get("cost", 0)) <= 0 var ceiling := resolved_max_count(source_id) for element: RestaurantElement in elements: if element.source_id != source_id: continue var allowed := element.instance_index < ceiling and missing_placement_requirement(source_id).is_empty() # Un meuble acheté garde son état `placed` pendant une suspension # automatique: `element_is_live()` le masque, puis le rend dès que le # prérequis revient. Seuls les meubles gratuits suivent ici l'auto-pose. if not allowed and automatic and element.placed: element.placed = false changed = true elif allowed and automatic and not element.dismissed and not element.placed: if element.position.is_equal_approx(Vector2.ZERO): element.position = first_free_position(element) element.placed = true changed = true func apply_layout() -> void: _sync_placement_with_limits() # Une limite ou un prérequis peut aussi disparaître hors du geste manuel. Le # plan ne doit alors jamais laisser une brigade travailler sans son casier; # les contrats restent acquis et passent en stand-by. if not employee_locker_is_live(): GameState.prune_employees() _refresh_decor_modifiers() for element: RestaurantElement in elements: if element.kind == "table": _apply_table_element(element) else: _apply_prop_element(element) _place_shelf() _refresh_cancel_button() queue_redraw() func _apply_prop_element(element: RestaurantElement) -> void: var node := element.node if node == null or not is_instance_valid(node): return node.position = element.position var live := element_is_live(element) node.visible = live if node is InteractiveProp: var prop := node as InteractiveProp prop.usable_faces = element.usable_faces() prop.resize(element.size()) # L'habillage fait partie de l'application du plan: une fiche déclare une # image par quart de tour, tourner le meuble doit donc changer sa face # dessinée. Sans cet appel les images publiées restaient invisibles. prop.show_element_visual(element.source_id, element.rotation_steps) # Un meuble retiré n'est plus là: ni dessin, ni interaction, ni corps. # Sans ça, le × marquait la fiche et laissait le meuble à l'écran. prop.set_process(live) if not live: prop.set_interactive_enabled(false) elif element.base_usable_faces == 0: # Décor et casier ont un corps, mais aucune action contextuelle. prop.set_interactive_enabled(false) elif prop.action != "takeout": # Le meuble takeout est volontairement inerte: ses deux zones # portent les hit zones. Tout le reste redevient utilisable en # revenant dans la salle. prop.set_interactive_enabled(true) 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", not live) # Où est assis le convive d'une table. `R` fait tourner le tabouret autour de la # table plutôt que de laisser la place au même endroit: c'est tout l'intérêt de # pouvoir pivoter un tabouret contre un mur ou face à la salle. func table_seat_position(index: int) -> Vector2: var element := element_of(RestaurantElement.instance_id("basic_table", index)) var centre := table_center(index) var turn := element.rotation_radians() if element != null else 0.0 return centre + Vector2(0, SEAT_OFFSET).rotated(turn) # Un porteur d'habillage par table, créé à la demande et gardé dans la fiche. # Une table est dessinée par la salle, pas par un noeud à elle: sans ce porteur # les quatre faces publiées de `basic_table` resteraient invisibles. func _table_visual(index: int) -> VisualHolder: if index < 0 or index >= tables.size(): return null var table: Dictionary = tables[index] var holder: VisualHolder = table.get("visual") if holder == null or not is_instance_valid(holder): holder = VisualHolder.new() holder.name = "TableVisual%d" % index # Le porteur est un enfant de la salle: il se dessine donc APRÈS le # plancher, sans avoir besoin d'un z négatif — lequel l'aurait au # contraire enfoui dessous, le z d'un enfant étant relatif au parent. holder.z_index = 0 add_child(holder) table["visual"] = holder return holder func _apply_table_visual(element: RestaurantElement) -> void: var holder := _table_visual(element.instance_index) if holder == null: return var live := element_is_live(element) holder.visible = live if not live: return holder.bind("restaurant_elements", element.source_id) holder.show_state(InteractiveProp.FACE_STATES[posmod(element.rotation_steps, InteractiveProp.FACE_STATES.size())]) holder.position = element.position holder.cover(Rect2(-element.size() * 0.5, element.size())) # Le disque dessiné cède la place à l'image, comme partout ailleurs. func table_draws_body(index: int) -> bool: if index < 0 or index >= tables.size(): return true var holder: VisualHolder = tables[index].get("visual") return holder == null or not is_instance_valid(holder) or not holder.is_showing() func _apply_table_element(element: RestaurantElement) -> void: var index := element.instance_index if index < 0 or index >= tables.size(): return _apply_table_visual(element) var table: Dictionary = tables[index] var shift: Vector2 = element.position - (table.center as Vector2) table.center = element.position table.seats = [element.position + Vector2(0, SEAT_OFFSET).rotated(element.rotation_radians())] if not shift.is_zero_approx(): _carry_table_contents(index, shift) var collision: CollisionShape2D = table.get("collision") if collision != null and is_instance_valid(collision) and collision.get_parent() is Node2D: (collision.get_parent() as Node2D).position = element.position collision.set_deferred("disabled", not element_is_live(element)) # Ce qui est posé sur une table suit la table. Sans ça, déplacer un tabouret # laissait l'assiette de son convive seule au milieu de la salle. func _carry_table_contents(index: int, shift: Vector2) -> void: if not is_inside_tree() or get_tree() == null: return for node: Node in get_tree().get_nodes_in_group("dish"): if not node is Dish: continue var plate := node as Dish if plate.table_index == index and plate.carrier == null: plate.global_position += shift func save_layout() -> void: var records: Dictionary = {} for element: RestaurantElement in elements: records[element.id] = element.to_record() GameState.capture_restaurant_layout(records) # --- Le mode construction ------------------------------------------------------ func _build_build_mode() -> void: build_mode = BuildMode.new() build_mode.name = "BuildMode" add_child(build_mode) build_mode.setup(self) build_mode.closed.connect(_on_build_mode_closed) # Pourquoi on ne peut pas réaménager maintenant, ou une chaîne vide. Déplacer # une table sous un client attablé le laisserait assis dans le vide, et un # passant qui entre pendant le chantier viserait un poste qui a bougé. func build_permit_granted() -> bool: return int(GameState.resolve_game_variable("build_permit")) > 0 func build_mode_blocker() -> String: if not console_is_installed(): return Loc.ui("ui.build.needs_console") # Réaménager la salle est une capacité qui s'achète: sans le permis, la # console ne propose rien d'autre que ses réglages. if not build_permit_granted(): return Loc.ui("ui.build.needs_permit") if dining_is_open() or takeout_counter_is_serving(): return Loc.ui("ui.build.needs_closed") # Seuls comptent ceux qui sont **dans** le magasin ou en train d'y entrer. Un # passant qui traverse le trottoir n'entrera jamais rideau baissé: le compter # rendait le chantier impossible à ouvrir, puisque la rue vit même fermée. for guest: Customer in customers: if not is_instance_valid(guest) or guest.is_queued_for_deletion(): continue if [Customer.State.ARRIVING, Customer.State.WAITING, Customer.State.EATING].has(guest.state): return Loc.ui("ui.build.needs_empty") return "" func enter_build_mode() -> bool: if build_mode == null or not is_instance_valid(build_mode) or build_mode.active: return false var blocker := build_mode_blocker() if not blocker.is_empty(): GameState.toast_requested.emit(blocker) return false recipe_menu.close() player.controls_enabled = false build_mode.open() hud.set_objective(Loc.ui("ui.build.hint")) return true func _on_build_mode_closed() -> void: save_layout() if player != null and is_instance_valid(player): player.controls_enabled = true hud.set_objective(Loc.ui("ui.restaurant.plates") % [_plates_in_use(), get_plate_capacity()]) func build_mode_is_active() -> bool: return build_mode != null and is_instance_valid(build_mode) and build_mode.active # --- Les postes --------------------------------------------------------------- # Emplacement d'un poste. Il vient du plan, pas d'une constante: depuis le mode # construction, un four peut être n'importe où, et tout ce qui vise un four — # cuisinier compris — doit suivre. func station_center(index: int) -> Vector2: var element := element_of(RestaurantElement.instance_id("basic_stove", index)) return element.position if element != null else default_station_center(index) # L'étagère se taille sur la colonne des fours. Appelée après `_build_stations`, # qui est ce qui fixe leurs positions. func _place_shelf() -> void: if shelf_station == null or not is_instance_valid(shelf_station): return var element := element_of("basic_shelf#0") if element == null: return shelf_station.position = element.position shelf_station.resize(element.size()) # Les emplacements se répartissent également sur la hauteur utile du meuble: # haut, milieu, bas. Une case n'est donc jamais à côté d'une planche, et deux # lectures successives donnent toujours le même point. func _shelf_usable_height() -> float: return maxf(SHELF_SLOT_SPACING, shelf_station.size.y - 24.0) func _shelf_slot_spacing() -> float: return _shelf_usable_height() / float(maxi(1, get_shelf_capacity())) # Les deux rideaux, lus une seule fois ici. Le reste du fichier ne parle jamais # directement de `GameState` pour savoir si le magasin sert. func dining_is_open() -> bool: return GameState.dining_is_open() # Lecture inverse, pour les appelants qui parlent de fermeture. func dining_is_closed() -> bool: return not GameState.dining_is_open() func takeout_counter_is_serving() -> bool: return GameState.takeout_counter_is_open() 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 var first_element := element_of("basic_stove#0") if first_element != null: first_element.node = cooking_station stations.append({"kind": "stove", "prop": cooking_station, "recipe": "", "remaining": 0.0, "total": 0.0, "refining": false, "order_table": -1}) 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.interaction_radius = cooking_station.interaction_radius prop.interaction_offset = cooking_station.interaction_offset prop.usable_faces = cooking_station.usable_faces prop.face_stand_gap = cooking_station.face_stand_gap prop.station_index = index prop.position = station_center(index) add_child(prop) var stove_element := element_of(RestaurantElement.instance_id("basic_stove", index)) if stove_element != null: stove_element.node = prop stations.append({"kind": "stove", "prop": prop, "recipe": "", "remaining": 0.0, "total": 0.0, "refining": false, "order_table": -1}) _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.get_interaction_position()) <= prop.get_interaction_radius() \ and player.get_contextual_interactable() == prop \ and Input.is_action_pressed("action") # Trois sources de progression, la meilleure gagne: le joueur qui tient le # poste, le cuisinier qui y travaille, ou le four autonome. func get_cooking_rate(index: int) -> float: var rate := maxf(0.0, float(GameState.resolve_game_variable("cook_idle_rate"))) if player_is_tending_station(index): rate = maxf(rate, 1.0) return maxf(rate, cook_rate_for_station(index)) 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 not dining_is_open() else Loc.ui("ui.station.console_open"), Loc.ui("ui.station.console_menu") % [GameState.offered_recipes().size(), GameState.unlocked_recipes().size()], Loc.ui("ui.station.console_rate") % served, ]} "takeout": if not GameState.takeout_is_open(): return {} var recipe_name := DataCatalog.recipe_name(GameState.takeout_recipe) if DataCatalog.RECIPES.has(GameState.takeout_recipe) else Loc.ui("ui.takeout.no_recipe") var lines: Array[String] = [recipe_name, Loc.ui("ui.station.takeout_stock") % [GameState.takeout_stock, GameState.takeout_capacity()]] var status := {"lines": lines} var service_progress := takeout_customer_service_progress() if service_progress >= 0.0: var multiplier := takeout_service_multiplier() lines.append(Loc.ui("ui.station.takeout_service") % [roundi(service_progress * 100.0), multiplier]) var helpers := takeout_service_helper_labels() if not helpers.is_empty(): lines.append(" + ".join(helpers)) status["progress"] = service_progress elif takeout_player_is_boosting_service(): lines.append(Loc.ui("ui.station.takeout_boost_ready")) elif GameState.takeout_cooking_remaining > 0.0: var progress := 1.0 - GameState.takeout_cooking_remaining / maxf(GameState.takeout_cooking_total, 0.01) status["progress"] = progress lines.append("%d%%" % roundi(progress * 100.0)) return status "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 _feed_console_panel() recipe_menu.open(RecipeMenu.Mode.CONSOLE) "takeout", "takeout_prepare": if not DataCatalog.RECIPES.has(GameState.takeout_recipe): GameState.toast_requested.emit(Loc.ui("ui.prompt.takeout_console")) elif GameState.takeout_stock >= GameState.takeout_capacity() and GameState.takeout_cooking_remaining <= 0.0: GameState.toast_requested.emit(Loc.ui("ui.prompt.takeout_full")) elif GameState.takeout_cooking_remaining <= 0.0 and not GameState.can_cook(GameState.takeout_recipe): GameState.toast_requested.emit(Loc.ui("ui.prompt.missing_ingredients")) "takeout_service": GameState.toast_requested.emit(Loc.ui("ui.restaurant.takeout_boost_active") if takeout_customer_service_progress() >= 0.0 else Loc.ui("ui.restaurant.takeout_boost_ready")) "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 if not _has_cookable_menu_recipe(): GameState.toast_requested.emit(Loc.ui("ui.prompt.missing_ingredients")) return pending_station = index player.controls_enabled = false recipe_menu.open(RecipeMenu.Mode.COOK) # 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_is_busy(index): return Loc.ui("ui.prompt.cook") if action == "cooking" and station_kind(index) == "spices": var spice_dish := player.held_dish as Dish if spice_dish == null: return Loc.ui("ui.prompt.spices_no_meal") if spice_dish.state != Dish.State.FILLED: return Loc.ui("ui.prompt.spices_wrong_plate") if spice_dish.superior: return Loc.ui("ui.prompt.spices_already") return Loc.ui("ui.prompt.spices_put") 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 == "takeout" or action == "takeout_prepare": if not DataCatalog.RECIPES.has(GameState.takeout_recipe): return Loc.ui("ui.prompt.takeout_console") if GameState.takeout_cooking_remaining > 0.0: return Loc.ui("ui.prompt.takeout_work") if GameState.takeout_stock >= GameState.takeout_capacity(): return Loc.ui("ui.prompt.takeout_full") if not GameState.can_cook(GameState.takeout_recipe): return Loc.ui("ui.prompt.missing_ingredients") return Loc.ui("ui.prompt.takeout_prepare") if action == "takeout_service": return Loc.ui("ui.prompt.takeout_service_boost") if action == "cooking" and _plates_in_use() >= get_plate_capacity(): return Loc.ui("ui.prompt.no_plate") if action == "dishwasher": var wash_dish := player.held_dish as Dish if wash_dish == null: return Loc.ui("ui.prompt.no_plate_to_wash") return Loc.ui("ui.prompt.wash") if wash_dish.state == Dish.State.DIRTY else Loc.ui("ui.prompt.wash_requires_dirty") if action == "shelf": if not player.held_dish is Dish: return Loc.ui("ui.prompt.no_plate_to_store") return Loc.ui("ui.prompt.shelf_put") if _free_shelf_slot() >= 0 else Loc.ui("ui.prompt.shelf_full") if action == "trash": var trash_dish := player.held_dish as Dish if trash_dish == null: return Loc.ui("ui.prompt.no_meal_to_trash") return Loc.ui("ui.prompt.trash_empty_plate") if trash_dish.state == Dish.State.DIRTY else Loc.ui("ui.prompt.trash_put") return fallback func _has_cookable_menu_recipe() -> bool: return not GameState.offered_recipes().is_empty() # Prompts only describe an Action press that can do real work. Visual rugs stay # visible even when temporarily unavailable, so capacity or the carried plate # never erases the physical language of the room. func can_use_interaction(action: String, index: int, current_player: Player) -> bool: if current_player == null or not is_instance_valid(current_player) or not current_player.controls_enabled or recipe_menu.visible: return false match action: "console": return console_is_installed() "takeout", "takeout_prepare": return GameState.takeout_is_open() and DataCatalog.RECIPES.has(GameState.takeout_recipe) \ and (GameState.takeout_cooking_remaining > 0.0 or GameState.takeout_can_start_dish()) "takeout_service": # This is a permanent work stance. It may be held before the next client # or plate arrives and never conflicts with a server already assigned. return GameState.takeout_is_open() "dishwasher": var dish := current_player.held_dish as Dish return dish != null and dish.state == Dish.State.DIRTY "shelf": return current_player.held_dish is Dish and _free_shelf_slot() >= 0 "trash": var dish := current_player.held_dish as Dish return dish != null and dish.state != Dish.State.DIRTY "cooking": if index < 0 or index >= visible_station_count(): return false if station_kind(index) == "spices": var dish := current_player.held_dish as Dish return station_is_busy(index) or (dish != null and dish.state == Dish.State.FILLED and not dish.superior) return station_is_busy(index) or (_plates_in_use() < get_plate_capacity() and _has_cookable_menu_recipe()) return true # 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_takeout_recipe_requested(recipe_id: String) -> void: # A queued customer has already committed to the displayed dish. Normally a # queued sale is backed by stock, but this guard also protects restored saves. if not takeout_queue.is_empty() or not GameState.select_takeout_recipe(recipe_id): GameState.toast_requested.emit(Loc.ui("ui.restaurant.takeout_switch_blocked")) return GameState.toast_requested.emit(Loc.ui("ui.restaurant.takeout_selected") % DataCatalog.recipe_name(recipe_id)) func _on_menu_closed() -> void: pending_station = -1 call_deferred("_restore_player_controls") func _restore_player_controls() -> void: # Le mode construction garde la main: sans cette garde, la restauration # différée d'une frame rendait les commandes au joueur **dans** le mode, et # son clic gauche rouvrait aussitôt la console qu'il venait de quitter. if build_mode_is_active(): return 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) # Le cuisinier dresse sur l'étagère et libère sa ligne: c'est ce qui raye la # commande du tableau. Sans cuisinier, l'assiette reste au poste. # Le plat sort **sur le four**, toujours. Il y était téléporté sur l'étagère # dès qu'un cuisinier était affecté: les deux emplacements n'en faisaient # plus qu'un à l'écran, et personne ne traversait la cuisine. var worker: Variant = station_cooks.get(index) if worker != null and is_instance_valid(worker): var line_table := int(station.get("order_table", -1)) if line_table >= 0: GameState.mark_order_line_ready(line_table) station.order_table = -1 station_cooks.erase(index) GameState.add_entity_counter("recipes", recipe_id, "total_cooked") # Le plat sort au poste: c'est là que l'effet visuel se joue. Registry.run_hook("recipes", recipe_id, "on_cooked", dish) _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) # Qui apporte le plat propre repartira avec la vaisselle sale de la table: # le porteur se capture avant le dépôt, qui le détache de l'assiette. var bearer := dish.carrier dish.place_for_customer(current_customer, payout, tip, get_serving_plate_position(current_customer)) swap_dirty_plate_to(bearer, current_customer.table_index) current_customer.begin_eating(EATING_SECONDS) current_customer.say("served") # 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", current_customer) Registry.run_hook("customer_profiles", current_customer.profile_id, "on_served", current_customer) _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", current_customer) # Servir un client qui a déjà mangé et débarrasser sa table sont **un seul # geste**: poser le plat propre et prendre l'assiette sale se font ensemble. # Sans ça, chaque second repas coûtait un aller-retour de plus à vide. func swap_dirty_plate_to(bearer: Node2D, table: int) -> Dish: if bearer == null or not is_instance_valid(bearer) or table < 0: return null var dirty := dirty_plate_on_table(table) if dirty == null: return null # Le ramassage garde son point de passage unique: c'est lui qui libère la # table et encaisse, une fois et une seule, quel que soit le porteur. if bearer is Player: var carrier_player := bearer as Player if carrier_player.held_dish != null and is_instance_valid(carrier_player.held_dish): return null dirty.pick_up(carrier_player) else: dirty.carry_by(bearer) if bearer is Employee: (bearer as Employee).take_swapped_plate(dirty) return dirty if dirty.carrier == bearer else null func dirty_plate_on_table(table: int) -> Dish: for node: Node in get_tree().get_nodes_in_group("dish"): if not node is Dish: continue var candidate := node as Dish if candidate.state == Dish.State.DIRTY and candidate.carrier == null and candidate.table_index == table: return candidate return null 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: current_customer.say("meal_finished") 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)) _restack_shelf() _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"))) # Où poser une assiette sur l'étagère. Le sol à côté du meuble quand elle est # pleine: une étagère saturée ne doit pas immobiliser celui qui porte, et une # assiette posée là ne doit pas non plus se faire prendre pour un emplacement. func free_shelf_position() -> Vector2: var slot := _free_shelf_slot() if slot < 0: return shelf_station.global_position + Vector2(-shelf_station.size.x * 0.5 - 26.0, 0.0) return _shelf_slot_position(slot) func _shelf_slot_position(index: int) -> Vector2: var spacing := _shelf_slot_spacing() var start := -_shelf_usable_height() * 0.5 return shelf_station.global_position + Vector2(0.0, start + (float(index) + 0.5) * spacing) # Le meuble montre ses cases. Le poste ne connaît pas la règle, il la demande: # une seule fonction décide où sont les emplacements, ici. func get_station_slot_marks(action: String, _index: int) -> Array: if action != "shelf" or shelf_station == null or not is_instance_valid(shelf_station): return [] var marks: Array = [] for index: int in range(get_shelf_capacity()): marks.append(_shelf_slot_position(index)) return marks # Les assiettes rangées, de haut en bas. Le critère est l'emprise du meuble, pas # un voisinage de case: une capacité qui change ne peut donc plus laisser une # assiette « entre deux rangs », invisible aux deux comptes. func _shelf_dishes() -> Array[Dish]: var result: Array[Dish] = [] if shelf_station == null or not is_instance_valid(shelf_station): return result var footprint := Rect2(shelf_station.global_position - shelf_station.size * 0.5, shelf_station.size).grow(6.0) for node: Node in get_tree().get_nodes_in_group("dish"): if not node is Dish: continue var candidate := node as Dish if candidate.carrier != null and is_instance_valid(candidate.carrier): continue if footprint.has_point(candidate.global_position): result.append(candidate) result.sort_custom(func(a: Dish, b: Dish) -> bool: return a.global_position.y < b.global_position.y) return result # Chaque assiette rejoint la position exacte de son emplacement. Appelé après # chaque dépôt et à chaque changement d'état: une upgrade de capacité redécoupe # les cases, et les assiettes doivent suivre au lieu de flotter. func _restack_shelf() -> void: var capacity := get_shelf_capacity() var stored := _shelf_dishes() for index: int in range(stored.size()): if index >= capacity: stored[index].global_position = shelf_station.global_position + Vector2(-shelf_station.size.x * 0.5 - 26.0, 0.0) continue stored[index].global_position = _shelf_slot_position(index) func _free_shelf_slot() -> int: var used := _shelf_dishes().size() return used if used < get_shelf_capacity() else -1 func _plates_on_shelf() -> int: return mini(_shelf_dishes().size(), get_shelf_capacity()) # 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 _exit_tree() -> void: GameState.restaurant_scene_active = false 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 waiting: Customer in takeout_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) or takeout_queue.has(guest): continue guests.append(guest.to_save_dictionary()) GameState.capture_restaurant_customers(guests) var posted: Dictionary = {} for worker: Employee in employees: if is_instance_valid(worker) and not worker.is_queued_for_deletion(): posted[worker.employee_id] = worker.global_position GameState.capture_employee_positions(posted) var burners: Array = [] for index: int in range(stations.size()): var station: Dictionary = stations[index] if float(station.remaining) <= 0.0: continue burners.append({ "index": index, "recipe": str(station.recipe), "remaining": float(station.remaining), "total": float(station.total), "refining": bool(station.refining), "order_table": int(station.order_table), }) GameState.capture_restaurant_stations(burners) # Une cuisson en cours traverse le changement de scène, comme les assiettes et # les convives. Sans ça, aller voir l'arbre d'upgrades jetait le plat **et** les # ingrédients déjà consommés — le joueur payait une visite au menu. func _restore_stations() -> void: for entry: Variant in GameState.take_restaurant_stations(): if not entry is Dictionary: continue var stored: Dictionary = entry var index := int(stored.get("index", -1)) # Un poste refermé — l'upgrade a pu être perdue en changeant de dimension # — ne peut rien reprendre: la cuisson est abandonnée plutôt que posée # sur un four qui n'existe plus. if index < 0 or index >= stations.size() or index >= visible_station_count(): continue var recipe_id := str(stored.get("recipe", "")) if not DataCatalog.RECIPES.has(recipe_id): continue var station: Dictionary = stations[index] station.recipe = recipe_id station.total = maxf(0.01, float(stored.get("total", 1.0))) station.remaining = clampf(float(stored.get("remaining", 0.0)), 0.0, float(station.total)) station.refining = bool(stored.get("refining", false)) station.order_table = int(stored.get("order_table", -1)) # 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 queue_kind := str(stored.get("queue_kind", "dining")) var seat := _takeout_queue_slot(takeout_queue.size()) if queue_kind == "takeout" else _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: if queue_kind == "takeout" or stored_state == int(Customer.State.TAKEOUT_QUEUED): guest.move_in_queue(_takeout_queue_slot(takeout_queue.size())) takeout_queue.append(guest) else: guest.move_in_queue(_queue_slot(queue.size())) queue.append(guest) _reflow_queue() _reflow_takeout_queue() if not takeout_queue.is_empty(): takeout_service_timer = _takeout_service_interval() 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) _restack_shelf() _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. Comme un poste: lu dans le plan, pas déduit d'une # grille figée. func table_center(index: int) -> Vector2: var element := element_of(RestaurantElement.instance_id("basic_table", index)) return element.position if element != null else default_table_center(index) 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_takeout_counter() _refresh_employees() 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 not dining_is_open(): 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()): # Un tabouret retiré en mode construction n'est plus dessiné: le plan # décide de sa présence, pas seulement la statistique qui l'a ouvert. var element := element_of(RestaurantElement.instance_id("basic_table", index)) if element == null or element_is_live(element): 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 var console_element := element_of("basic_console#0") if console_element != null: console_element.node = console_prop console_prop.position = console_element.position console_prop.interaction_offset = CONSOLE_INTERACTION_OFFSET console_prop.interaction_radius = 64.0 # Adossée au mur nord: seule sa face basse est atteignable. console_prop.usable_faces = InteractiveProp.FACE_BOTTOM 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_operations() if int(report.dishes) > 0: GameState.toast_requested.emit(Loc.ui("ui.restaurant.away_takeout_sales") % [int(report.dishes), int(report.coins)]) if bool(report.ran_dry): GameState.toast_requested.emit(Loc.ui("ui.restaurant.away_takeout_dry")) if int(report.dining_dishes) > 0: GameState.toast_requested.emit(Loc.ui("ui.restaurant.away_dining_sales") % [int(report.dining_dishes), int(report.dining_coins)]) if bool(report.dining_ran_dry): GameState.toast_requested.emit(Loc.ui("ui.restaurant.away_dining_dry")) # --- La brigade --------------------------------------------------------------- # Où un employé sans tâche attend. Le serveur se met dans le passage entre la # cuisine et la salle — d'où toute tâche est à mi-chemin —, le cuisinier reste # devant sa colonne de postes. Un décalage par rang évite qu'ils se superposent. const EMPLOYEE_REST_POSITION := Vector2(760, 470) # À droite de son four, donc entre le four et l'étagère: le va-et-vient se fait # sur un axe, sans traverser la cuisine. const COOK_REST_OFFSET := Vector2(74, 0) const REST_ROW_SPACING := 46.0 func employee_rest_position(worker: Employee) -> Vector2: var rank := 0 for other: Employee in employees: if other == worker: break if is_instance_valid(other) and other.role == worker.role: rank += 1 if worker.role != "cook": return EMPLOYEE_REST_POSITION + Vector2(0, float(rank) * REST_ROW_SPACING) # Un cuisinier attend devant **son** four: un poste chacun, jamais deux au # même endroit. Au-delà des fours ouverts, on empile sous le dernier. var stoves := maxi(1, unlocked_stoves()) var anchor := station_center(mini(rank, stoves - 1)) + COOK_REST_OFFSET return anchor + Vector2(0, float(maxi(0, rank - stoves + 1)) * REST_ROW_SPACING) # La salle reflète qui travaille. Un contrat en stand-by n'a pas de corps actif. # Où un employé apporte la vaisselle sale. Le joueur y va lui-même; l employé a # besoin de la même adresse, et il n y en a qu une. func dishwasher_position() -> Vector2: if dishwasher_station != null and is_instance_valid(dishwasher_station): return dishwasher_station.global_position return global_position func _refresh_employees() -> void: var wanted := GameState.working_employees for index: int in range(employees.size() - 1, -1, -1): var worker := employees[index] if not is_instance_valid(worker) or not wanted.has(worker.employee_id): employees.remove_at(index) if is_instance_valid(worker): _release_cook(worker) worker.queue_free() for id: String in wanted: if _employee_node(id) != null: continue var worker := Employee.new() worker.name = "Employee_%s" % id worker.add_to_group("employee") add_child(worker) worker.setup(id, self) employees.append(worker) # Revenir du menu d'upgrades ne doit pas renvoyer la brigade au fond de # la cuisine. La position traverse le changement de scène; la tâche, non # — elle se redéduit de la salle, ce qui vaut mieux qu'une tâche périmée. var resumed: Variant = GameState.take_employee_position(id) worker.position = resumed if resumed is Vector2 else employee_rest_position(worker) func _employee_node(id: String) -> Employee: for worker: Employee in employees: if is_instance_valid(worker) and worker.employee_id == id: return worker return null # Un cuisinier occupe son poste jusqu'à ce que le plat sorte: c'est ce qui fait # avancer la cuisson sans le joueur. func assign_cook(index: int, worker: Employee) -> void: station_cooks[index] = worker func _release_cook(worker: Employee) -> void: release_takeout_cook(worker) release_takeout_server(worker) for index: Variant in station_cooks.keys(): if station_cooks[index] == worker: station_cooks.erase(index) func cook_rate_for_station(index: int) -> float: var worker: Variant = station_cooks.get(index) if worker == null or not is_instance_valid(worker): return 0.0 var station: Dictionary = stations[index] return (worker as Employee).cooking_rate_for(station_kind(index), str(station.recipe)) 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: # plus rien ne permettrait de le rouvrir. GameState.open_dining() GameState.open_takeout_counter() 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) # Ce que la console affiche vient d'ici. Le panneau ne connaît ni le magasin ni # son débit: il ne saurait pas où les prendre, et n'a pas à le savoir. func _feed_console_panel() -> void: recipe_menu.shop_closed = not dining_is_open() recipe_menu.takeout_closed = not takeout_counter_is_serving() recipe_menu.takeout_installed = GameState.takeout_is_open() recipe_menu.build_permit = build_permit_granted() recipe_menu.served_per_minute = served_rate() * 60.0 func _on_console_switch() -> void: toggle_open_closed() _feed_console_panel() recipe_menu.build_list() func _on_console_build_mode() -> void: enter_build_mode() func _on_console_takeout_switch() -> void: toggle_takeout_open_closed() _feed_console_panel() recipe_menu.build_list() func _on_console_hire(employee_id: String) -> void: GameState.hire_employee(employee_id) recipe_menu.build_list() func _on_console_employee_work(employee_id: String) -> void: GameState.start_employee_work(employee_id) recipe_menu.build_list() func _on_console_employee_standby(employee_id: String) -> void: GameState.standby_employee(employee_id) recipe_menu.build_list() # L'interrupteur de la salle. Fermer volontairement disperse la file sans # pénalité: ceux qui attendaient n'ont rien abandonné, c'est le magasin qui a # baissé le rideau. Rouvrir est **toujours** un geste, jamais un effet de bord. func toggle_open_closed() -> bool: if not console_is_installed(): return false if dining_is_open(): GameState.close_dining(GameState.CLOSE_REASON_MANUAL) GameState.toast_requested.emit(Loc.ui("ui.restaurant.closed_by_hand")) _apply_dining_closure() else: # Un magasin sans four, sans plonge, sans poubelle ou sans place ne peut # servir personne: mieux vaut refuser d'ouvrir que d'ouvrir sur rien. var kit := opening_blocker() if not kit.is_empty(): GameState.toast_requested.emit(kit) return false GameState.open_dining() GameState.toast_requested.emit(Loc.ui("ui.restaurant.opened_by_hand")) _apply_dining_opening() return true # L'interrupteur du comptoir. Indépendant du précédent: le comptoir peut vendre # sa réserve alors que la salle est fermée, et l'inverse. func toggle_takeout_open_closed() -> bool: if not console_is_installed() or not GameState.takeout_is_open(): return false if takeout_counter_is_serving(): GameState.close_takeout_counter(GameState.CLOSE_REASON_MANUAL) GameState.toast_requested.emit(Loc.ui("ui.takeout.closed_by_hand")) _disperse_takeout_queue() else: GameState.open_takeout_counter() GameState.toast_requested.emit(Loc.ui("ui.takeout.opened_by_hand")) _refresh_takeout_counter() return true # Les quatre états, nommés. C'est ce que la console affiche et ce que les tests # interrogent: la combinaison des deux rideaux n'a pas à être recomposée ailleurs. func opening_state() -> String: var dining := dining_is_open() var counter := takeout_counter_is_serving() if dining and counter: return "all_open" if dining: return "dining_only" if counter: return "takeout_only" return "all_closed" # Le meuble affiche l'état, mais ses deux tapis portent les vraies hit zones. # Préparer à gauche et aider le service à droite ne peuvent donc plus changer de # sens selon le stock, ni se neutraliser quand un employé occupe le comptoir. func _build_takeout_counter() -> void: takeout_prop = InteractiveProp.new() takeout_prop.name = "TakeoutCounter" takeout_prop.action = "takeout" takeout_prop.prompt = Loc.ui("ui.prompt.takeout") takeout_prop.label = Loc.ui("ui.station.takeout") takeout_prop.color = Color("7fcea5") takeout_prop.size = Vector2(142, 72) takeout_prop.position = TAKEOUT_POSITION var counter_element := element_of("basic_takeout_counter#0") if counter_element != null: counter_element.node = takeout_prop takeout_prop.position = counter_element.position takeout_prop.interactive_enabled = false add_child(takeout_prop) takeout_prepare_spot = _build_takeout_spot("TakeoutPrepareSpot", "takeout_prepare", TAKEOUT_PREP_SPOT_POSITION, Color("d9a441")) takeout_service_spot = _build_takeout_spot("TakeoutServiceSpot", "takeout_service", TAKEOUT_SERVICE_SPOT_POSITION, Color("63c792")) _refresh_takeout_counter() func _build_takeout_spot(node_name: String, spot_action: String, spot_position: Vector2, spot_color: Color) -> InteractiveProp: var spot := InteractiveProp.new() spot.name = node_name spot.action = spot_action spot.prompt = Loc.ui("ui.prompt.takeout_service_boost") if spot_action == "takeout_service" else Loc.ui("ui.prompt.takeout") spot.label = "" spot.color = spot_color spot.position = spot_position spot.solid = false spot.draw_body = false spot.interaction_radius = TAKEOUT_SPOT_RADIUS # Les deux zones sont deux moitiés d'un même comptoir: chacune dessine sa # bordure sur sa moitié de la seule face accessible, celle du bas. On lit # donc où préparer et où aider sans qu'aucune zone n'ait à changer de sens. var half := Vector2(takeout_prop.size.x * 0.5, takeout_prop.size.y) var half_center := takeout_prop.position + Vector2(half.x * (-0.5 if spot_action == "takeout_prepare" else 0.5), 0.0) spot.usable_faces = InteractiveProp.FACE_BOTTOM spot.face_band_size = half spot.face_band_offset = half_center - spot.position spot.face_band_color = Color(spot_color.r, spot_color.g, spot_color.b, 0.72) # Both zones control the same physical counter. Their stable outline # therefore frames the counter itself instead of one half of it. spot.target_highlight_offset = takeout_prop.position - spot.position spot.target_highlight_size = takeout_prop.size add_child(spot) return spot func _refresh_takeout_counter() -> void: if takeout_prop == null or not is_instance_valid(takeout_prop): return var open := GameState.takeout_is_open() takeout_prop.visible = open takeout_prop.set_process(open) takeout_prop.set_interactive_enabled(false) if takeout_prop.body != null and is_instance_valid(takeout_prop.body): for child: Node in takeout_prop.body.get_children(): if child is CollisionShape2D: (child as CollisionShape2D).set_deferred("disabled", not open) for spot: InteractiveProp in [takeout_prepare_spot, takeout_service_spot]: if spot == null or not is_instance_valid(spot): continue spot.visible = open spot.set_process(open) spot.set_interactive_enabled(open) func _player_is_holding_at(spot: InteractiveProp) -> bool: return spot != null and is_instance_valid(spot) and spot.visible \ and player != null and is_instance_valid(player) and player.controls_enabled \ and not recipe_menu.visible \ and player.global_position.distance_to(spot.get_interaction_position()) <= spot.get_interaction_radius() \ and player.get_contextual_interactable() == spot \ and Input.is_action_pressed("action") func takeout_player_is_preparing() -> bool: return _player_is_holding_at(takeout_prepare_spot) func takeout_player_is_helping_service() -> bool: return _player_is_holding_at(takeout_service_spot) func assign_takeout_cook(worker: Employee) -> bool: if takeout_cook != null and is_instance_valid(takeout_cook) and takeout_cook != worker: return false takeout_cook = worker return true func release_takeout_cook(worker: Employee) -> void: if takeout_cook == worker: takeout_cook = null func takeout_cook_is_assigned(worker: Employee = null) -> bool: if takeout_cook == null or not is_instance_valid(takeout_cook): takeout_cook = null return false return worker == null or takeout_cook == worker func takeout_server_position() -> Vector2: return takeout_prop.global_position + TAKEOUT_SERVER_OFFSET if takeout_prop != null and is_instance_valid(takeout_prop) else TAKEOUT_POSITION + TAKEOUT_SERVER_OFFSET func assign_takeout_server(worker: Employee) -> bool: if takeout_server != null and is_instance_valid(takeout_server) and takeout_server != worker: return false takeout_server = worker return true func release_takeout_server(worker: Employee) -> void: if takeout_server == worker: takeout_server = null func takeout_server_is_assigned(worker: Employee = null) -> bool: if takeout_server == null or not is_instance_valid(takeout_server): takeout_server = null return false return worker == null or takeout_server == worker func takeout_needs_server() -> bool: return GameState.takeout_counter_is_open() and GameState.takeout_stock > 0 and not takeout_queue.is_empty() func takeout_server_priority(worker: Employee) -> float: if not takeout_needs_server(): return 0.0 var capacity := maxi(1, GameState.takeout_queue_capacity()) var queue_pressure := float(takeout_queue.size()) / float(capacity) return (0.6 + queue_pressure) * worker.task_preference("takeout") func takeout_player_is_boosting_service() -> bool: return takeout_player_is_helping_service() func takeout_service_multiplier() -> float: var multiplier := 1.0 if takeout_player_is_boosting_service(): multiplier += TAKEOUT_SERVICE_ASSIST_BONUS if takeout_server_is_assigned() and takeout_server.is_tending_takeout_service(): multiplier += TAKEOUT_SERVICE_ASSIST_BONUS return multiplier func takeout_service_helper_labels() -> Array[String]: var helpers: Array[String] = [] if takeout_player_is_boosting_service(): helpers.append(Loc.ui("ui.station.takeout_helper_player")) if takeout_server_is_assigned() and takeout_server.is_tending_takeout_service(): helpers.append(Loc.ui("ui.station.takeout_helper_server") % DataCatalog.employee_name(takeout_server.employee_id)) return helpers # The customer bar reports service work, never patience. Only the head of the # line can have active progress; everyone behind them is still waiting for a # turn, so callers receive -1 instead of a misleading partial bar. func takeout_customer_service_progress(candidate: Customer = null) -> float: if takeout_queue.is_empty() or GameState.takeout_stock <= 0: return -1.0 var front: Customer = takeout_queue[0] if not is_instance_valid(front) or front.state != Customer.State.TAKEOUT_QUEUED: return -1.0 if candidate != null and candidate != front: return -1.0 if front.global_position.distance_to(front.queue_position) > 6.0: return -1.0 return clampf(1.0 - takeout_service_timer / maxf(_takeout_service_interval(), 0.01), 0.0, 1.0) func takeout_needs_cook() -> bool: # Cuisiner ne dépend que du meuble, pas de son rideau: un comptoir fermé se # regarnit, et c'est même la seule façon de pouvoir le rouvrir. if not GameState.takeout_is_open() or not DataCatalog.RECIPES.has(GameState.takeout_recipe): return false if not GameState.takeout_should_restock(): return false return GameState.takeout_cooking_remaining > 0.0 or GameState.can_cook(GameState.takeout_recipe) func takeout_cook_priority() -> float: if not takeout_needs_cook(): return 0.0 var capacity := maxi(1, GameState.takeout_capacity()) var empty_ratio := 1.0 - float(GameState.takeout_stock) / float(capacity) var queue_urgency := 1.5 if not takeout_queue.is_empty() else 0.0 return maxf(0.0, float(GameState.resolve_game_variable("takeout_priority"))) * (0.5 + empty_ratio + queue_urgency) func takeout_work_rate() -> float: var labor := 1.0 if takeout_player_is_preparing() else 0.0 if takeout_needs_cook() and takeout_cook_is_assigned() and takeout_cook.is_tending_takeout(): labor += takeout_cook.cooking_rate_for("takeout", GameState.takeout_recipe) return labor * maxf(0.0, float(GameState.resolve_game_variable("takeout_cook_speed"))) func _process_takeout(delta: float) -> void: _process_takeout_service(delta) if not GameState.takeout_is_open(): return var work_rate := takeout_work_rate() if work_rate <= 0.0: return if GameState.takeout_cooking_remaining <= 0.0 and not GameState.start_takeout_dish(): return if GameState.advance_takeout_cooking(delta, work_rate): var recipe_id := GameState.takeout_recipe GameState.add_entity_counter("recipes", recipe_id, "total_cooked") Registry.run_hook("recipes", recipe_id, "on_cooked", takeout_prop) if takeout_cook_is_assigned(): GameState.add_entity_counter("employees", takeout_cook.employee_id, "total_dishes_cooked", 1) GameState.toast_requested.emit(Loc.ui("ui.restaurant.takeout_ready") % DataCatalog.recipe_name(recipe_id)) # 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 _serve_takeout_customer(candidate: Customer) -> bool: if not GameState.takeout_counter_is_open() or not takeout_queue.has(candidate): return false var recipe_id := GameState.takeout_recipe var price := GameState.takeout_sale_price() if price <= 0 or candidate.cash < price: return false var payout := GameState.sell_takeout_dish() if payout <= 0: return false candidate.cash -= price GameState.record_entity_event("recipes", recipe_id, "total_served", "dishes_served") GameState.add_entity_counter("customer_profiles", candidate.profile_id, "total_served") Registry.run_hook("recipes", recipe_id, "on_served", candidate) Registry.run_hook("customer_profiles", candidate.profile_id, "on_served", candidate) var popup := MONEY_POPUP_SCENE.instantiate() popup.setup_currency(payout, 0) popup.global_position = candidate.global_position + Vector2(0, -30) add_child(popup) candidate.begin_leaving() return true func _build_order_board() -> void: order_board_panel = OrderBoard.new() order_board_panel.name = "OrderBoard" var board_element := element_of("basic_order_board#0") order_board_panel.position = board_element.position if board_element != null else ORDER_BOARD_POSITION add_child(order_board_panel) if board_element != null: board_element.node = order_board_panel # Toute fiche sans nœud spécialisé reçoit au minimum son meuble procédural. Le # casier est le premier utilisateur de ce chemin; les postes connus gardent # leurs scripts dédiés, et un futur décor de catalogue ne restera plus invisible. func _build_unbound_element_props() -> void: for element: RestaurantElement in elements: if element.kind == "table" or element.node != null: continue var definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS.get(element.source_id, {}) var prop := InteractiveProp.new() prop.name = "Furniture_%s" % element.id.replace("#", "_") prop.action = "restaurant" prop.prompt = "" prop.label = element.label prop.color = definition.get("fallback_color", Color("718096")) prop.size = element.size() prop.position = element.position prop.usable_faces = element.usable_faces() prop.interactive_enabled = false add_child(prop) element.node = prop 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: 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, TAKEOUT_CENTER_X) # The second decision point belongs to the physical counter. A passerby who is # interested joins this line here; otherwise they keep strolling toward the # regular restaurant entrance. func passerby_reached_takeout(candidate: Customer) -> void: var verdict := evaluate_takeout_join(candidate) if not bool(verdict.join): return var starts_new_line := takeout_queue.is_empty() takeout_queue.append(candidate) candidate.requested_recipe = GameState.takeout_recipe candidate.join_takeout_queue(_takeout_queue_slot(takeout_queue.size() - 1), float(verdict.acceptable_wait)) # A newcomer at the back must never restart the customer already being # served. Initialize the clock only when this customer creates the line. if starts_new_line: takeout_service_timer = _takeout_service_interval() # 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: 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) func _takeout_queue_slot(index: int) -> Vector2: return Vector2(TAKEOUT_CENTER_X - TAKEOUT_QUEUE_FIRST_OFFSET - float(index) * TAKEOUT_QUEUE_SLOT_SPACING, TAKEOUT_QUEUE_Y) func _takeout_service_interval() -> float: var speed := maxf(0.01, float(GameState.resolve_game_variable("takeout_service_speed"))) return 1.0 / speed func evaluate_takeout_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 not GameState.takeout_counter_is_open() or not DataCatalog.RECIPES.has(GameState.takeout_recipe): verdict.reason = "closed" return verdict if takeout_queue.size() >= GameState.takeout_queue_capacity(): verdict.reason = "queue_full" return verdict if candidate.cash < GameState.takeout_sale_price(): verdict.reason = "too_expensive" return verdict # Every queued sale is backed by a finished dish. Production may refill the # counter while the line moves, but it never promises ingredients not cooked. if GameState.takeout_stock <= takeout_queue.size(): verdict.reason = "out_of_stock" return verdict var appeal := clampf(float(GameState.resolve_game_variable("takeout_appeal")), 0.0, 1.0) if candidate.favorite_meal == GameState.takeout_recipe: appeal = maxf(appeal, candidate.favorite_chance) else: appeal *= 0.35 + candidate.hunger * 0.65 if randf() > appeal: verdict.reason = "not_interested" return verdict var wait := float(takeout_queue.size() + 1) * _takeout_service_interval() / takeout_service_multiplier() verdict.estimated_wait = wait if wait > acceptable: verdict.reason = "too_slow" return verdict verdict.join = true verdict.reason = "joins" return verdict func _process_takeout_service(delta: float) -> void: if not GameState.takeout_counter_is_open() or takeout_queue.is_empty(): takeout_service_timer = 0.0 return while not takeout_queue.is_empty(): var candidate: Customer = takeout_queue[0] if is_instance_valid(candidate) and candidate.state == Customer.State.TAKEOUT_QUEUED: break takeout_queue.remove_at(0) if takeout_queue.is_empty(): takeout_service_timer = 0.0 return if GameState.takeout_stock <= 0: return var next_customer: Customer = takeout_queue[0] # The service clock starts only once the head of the line has physically # reached the counter slot. Joining the line can never create an instant sale. if next_customer.global_position.distance_to(next_customer.queue_position) > 6.0: return takeout_service_timer = maxf(0.0, takeout_service_timer - delta * takeout_service_multiplier()) if takeout_service_timer > 0.0: return if not _serve_takeout_customer(next_customer): next_customer.begin_leaving() takeout_queue.remove_at(0) _reflow_takeout_queue() takeout_service_timer = _takeout_service_interval() func _reflow_takeout_queue() -> void: for index: int in range(takeout_queue.size()): if is_instance_valid(takeout_queue[index]): takeout_queue[index].move_in_queue(_takeout_queue_slot(index)) # --- 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 not dining_is_open(): 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. # Ce que le passant pourrait commander s'il entrait: une recette que le magasin # sait faire, à sa portée. On ne regarde ici QUE le prix — la réserve est jugée # juste après, et c'est ce qui permet de dire « rupture » au lieu de « trop # cher » quand le buffet est vide. Confondre les deux donnait au joueur une # raison fausse, et la vraie n'apparaissait jamais. func affordable_recipes(budget: int) -> Array[String]: var result: Array[String] = [] for recipe_id: String in GameState.unlocked_recipes(): 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. # Les deux files se dispersent séparément: fermer la salle ne renvoie pas ceux # qui attendent au comptoir, et réciproquement. func _disperse_queue() -> void: for waiting: Customer in queue.duplicate(): if is_instance_valid(waiting): waiting.begin_leaving() queue.clear() func _disperse_takeout_queue() -> void: for waiting: Customer in takeout_queue.duplicate(): if is_instance_valid(waiting): waiting.begin_leaving() takeout_queue.clear() takeout_service_timer = 0.0 func customer_left_queue(leaving: Customer) -> void: if takeout_queue.has(leaving): var was_front := takeout_queue[0] == leaving takeout_queue.erase(leaving) _reflow_takeout_queue() # Someone abandoning the back of the line does not erase the work already # spent on its first customer. if was_front: takeout_service_timer = _takeout_service_interval() if not takeout_queue.is_empty() else 0.0 return 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() if takeout_queue.has(gone): var was_front := takeout_queue[0] == gone takeout_queue.erase(gone) _reflow_takeout_queue() if was_front: takeout_service_timer = _takeout_service_interval() if not takeout_queue.is_empty() else 0.0 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) # Cette évaluation ne fait plus que **baisser** un rideau, en nommant sa raison. # Une réouverture automatique rouvrait le magasin dès qu'un œuf rentrait, sans # que le joueur ait vu pourquoi il avait fermé: rouvrir est désormais un geste. 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() if dining_is_open(): var dining_reason := _dining_closure_reason() if not dining_reason.is_empty() and GameState.close_dining(dining_reason): GameState.toast_requested.emit(Loc.ui(GameState.dining_close_reason_key(dining_reason))) _apply_dining_closure() if takeout_counter_is_serving(): var counter_reason := _takeout_closure_reason() if not counter_reason.is_empty() and GameState.close_takeout_counter(counter_reason): GameState.toast_requested.emit(Loc.ui(GameState.takeout_close_reason_key(counter_reason))) _disperse_takeout_queue() _refresh_takeout_counter() _refresh_closure_display() # Pourquoi la salle devrait fermer, ou une chaîne vide si elle peut servir. func _dining_closure_reason() -> String: if GameState.restaurant_operations_can_continue(): return "" if GameState.unlocked_recipes().is_empty(): return GameState.CLOSE_REASON_NO_RECIPE if not _has_food_supply(): return GameState.CLOSE_REASON_INGREDIENTS return "" # Le comptoir tient tant qu'il lui reste des plats à vendre: c'est la réserve # déjà cuisinée qui le garde ouvert, pas le stock d'ingrédients. func _takeout_closure_reason() -> String: if not GameState.takeout_is_open(): return "" if not DataCatalog.RECIPES.has(GameState.takeout_recipe): return GameState.CLOSE_REASON_NO_RECIPE if GameState.takeout_stock > 0 or GameState.takeout_cooking_remaining > 0.0: return "" if GameState.can_cook(GameState.takeout_recipe): return "" return GameState.CLOSE_REASON_INGREDIENTS func _apply_dining_closure() -> void: _set_door_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() _refresh_closure_display() func _apply_dining_opening() -> void: _set_door_closed(false) arrival_timer = minf(arrival_timer, 1.0) _refresh_closure_display() # Le bandeau du HUD dit **pourquoi** c'est fermé, et par où rouvrir. func _refresh_closure_display() -> void: if hud == null or not is_instance_valid(hud): return if dining_is_open(): hud.set_restaurant_closed(false) return hud.set_restaurant_closed(true, "%s %s" % [ Loc.ui(GameState.dining_close_reason_key(GameState.dining_close_reason)), Loc.ui("ui.restaurant.closed_reopen_hint"), ]) 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 dining_is_open() 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(not dining_is_open()) 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), table_seat_position(index), true, table_draws_body(index)) func _draw_table(table_position: Vector2, seat_position: Vector2, unlocked: bool, draw_top: bool = true) -> 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) # Le tabouret reste dessiné même quand la table porte une image: il tourne # autour d'elle et aucune face publiée ne le montre. draw_circle(seat_position, CHAIR_RADIUS, chair_color) draw_circle(seat_position, CHAIR_RADIUS, Color("e0ad74") if unlocked else Color("55475e"), false, 3.0) if not draw_top: return 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 not dining_is_open(): 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"))