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") # Le restaurant compact reprend la moitié de la largeur d'origine, puis les # 140 px minimaux qui rendent chaque poste et ses faces circulables. Sa hauteur # reste aux trois quarts de l'ancien plan. const ROOM_RECT := Rect2(510, 200, 1040, 652.5) const ROOM_LAYOUT_REVISION := 4 # 44 / 3: dessin, collision et volume 3D lisent tous cette même épaisseur. const WALL_THICKNESS := 44.0 / 3.0 const DOOR_CENTER_X := 1145.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 := 5 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(920, 470) const TABLE_SPACING := Vector2(120, 140) # The default stool sits north of the table. A seated customer then faces south, # leaving the plate and tabletop readable from the restaurant camera. 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 static func default_table_seat_offset() -> Vector2: return Vector2(0.0, -SEAT_OFFSET) # 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(530, 245, 290, 580) const STATION_ORIGIN := Vector2(574.333333, 330) const STATION_SPACING := 90.0 const MAX_STATIONS := 4 const CONSOLE_POSITION := Vector2(1400, 240) 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 := 900.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_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(870, 400, 650, 425) 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(680, 745) # 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. # --- La rue ------------------------------------------------------------------- # # La rue longe **une face** de la salle, et tout ce qui la concerne s'en déduit: # le trottoir, la porte, la file du magasin, celle du comptoir, et le sens de # marche des passants. # # Elle était câblée au mur nord, dans le sens est-ouest. Avec la vue en 3/4 de # la branche 3D, une rue au nord se retrouve loin et écrasée; sur la face ouest # elle longe le bord de l'image et se lit. Le côté est donc **déclaré** au lieu # d'être supposé — et les deux valeurs marchent. const STREET_SIDE := "east" # L'ancien trottoir faisait 52 unités. À 156, son demi-gabarit vaut 78; # 98 laisse les mêmes 20 unités d'air entre sa bordure et le mur. const STREET_GAP := 98.0 static func street_is_vertical() -> bool: return STREET_SIDE == "west" or STREET_SIDE == "east" # L'axe de marche des passants. static func street_axis() -> Vector2: return Vector2.DOWN if street_is_vertical() else Vector2.RIGHT # La coordonnée fixe de la rue, déduite du plan de la salle: la rue longe la # façade, à un écart constant. Écrire ce nombre à la main le désaccorderait au # premier déplacement de mur. static func street_line() -> float: match STREET_SIDE: "west": return ROOM_RECT.position.x - STREET_GAP "east": return ROOM_RECT.end.x + STREET_GAP _: return 145.0 const NORTH_SIDEWALK_Y := ROOM_RECT.position.y - STREET_GAP const EAST_SIDEWALK_X := ROOM_RECT.end.x + STREET_GAP # Kept as an alias for the horizontal passerby fixtures used by older saves and # focused smoke tests. The live route now follows both legs of the L. const SIDEWALK_Y := NORTH_SIDEWALK_Y const TAKEOUT_QUEUE_Y := NORTH_SIDEWALK_Y const SIDEWALK_BAND := 156.0 const SIDEWALK_LEFT := 450.0 const SIDEWALK_RIGHT := EAST_SIDEWALK_X const SIDEWALK_BOTTOM := ROOM_RECT.end.y + 60.0 const PASSERBY_LANE_OFFSET := SIDEWALK_BAND * 0.28 const ENTRANCE := Vector2(DOOR_CENTER_X, SIDEWALK_Y) # Le seuil réel, du côté que la rue longe. Écrit en constante, il restait au mur # nord quand la porte passait à l'est: les clients entraient par un mur plein et # le traversaient pour rejoindre leur table. static func entrance_point() -> Vector2: if not street_is_vertical(): return ENTRANCE # Les clients naissent et quittent la salle juste à l'extérieur du mur. Le # point précédent était 34 px *dans* la pièce: sur une façade est, ils # apparaissaient donc derrière le mur avant de rebrousser chemin. var outside := WALL_THICKNESS * 0.5 + 16.0 var x := ROOM_RECT.position.x - outside if STREET_SIDE == "west" else ROOM_RECT.end.x + outside return Vector2(x, street_door()) # Un point de la rue: `along` avance le long de l'axe de marche, la coordonnée # perpendiculaire est celle de la rue. Toute la géométrie de rue passe par ici. static func street_point(along: float, across: float = 0.0) -> Vector2: return Vector2(street_line() + across, along) if street_is_vertical() \ else Vector2(along, street_line() + across) # Normale qui va du restaurant vers la rue. L'évitement des passants choisit # ce côté pour ne jamais contourner une file en traversant le mur. static func street_outward() -> Vector2: match STREET_SIDE: "west": return Vector2.LEFT "east": return Vector2.RIGHT "south": return Vector2.DOWN _: return Vector2.UP # Les deux bouts du trottoir, dans l'ordre de marche: toute la façade, débordée # des deux côtés pour que les passants entrent et sortent du cadre. static func street_span() -> Vector2: if street_is_vertical(): return Vector2(ROOM_RECT.position.y - 60.0, ROOM_RECT.end.y + 60.0) return Vector2(SIDEWALK_LEFT, SIDEWALK_RIGHT) # Où se trouve la porte le long de la rue, et où le comptoir takeout la croise. # Les deux se placent aux mêmes fractions de façade que dans la version d'origine. static func street_door() -> float: if street_is_vertical(): return ROOM_RECT.position.y + ROOM_RECT.size.y * 0.64 return DOOR_CENTER_X static func street_takeout() -> float: if street_is_vertical(): return ROOM_RECT.position.y + ROOM_RECT.size.y * 0.22 return TAKEOUT_CENTER_X # The public sidewalk is an L: its north leg serves the takeout counter and its # east leg serves the dining-room entrance. Both rectangles overlap at the turn # so the 2D drawing and 3D slabs never reveal a seam. static func north_sidewalk_rect() -> Rect2: var top := NORTH_SIDEWALK_Y - SIDEWALK_BAND * 0.5 var right := EAST_SIDEWALK_X + SIDEWALK_BAND * 0.5 return Rect2(SIDEWALK_LEFT - 60.0, top, right - (SIDEWALK_LEFT - 60.0), SIDEWALK_BAND) static func east_sidewalk_rect() -> Rect2: var left := EAST_SIDEWALK_X - SIDEWALK_BAND * 0.5 var top := NORTH_SIDEWALK_Y - SIDEWALK_BAND * 0.5 return Rect2(left, top, SIDEWALK_BAND, SIDEWALK_BOTTOM + 60.0 - top) static func sidewalk_rects() -> Array[Rect2]: var result: Array[Rect2] = [north_sidewalk_rect(), east_sidewalk_rect()] return result static func sidewalk_bounds() -> Rect2: return north_sidewalk_rect().merge(east_sidewalk_rect()) # The restaurant camera deliberately crops the distant ends of the public # route. It keeps the complete room, the north storefront and the east sidewalk # in frame, matching the useful composition rather than the pedestrians' entire # off-screen spawn path. static func camera_frame_rect() -> Rect2: var north := north_sidewalk_rect() var east := east_sidewalk_rect() return Rect2( Vector2(ROOM_RECT.position.x, north.position.y), Vector2(east.end.x - ROOM_RECT.position.x, ROOM_RECT.end.y - north.position.y)) # A signed lane is applied on both legs. That produces a true right-angle turn: # the horizontal lane and vertical lane meet at the same corner waypoint. static func sidewalk_route(forward: bool, lane: float) -> Array[Vector2]: var northwest := Vector2(SIDEWALK_LEFT, NORTH_SIDEWALK_Y + lane) var northeast := Vector2(EAST_SIDEWALK_X + lane, NORTH_SIDEWALK_Y + lane) var southeast := Vector2(EAST_SIDEWALK_X + lane, SIDEWALK_BOTTOM) var result: Array[Vector2] = [] if forward: result.append(northwest) result.append(northeast) result.append(southeast) else: result.append(southeast) result.append(northeast) result.append(northwest) return result static func sidewalk_takeout_point(lane: float) -> Vector2: return Vector2(TAKEOUT_CENTER_X, NORTH_SIDEWALK_Y + lane) static func sidewalk_door_point(lane: float) -> Vector2: return Vector2(EAST_SIDEWALK_X + lane, street_door()) # Les rares clients créés directement au seuil n'ont pas l'ancien trajet de # passant à reprendre. Ce petit raccord les amène dans une voie du trottoir, # puis jusqu'à son extrémité hors du cadre utile de la caméra. static func customer_departure_route() -> Array[Vector2]: var public_route := sidewalk_route(true, PASSERBY_LANE_OFFSET) return [entrance_point(), sidewalk_door_point(PASSERBY_LANE_OFFSET), public_route[-1]] # Crowd avoidance always chooses the outside of the current leg, never a # detour through the restaurant wall. This replaces the single eastward normal # only for live L-shaped routes; `street_outward()` remains the legacy fallback. static func sidewalk_outward_at(point: Vector2) -> Vector2: if absf(point.y - NORTH_SIDEWALK_Y) <= absf(point.x - EAST_SIDEWALK_X): return Vector2.UP return Vector2.RIGHT # The takeout counter remains anchored to the north wall. The new north leg of # the sidewalk now passes directly outside it, while its two work spots face # inward so employees can prepare and hand over orders from inside the room. static func takeout_facing() -> Vector2: return Vector2.DOWN # The two work spots spread along the counter's horizontal service face. static func takeout_prep_point() -> Vector2: return takeout_position() + takeout_facing() * 86.0 + Vector2(-62.0, 0.0) static func takeout_service_point() -> Vector2: return takeout_position() + takeout_facing() * 86.0 + Vector2(62.0, 0.0) static func takeout_server_offset() -> Vector2: return Vector2(50.0, 0.0) static func takeout_position() -> Vector2: return TAKEOUT_POSITION # Compatibility accessor for code that needs the dining-entrance segment. New # rendering and camera code iterate `sidewalk_rects()` to cover the complete L. static func street_rect() -> Rect2: return east_sidewalk_rect() # 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] = [] ## Les lignes de lavage qui tournent, une entree par assiette en cours. ## Une assiette lavee ne transporte rien — elle a deja encaisse en devenant ## sale — donc la plonge n'est qu'une affaire de comptes et de temps. var wash_running: Array[Dictionary] = [] ## Les assiettes qui attendent leur tour. Un nombre suffit, pour la meme ## raison: elles sont toutes identiques du point de vue de la machine. var wash_queue := 0 # 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 var _layout_reflowed := false 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) recipe_menu.weapon_purchase_requested.connect(_on_console_weapon_purchase) recipe_menu.weapon_equip_requested.connect(_on_console_weapon_equip) recipe_menu.equipment_purchase_requested.connect(_on_console_equipment_purchase) recipe_menu.equipment_equip_requested.connect(_on_console_equipment_equip) recipe_menu.recipe_consumable_prepare_requested.connect(_on_console_recipe_consumable_prepare) recipe_menu.consumable_equip_requested.connect(_on_console_consumable_equip) 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() _restore_washing() _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) if _layout_reflowed: # La sauvegarde suivante doit porter le plan compact, pas refaire la # transposition à chaque retour dans la salle. call_deferred("save_layout") 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") _advance_washing(delta) _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()]) # La console s'ouvre de n'importe où dans la salle. # # C'est `_input` et non `_unhandled_input`: une touche de tabulation sert aussi à # l'interface pour passer d'un bouton à l'autre, et le HUD en a. Laissée passer, # elle aurait été mangée par le bouton qui avait le focus, et la console ne se # serait ouverte que par hasard. Quand le menu est déjà ouvert, c'est lui qui # ferme: le geste reste unique, l'endroit qui y répond change. func _input(event: InputEvent) -> void: if not event.is_action_pressed("console_toggle"): return if get_tree().paused or transitioning or build_mode_is_active() \ or recipe_menu.visible or not console_is_installed(): return get_viewport().set_input_as_handled() handle_interaction("console") 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: # Dimension-one kitchen reserves two workstations on the west wall. # Keep legacy extra instances valid without crowding the washing area. if index >= 2: return Vector2(930 + float(index - 2) * 110, 795) 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.wall_bounds = ROOM_RECT 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": # The placement footprint includes the northern stool. Its offset rotates # with the table through RestaurantElement.footprint_at(). element.footprint_offset = default_table_seat_offset().normalized() \ * ((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(700.0, (default_station_center(0).y + default_station_center(SHELF_STOVE_SPAN - 1).y) * 0.5) "basic_dishwasher": return Vector2(free_placement_rect().position.x + float(DataCatalog.RESTAURANT_ELEMENTS[source_id].width) * 0.5, 540.0) "basic_trash": return Vector2(free_placement_rect().position.x + float(DataCatalog.RESTAURANT_ELEMENTS[source_id].width) * 0.5, 640.0) "basic_console": return CONSOLE_POSITION "basic_takeout_counter": return takeout_position() "basic_order_board": return ORDER_BOARD_POSITION "employee_locker": return Vector2(795.0, 340.0) # 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() _layout_reflowed = false 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. Les # plans antérieurs au restaurant compact gardent leur présence, mais leurs # coordonnées et rotations sont refluées vers cette nouvelle disposition: # réduire de moitié un plan libre ne peut pas préserver ses couloirs. var layout_meta: Variant = GameState.restaurant_layout.get("_room_revision", {}) var saved_revision := int((layout_meta as Dictionary).get("version", 0)) \ if layout_meta is Dictionary else 0 var needs_reflow := not GameState.restaurant_layout.is_empty() and saved_revision < ROOM_LAYOUT_REVISION for element: RestaurantElement in elements: var record := GameState.restaurant_layout_entry(element.id) if not record.is_empty(): element.apply_record(record) # Revision 4 only tidies kitchen appliances. Preserve the author's # tables, storage, decorations and manually dismissed furniture. if needs_reflow and (saved_revision < 3 or element.source_id in ["basic_stove", "basic_dishwasher", "basic_trash"]): element.position = _default_position(element.source_id, element.instance_index) element.rotation_steps = 0 _layout_reflowed = true if element.position.is_equal_approx(Vector2.ZERO): element.position = first_free_position(element) # 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 var prop: InteractiveProp match source_id: "basic_stove": prop = cooking_station "basic_shelf": prop = shelf_station "basic_dishwasher": prop = dishwasher_station "basic_trash": prop = trash_station if prop != null: element.node = prop prop.label = element.label 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.kind == "board": return ROOM_RECT.grow(WALL_THICKNESS * 0.5) 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.kind == "board" and element.wall_side_at(at) == STREET_SIDE: var along := at.y if street_is_vertical() else at.x if absf(along - street_door()) < DOOR_WIDTH * 0.5 + element.base_size.x * 0.5: return Loc.ui("ui.build.stay_on_wall") if element.anchor == RestaurantElement.ANCHOR_NORTH_WALL: if absf(at.y - element.position.y) > 0.5: return Loc.ui("ui.build.stay_on_wall") # Only the takeout counter must remain left of its service opening. Other # north-wall fixtures, notably the management console, may use the whole # wall as long as their footprint stays between its inner corners. if element.source_id == "basic_takeout_counter" \ and 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 \ or box.end.x > ROOM_RECT.end.x - WALL_THICKNESS: return Loc.ui("ui.build.inside_room") elif not RestaurantElement.fits_inside(box, placement_area(element)): 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. # La salle a-t-elle deja connu ce meuble? Un exemplaire suspendu — sa limite # est retombee, son prerequis a disparu — garde son entree au plan et revient # des que la voie se rouvre. Un exemplaire JAMAIS introduit, lui, n'a pas # d'entree: c'est un meuble qu'on vient de debloquer. func _room_knows(element: RestaurantElement) -> bool: return not GameState.restaurant_layout_entry(element.id).is_empty() func _sync_placement_with_limits() -> void: # Debloquer un meuble le rend DISPONIBLE, il ne le pose pas: il attend dans # le menu de construction, et c'est le joueur qui choisit sa place. Seule une # salle encore sans plan — une dimension neuve — s'installe toute seule. var fresh_room := GameState.restaurant_layout.is_empty() # 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 \ and (fresh_room or _room_knows(element)): # Une position ecrite n'est pas forcement LIBRE: un meuble qui # arrive apparaissait sur un autre. On la garde si elle tient, # et on cherche la premiere place sinon. if element.position.is_equal_approx(Vector2.ZERO) \ or not str(placement_blocker(element, element.position)).is_empty(): element.position = first_free_position(element) element.placed = true changed = true func apply_layout() -> void: var was_fresh := GameState.restaurant_layout.is_empty() _sync_placement_with_limits() # La salle neuve s'installe, puis ecrit son plan: sans cela, chaque deblocage # suivant se croirait encore dans une salle neuve et se poserait tout seul. if was_fresh and not elements.is_empty(): save_layout() # 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 OrderBoard: (node as OrderBoard).display_size = element.base_size (node as OrderBoard).wall_side = element.wall_side_at(element.position) 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 + default_table_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.show_when(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 + default_table_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 = {"_room_revision": {"version": ROOM_LAYOUT_REVISION}} 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.show_static_label = cooking_station.show_static_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" var station_element := element_of(RestaurantElement.instance_id("basic_stove", index)) prop.label = Loc.ui("ui.station.spices") if spices else ( station_element.label if station_element != null 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: return var prop: InteractiveProp = stations[target].prop var beside := prop.position + Vector2(-prop.size.x * 0.5 - 6.0, -prop.size.y * 0.5 - 6.0) # En rendu 3D la caméra 2D est éteinte: laissé à ses coordonnées de monde, le # bouton se dessinait n'importe où à l'écran, détaché du four qu'il annule. # On le pose donc là où la caméra montre réellement ce four. var projected := Bridge3D.world_to_screen(prop.global_position, Bridge3D.entity_top_elevation(prop) * 0.5) if Render3D.active else Vector2.INF cancel_button.position = projected + Vector2(-prop.size.x * 0.5 - 6.0, 0.0) \ if projected.is_finite() else beside 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: "dishwasher": # Une barre PAR ligne de lavage: trois assiettes qui tournent, trois # barres. La file, elle, se dit en toutes lettres. var wash_ratios := washing_progress_lines() if wash_ratios.is_empty(): return {} var wash_text := Loc.ui("ui.station.washing") % wash_ratios.size() if wash_queue > 0: wash_text += " · " + Loc.ui("ui.station.wash_queue") % wash_queue return {"lines": [wash_text], "progress": wash_ratios[0], "progress_lines": wash_ratios} "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(GameState.recipe_of(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": # `is` sur une instance deja liberee plante avant meme la garde: `as` # rend null, et une assiette disparue cesse d'etre rangeable. return (current_player.held_dish as Dish) != null 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(GameState.recipe_of(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 # Ce service-ci compte deja pour le niveau: le plat qui FAIT franchir le # seuil serait sinon le seul paye a l'ancien prix, alors que c'est lui qui # a ouvert le palier. var price := int(GameState.recipe_of(dish.recipe_id, 1).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 # Garder un client a table vaut mieux que le remplacer: chaque plat deja # mange par celui-ci ajoute un palier au paiement du suivant. var repeat_step := maxf(0.0, float(GameState.resolve_game_variable("repeat_meal_payout_step"))) var repeat_bonus := 1.0 + float(current_customer.meals_eaten) * repeat_step var payout := roundi(float(price) * float(GameState.resolve_game_variable("payout")) * repeat_bonus) 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) var service_wait := current_customer.order_wait_elapsed 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) # Servir vite compte davantage que servir: c'est la patience restante qui # tranche, la meme mesure que celle qui decide du pourboire. var fast_threshold := clampf(float(GameState.resolve_game_variable("fast_service_ratio")), 0.0, 1.0) adjust_popularity_for(current_customer, "served_fast" if service_ratio >= fast_threshold else "served") _penalize_service_overtakes(current_customer, service_wait) 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") # Only a successfully delivered dining meal can overtake another order. # Each older waiting diner reacts once to this delivery, without leaving. func _penalize_service_overtakes(served: Customer, service_wait: float) -> void: for waiting: Customer in customers: if not is_instance_valid(waiting) or waiting.is_queued_for_deletion() or waiting == served: continue if waiting.state != Customer.State.WAITING: continue if waiting.order_wait_elapsed > service_wait + 0.0001: adjust_popularity_for(waiting, "service_overtaken") # Ce qui deplace la reputation, et de combien. # # Chaque situation nomme le coefficient qui la pondere et le sens du mouvement. # Le montant vaut toujours `poids du client x coefficient x multiplicateur # global` — un seul calcul, un seul point d'entree. C'est la que mord un upgrade # qui vise « toutes les hausses » ou « toutes les baisses », et c'est pour ca # qu'aucun appelant ne calcule son montant lui-meme. # Chaque situation dit ce qu'elle vaut pour la réputation, et si le client la # vit comme une déception. Les deux ne se déduisent pas l'une de l'autre: # changer de commande coûte de la réputation sans faire partir personne, donc # ce client-là encaisse et reste — il n'a pas d'adieu à dire. const POPULARITY_SITUATIONS := { "served": {"variable": "popularity_coef_served", "gain": true}, "served_fast": {"variable": "popularity_coef_served_fast", "gain": true}, "takeout_served": {"variable": "popularity_coef_takeout_served", "gain": true}, "left_waiting": {"variable": "popularity_coef_left_waiting", "gain": false, "disappointed": true}, "left_closed": {"variable": "popularity_coef_left_closed", "gain": false, "disappointed": true}, "order_changed": {"variable": "popularity_coef_order_changed", "gain": false}, "service_overtaken": {"variable": "popularity_coef_service_overtaken", "gain": false}, "queue_gave_up": {"variable": "popularity_coef_queue_gave_up", "gain": false, "disappointed": true}, "queue_closed": {"variable": "popularity_coef_queue_closed", "gain": false, "disappointed": true}, "takeout_left": {"variable": "popularity_coef_takeout_left", "gain": false, "disappointed": true}, } func adjust_popularity_for(who: Customer, situation: String) -> float: var entry: Dictionary = POPULARITY_SITUATIONS.get(situation, {}) if entry.is_empty() or who == null or not is_instance_valid(who): return get_popularity() # Toutes les déceptions passent ici, parce que c'est le seul endroit qui # connaît la situation. Renoncer soi-même et se faire fermer la porte au nez # se disent de la même façon: le client repart sans avoir mangé. Sans message # écrit pour ce cas, `say()` rend faux et rien ne s'affiche. if bool(entry.get("disappointed", false)): who.say("disappointed") var coefficient := maxf(0.0, float(GameState.resolve_game_variable(str(entry.variable)))) var is_gain := bool(entry.gain) var multiplier := maxf(0.0, float(GameState.resolve_game_variable( "popularity_gain_multiplier" if is_gain else "popularity_loss_multiplier"))) var amount := maxf(0.0, who.popularity_weight) * coefficient * multiplier return adjust_popularity(amount if is_gain else -amount) # 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, situation: String = "left_waiting") -> void: if current_customer.meals_eaten > 0: return adjust_popularity_for(current_customer, situation) 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 var center: Vector2 = tables[current_customer.table_index].center var customer_side := center.direction_to(current_customer.seat_position) return center + customer_side * 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 if current_customer.meals_eaten < current_customer.appetite: current_customer.begin_reorder_pause() # Une assiette devrait toujours porter le repas. Si elle a disparu à cause # d'une ancienne sauvegarde, ne jamais laisser le client bloqué à table. if serving_dish == null: current_customer.start_reorder_delay() else: current_customer.begin_leaving() _persist_room() call_deferred("evaluate_restaurant_closure") func customer_reorder_delay_finished(current_customer: Customer) -> void: if current_customer.state != Customer.State.BETWEEN_ORDERS: return var next_recipe := _choose_available_recipe(current_customer) if 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) # Ce que ce client commande, parmi ce qu'il peut payer et qu'on sait produire. # # Deux forces se multiplient: le **gout** (le produit des poids qu'il donne aux # categories du plat) et le **biais de prix** (sa disposition a depenser, lue # relativement a l'eventail du jour, pas dans l'absolu). Un plat qu'il n'aime # qu'a moitie peut encore sortir, mais il devra passer un second jet: c'est la # qu'un client difficile finit par repartir sans rien commander. func _choose_available_recipe(current_customer: Customer) -> String: var available: Array[String] = [] for recipe_id: String in DataCatalog.RECIPES: # Orders include cooking and plated portions, even after their last # ingredient was consumed. The stove menu only lists cookable recipes. if not _recipe_is_available(recipe_id): continue var price := int(GameState.recipe_of(recipe_id).sale_price) if price <= current_customer.cash: available.append(recipe_id) if available.is_empty(): return "" var cheapest := recipe_price(available[0]) var dearest := cheapest for recipe_id: String in available: var price := recipe_price(recipe_id) cheapest = mini(cheapest, price) dearest = maxi(dearest, price) var span := float(dearest - cheapest) var weights: Array[float] = [] var total := 0.0 for recipe_id: String in available: # Le prix ne pese que par comparaison: un plat est cher ou bon marche # relativement a ce que ce client peut s'offrir aujourd'hui. var ratio := 0.5 if span <= 0.0 else float(recipe_price(recipe_id) - cheapest) / span var price_bias := lerpf(1.0 - ratio, ratio, current_customer.spending) + 0.05 var weight := maxf(0.0, current_customer.recipe_taste(recipe_id) * price_bias) weights.append(weight) total += weight # Tout a zero: aucune envie ne survit, il repart sans commander. if total <= 0.0: return "" var threshold := randf() * total var chosen: String = available.back() for index: int in range(available.size()): threshold -= weights[index] if threshold < 0.0: chosen = available[index] break # Un plat qu'il aime moins que la normale n'est pas un plat qu'il commande: # le poids restant devient sa chance de rester, le reste le fait partir. var chosen_taste := current_customer.recipe_taste(chosen) if chosen_taste < 1.0 and randf() > chosen_taste: return "" return chosen func recipe_price(recipe_id: String) -> int: return int(DataCatalog.RECIPES.get(recipe_id, {}).get("sale_price", 0)) # Portions d'un plat qui ne demanderont plus rien à la réserve: celles qui # cuisent sur un poste, et celles qui attendent, cuites, sur une assiette. # # Une assiette en cours de repas (`SERVING`) appartient déjà à quelqu'un et ne # compte pour personne d'autre — c'est la règle que `_has_food_supply()` tenait # déjà de son côté. func _servings_ready(recipe_id: String) -> int: if not is_inside_tree() or get_tree() == null: return 0 var ready := 0 for index: int in range(visible_station_count()): if station_is_busy(index) and str(stations[index].recipe) == recipe_id: ready += 1 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: ready += 1 return ready func _recipe_is_available(recipe_id: String) -> bool: return GameState.can_cook(recipe_id) or _servings_ready(recipe_id) > 0 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 # L'assiette a deja encaisse en devenant sale: le lavage ne porte aucun # paiement, seulement le temps qu'il prend. dish.wash() var cycle := wash_duration() if cycle <= 0.0: # Une duree nulle est un lavage instantane: c'est ce que faisait la # plonge avant ce reglage, et l'auteur peut y revenir. _finish_washing() elif wash_running.size() < wash_line_count(): wash_running.append({"remaining": cycle, "total": cycle}) else: # Toutes les lignes tournent: l'assiette prend la file au lieu d'etre # refusee. Le joueur repose sa pile et repart. wash_queue += 1 _persist_room() call_deferred("evaluate_restaurant_closure") ## Combien de temps une assiette passe dans le bac. C'est une valeur d'auteur, ## resolue comme toutes les autres. func wash_duration() -> float: return maxf(0.0, float(GameState.resolve_game_variable("wash_time"))) ## Combien d'assiettes la plonge lave EN MEME TEMPS. Une valeur d'auteur, ## qu'une amelioration peut faire monter. func wash_line_count() -> int: return clampi(int(GameState.resolve_game_variable("wash_lines")), 1, 3) func washing_is_busy() -> bool: return not wash_running.is_empty() or wash_queue > 0 ## Ou en est chaque ligne, de 0 a 1, dans l'ordre ou elles finiront. Chacune ## porte sa propre barre: une plonge a trois lignes montre trois barres. func washing_progress_lines() -> Array[float]: var ratios: Array[float] = [] for line: Dictionary in wash_running: var total := maxf(0.001, float(line.get("total", 0.0))) ratios.append(clampf(1.0 - float(line.get("remaining", 0.0)) / total, 0.0, 1.0)) ratios.sort() ratios.reverse() return ratios ## La ligne la plus avancee, `-1` quand rien ne tourne. C'est elle qui dit ## quand une place se liberera. func washing_progress() -> float: var ratios := washing_progress_lines() return ratios[0] if not ratios.is_empty() else -1.0 func _advance_washing(delta: float) -> void: if wash_running.is_empty() and wash_queue <= 0: return var finished := 0 var remaining_delta := maxf(0.0, delta) var cycle := wash_duration() # Advance to each completion, so queued plates use the remainder of a frame. while true: if cycle <= 0.0: finished += wash_queue wash_queue = 0 while wash_queue > 0 and wash_running.size() < wash_line_count(): wash_queue -= 1 wash_running.append({"remaining": cycle, "total": cycle}) if wash_running.is_empty(): break var next_finish := INF for line: Dictionary in wash_running: next_finish = minf(next_finish, float(line.remaining)) var step := minf(remaining_delta, maxf(0.0, next_finish)) remaining_delta = maxf(0.0, remaining_delta - step) var completed := 0 for index: int in range(wash_running.size() - 1, -1, -1): wash_running[index].remaining = maxf(0.0, float(wash_running[index].remaining) - step) if float(wash_running[index].remaining) <= 0.0: wash_running.remove_at(index) completed += 1 finished += completed if completed == 0: break for _plate: int in finished: _finish_washing() # Keep progress available to saves even between plate completions. _persist_washing() if finished > 0: call_deferred("evaluate_restaurant_closure") func _persist_washing() -> void: GameState.restaurant_washing = {"running": wash_running.duplicate(true), "queue": wash_queue} func _restore_washing() -> void: var stored := GameState.restaurant_washing.duplicate(true) wash_running.clear() wash_queue = maxi(0, int(stored.get("queue", 0))) for entry: Variant in stored.get("running", []): if not entry is Dictionary: continue var total := maxf(0.001, float(entry.get("total", wash_duration()))) var remaining := clampf(float(entry.get("remaining", total)), 0.0, total) if remaining > 0.0: if wash_running.size() < wash_line_count(): wash_running.append({"remaining": remaining, "total": total}) else: wash_queue += 1 _persist_washing() func _finish_washing() -> void: GameState.toast_requested.emit(Loc.ui("ui.restaurant.plate_washed")) 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 var occupant: Variant = table.customers[0] if occupant is Customer and is_instance_valid(occupant) \ and (occupant as Customer).state == Customer.State.BETWEEN_ORDERS: (occupant as Customer).start_reorder_delay() _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.float_above(dish, 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) _persist_washing() # 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) \ or stored_state == int(Customer.State.BETWEEN_ORDERS) # 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_point()) 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, # One stool, north of the table by default. "seats": [center + default_table_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: var console_element := element_of("basic_console#0") console_prop = InteractiveProp.new() console_prop.name = "ManagementConsole" console_prop.action = "console" console_prop.prompt = Loc.ui("ui.prompt.console") console_prop.label = console_element.label if console_element != null else Loc.ui("ui.station.console") console_prop.show_static_label = false console_prop.color = Color("6cc4da") # The catalogue defines the physical footprint before `_ready()` creates the # collision body. Applying the layout later is too late for child hit zones, # exactly like it was for the takeout counter. console_prop.size = console_element.size() if console_element != null else Vector2(88, 65) console_prop.position = CONSOLE_POSITION 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, ses trois autres faces sont dans le mur: les # déclarer ne coûte rien et évite qu'un déplacement la rende inabordable. console_prop.usable_faces = InteractiveProp.FACE_ALL 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(847, 520) # À 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() func _on_console_weapon_purchase(weapon_id: String) -> void: if not GameState.purchase_weapon(weapon_id): GameState.toast_requested.emit(Loc.ui("ui.console.purchase_failed")) recipe_menu.build_list() func _on_console_weapon_equip(weapon_id: String, slot: int) -> void: GameState.equip_weapon(weapon_id, slot) recipe_menu.build_list() func _on_console_equipment_purchase(equipment_id: String) -> void: if not GameState.purchase_equipment(equipment_id): GameState.toast_requested.emit(Loc.ui("ui.console.purchase_failed")) recipe_menu.build_list() func _on_console_equipment_equip(equipment_id: String, slot: int) -> void: GameState.equip_equipment(equipment_id, slot) recipe_menu.build_list() func _on_console_recipe_consumable_prepare(recipe_id: String) -> void: if not GameState.prepare_recipe_consumable(recipe_id): GameState.toast_requested.emit("Ingrédients manquants ou capacité de plats pleine") else: GameState.toast_requested.emit("%s préparé" % DataCatalog.recipe_name(recipe_id)) recipe_menu.build_list() func _on_console_consumable_equip(consumable_id: String, slot: int) -> void: GameState.equip_consumable(consumable_id, slot) 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: var counter_element := element_of("basic_takeout_counter#0") takeout_prop = InteractiveProp.new() takeout_prop.name = "TakeoutCounter" takeout_prop.action = "takeout" takeout_prop.prompt = Loc.ui("ui.prompt.takeout") takeout_prop.label = counter_element.label if counter_element != null else Loc.ui("ui.station.takeout") takeout_prop.show_static_label = false takeout_prop.color = Color("7fcea5") # La taille vient de la **fiche**, pas d'un littéral. `apply_layout()` la lui # donnera de toute façon, mais trop tard: les deux zones de service se taillent # sur `takeout_prop.size` au moment de leur construction, juste en dessous. # Écrite en dur, la largeur laissait leurs bordures dimensionnées pour un # comptoir qui n'existe plus dès qu'on redimensionne le meuble. var counter_definition: Dictionary = DataCatalog.RESTAURANT_ELEMENTS.get("basic_takeout_counter", {}) takeout_prop.size = Vector2( float(counter_definition.get("width", 142.0)), float(counter_definition.get("height", 72.0))) takeout_prop.position = takeout_position() 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_point(), Color("d9a441")) takeout_service_spot = _build_takeout_spot("TakeoutServiceSpot", "takeout_service", takeout_service_point(), 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.show_static_label = false 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. Hovering either one shades # the counter visual itself instead of drawing a detached rectangle. spot.target_highlight_owner = takeout_prop 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(1) 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.float_above(candidate, 0.0, 30.0) add_child(popup) adjust_popularity_for(candidate, "takeout_served") 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.show_static_label = false 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. var occupied := busy_station_count() + wash_running.size() + wash_queue for dish: Node in get_tree().get_nodes_in_group("dish"): if not dish.is_queued_for_deletion(): occupied += 1 return occupied # 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 forward := randf() < 0.5 # Deux files de marche exploitent réellement le trottoir élargi. Leur écart # dépasse deux rayons de client: la file d'attente reste au centre et les # passants disposent d'une voie de chaque côté. var lane := PASSERBY_LANE_OFFSET if forward else -PASSERBY_LANE_OFFSET var route: Array[Vector2] = sidewalk_route(forward, lane) var passerby_profile := _pick_customer_profile() if passerby_profile.is_empty(): return var passerby: Customer = CUSTOMER_SCENE.instantiate() add_child(passerby) passerby.departed.connect(_on_customer_departed) customers.append(passerby) passerby.setup_passerby_path(passerby_profile, self, route, sidewalk_door_point(lane), sidewalk_takeout_point(lane)) # 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 street_point(street_door() - QUEUE_FIRST_OFFSET - float(index) * QUEUE_SLOT_SPACING) # The takeout line waits on the centre of the north sidewalk leg, directly in # front of the counter. Passers keep one walking lane on either side of it. 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 # Le comptoir ne propose qu'un plat: son gout pese donc directement sur # l'envie. Un plat neutre retrouve exactement l'attrait d'avant. var appeal := clampf(float(GameState.resolve_game_variable("takeout_appeal")), 0.0, 1.0) appeal = clampf(appeal * candidate.recipe_taste(GameState.takeout_recipe) * (0.35 + candidate.hunger * 0.65), 0.0, 1.0) 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 # Le gout decide aussi de s'arreter ou non, et il le fait sans aleatoire pour # garder la promesse de ce controle: c'est la faim qui abaisse l'exigence. # Un affame accepte une carte qui ne lui plait qu'a moitie; un difficile qui # n'a pas faim passe son chemin meme devant un comptoir libre. var best_taste := 0.0 for recipe_id: String in affordable: best_taste = maxf(best_taste, candidate.recipe_taste(recipe_id)) if best_taste < 1.0 - candidate.hunger: verdict.reason = "not_interested" 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(GameState.recipe_of(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: # Ce qui cuit et ce qui est déjà cuit est une portion servable: le passant # la compte comme accessible, et la réserve n'a plus à couvrir qu'elle. # Sans ce décompte il refusait d'entrer « rupture » devant une assiette # prête posée sur le comptoir, alors que le client attablé, lui, la voyait. var missing := servings - _servings_ready(recipe_id) if missing <= 0: return true var enough := true for ingredient_id: String in GameState.recipe_of(recipe_id).get("ingredients", {}): var needed := int(GameState.recipe_of(recipe_id).ingredients[ingredient_id]) * missing 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_point()) _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 la note est **la plus légère de la table** — bien # plus légère qu'un client qui renonce de lui-même. Elle n'est pas nulle pour # autant: quelqu'un s'est déplacé pour rien, et le voisinage s'en souvient. # 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): adjust_popularity_for(waiting, "queue_closed") waiting.begin_leaving() queue.clear() func _disperse_takeout_queue() -> void: for waiting: Customer in takeout_queue.duplicate(): if is_instance_valid(waiting): adjust_popularity_for(waiting, "takeout_left") 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() adjust_popularity_for(leaving, "takeout_left") # 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_for(leaving, "queue_gave_up") 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 # Le profil se choisit **avant** d'inscrire le client: si aucun n'est # autorisé, personne ne se présente, et la table reste libre. var profile_id := _pick_customer_profile() if profile_id.is_empty(): 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 entrance := entrance_point() + street_axis() * float(entrance_offset * 42) new_customer.setup(profile_id, self, table_index, seat_index, table.seats[seat_index], entrance) # Quand chaque profil pourra se représenter, en temps de jeu. Un profil absent # de la table n'a jamais eu à attendre. Le compte part de son **départ**, pas de # son arrivée: c'est ce que « délai avant réapparition » veut dire. var _profile_ready_at := {} # Combien d'exemplaires de ce profil sont là en ce moment, en comptant ceux qui # s'en vont: tant qu'il est à l'écran, il occupe sa place. func customers_present_of(profile_id: String) -> int: var present := 0 for known: Customer in customers: if is_instance_valid(known) and known.profile_id == profile_id: present += 1 return present # Ce qui empêche un profil de se présenter maintenant. Trois portes, dans l'ordre # du moins cher au plus cher à évaluer. func customer_profile_is_available(profile_id: String) -> bool: var profile: Dictionary = DataCatalog.CUSTOMER_PROFILES.get(profile_id, {}) if profile.is_empty(): return false if float(_profile_ready_at.get(profile_id, 0.0)) > GameState.game_time: return false var ceiling := int(profile.get("max_on_screen", 0)) if ceiling > 0 and customers_present_of(profile_id) >= ceiling: return false # Même vocabulaire que le déverrouillage d'un plat: fact, statistique ou # upgrade acheté. La réputation étant un fact, « au moins 25 » s'écrit sans # rien ajouter au schéma. return GameState.conditions_met(profile.get("conditions", [])) # Le profil du prochain client, ou rien du tout si aucun n'est autorisé. Rendre # un profil interdit par défaut ferait apparaître le client rare en boucle. func _pick_customer_profile() -> String: var total_weight := 0.0 for profile_id: String in DataCatalog.CUSTOMER_PROFILES: if customer_profile_is_available(profile_id): total_weight += float(DataCatalog.CUSTOMER_PROFILES[profile_id].get("arrival_weight", 1.0)) if total_weight <= 0.0: return "" var roll := randf() * total_weight for profile_id: String in DataCatalog.CUSTOMER_PROFILES: if not customer_profile_is_available(profile_id): continue roll -= float(DataCatalog.CUSTOMER_PROFILES[profile_id].get("arrival_weight", 1.0)) if roll <= 0.0: return profile_id return "" func _on_customer_departed(current_customer: Customer) -> void: var cooldown := float(DataCatalog.CUSTOMER_PROFILES.get(current_customer.profile_id, {}) .get("respawn_cooldown", 0.0)) if cooldown > 0.0: _profile_ready_at[current_customer.profile_id] = GameState.game_time + cooldown forget_customer(current_customer) # Le seuil met fin à l'occupation de la table, pas à l'existence du client: # son Node2D reste vivant assez longtemps pour marcher sur le trottoir. func release_departing_customer_seat(current_customer: Customer) -> void: if current_customer.table_index < 0 or current_customer.table_index >= tables.size() \ or current_customer.seat_index < 0: return var occupants: Array = tables[current_customer.table_index].customers if occupants[current_customer.seat_index] == current_customer: occupants[current_customer.seat_index] = null _seat_from_queue() _persist_room() # 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(): if not is_instance_valid(current_customer): continue # Only dining customers are dismissed. Street passers keep following the # sidewalk, takeout customers keep their independent queue, and whoever # already has a meal finishes it before leaving on their own. if [Customer.State.QUEUED, Customer.State.ARRIVING, Customer.State.WAITING] \ .has(current_customer.state): # Ceux de la file sont déjà partis avec `_disperse_queue()`: seuls # restent ici les clients de la salle, qui n'auront pas leur repas. if current_customer.meals_eaten <= 0: adjust_popularity_for(current_customer, "left_closed") 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: # Changer de commande n'est pas un départ, mais ce n'est pas rien: # le client a demandé une chose et en reçoit une autre. adjust_popularity_for(current_customer, "order_changed") 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 # La porte se perce dans la face que la rue longe: un mur plein devant le # trottoir laisserait les clients tourner dehors sans jamais pouvoir entrer. if street_is_vertical(): var pierced_x := left_x if STREET_SIDE == "west" else right_x var solid_x := right_x if STREET_SIDE == "west" else left_x var gap_top := street_door() - DOOR_WIDTH * 0.5 var gap_bottom := street_door() + DOOR_WIDTH * 0.5 _add_wall(Vector2(pierced_x, (top_y + gap_top) * 0.5), Vector2(WALL_THICKNESS, maxf(0.0, gap_top - top_y)), "StreetWallTop") _add_wall(Vector2(pierced_x, (gap_bottom + bottom_y) * 0.5), Vector2(WALL_THICKNESS, maxf(0.0, bottom_y - gap_bottom)), "StreetWallBottom") _add_wall(Vector2((left_x + right_x) * 0.5, top_y), Vector2(right_x - left_x, WALL_THICKNESS), "NorthWall") _add_wall(Vector2((left_x + right_x) * 0.5, bottom_y), Vector2(right_x - left_x, WALL_THICKNESS), "SouthWall") _add_wall(Vector2(solid_x, (top_y + bottom_y) * 0.5), Vector2(WALL_THICKNESS, bottom_y - top_y), "BackWall") else: _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(left_x if STREET_SIDE == "west" else right_x, street_door()) if street_is_vertical() else Vector2(DOOR_CENTER_X, top_y) door.collision_layer = 2 door_collision = CollisionShape2D.new() var door_shape := RectangleShape2D.new() door_shape.size = Vector2(WALL_THICKNESS, DOOR_WIDTH - 18.0) if street_is_vertical() else 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: # La branche 3D donne un corps à cette entité: son dessin 2D se tait. # Une ligne, et aucune règle ne bouge — la 2D reste l'autorité. if Render3D.active: return 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. for walk: Rect2 in sidewalk_rects(): draw_rect(walk, Color("564a68")) draw_rect(walk, Color("7d6d93"), false, 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), "FERMÉ", HORIZONTAL_ALIGNMENT_CENTER, DOOR_WIDTH - 40, 14, Color("ff8f87")) 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), "OUVERT", HORIZONTAL_ALIGNMENT_CENTER, DOOR_WIDTH - 70, 12, Color("7dffb2"))