class_name Customer extends Node2D signal departed(customer: Customer) # Un client traverse d'abord le trottoir. Il ne devient un convive qu'après # avoir choisi de faire la file, puis avoir obtenu une table libre et propre. enum State { STROLLING, QUEUED, ARRIVING, WAITING, EATING, LEAVING } var restaurant: Node var profile_id := "regular" var display_name := "Customer" var body_color := Color("06d6a0") var speed := 180.0 var cash := 30 var appetite := 1 var favorite_meal := "fried_egg" var favorite_chance := 0.5 var generosity := 0.15 var meals_eaten := 0 var requested_recipe := "" var patience := 60.0 var max_patience := 60.0 var consumption_remaining := 0.0 var meal_consumption_speed := 1.0 var state := State.ARRIVING var table_index := -1 var seat_index := -1 var seat_position := Vector2.ZERO var entrance_position := Vector2.ZERO var crossed_door := false # Traits de décision, lus du profil. var hunger := 0.5 var queue_tolerance := 1.0 # Trajet de trottoir et place dans la file. var stroll_target := Vector2.ZERO var decision_x := 0.0 var decided := false var queue_position := Vector2.ZERO var queue_patience := 0.0 var max_queue_patience := 0.0 var body: StaticBody2D var body_collision: CollisionShape2D # Latches once the player has been close enough to read the current order. An # order already learned stays readable from anywhere until a new one is placed. var order_known := false # Seconds left before a read order fades back to unknown. Refreshed while the # player stays in range, counted down once they walk away. var order_memory_remaining := 0.0 var active: bool: get: return state == State.WAITING func _ready() -> void: add_to_group("interactable") add_to_group("dish_receiver") _build_collision() queue_redraw() # Le client se retire lui-même des listes du restaurant en quittant l'arbre, # quel que soit le chemin de libération. Placer ce filet ici plutôt que dans # l'enregistrement le rend impossible à contourner par un autre constructeur. func _exit_tree() -> void: if restaurant != null and is_instance_valid(restaurant) and restaurant.has_method("forget_customer"): restaurant.forget_customer(self) func _build_collision() -> void: # A seated customer physically blocks the player. The shape is disabled while # walking so a moving static body can never shove or trap the player. body = StaticBody2D.new() body.name = "CustomerBody" body.collision_layer = 2 body.collision_mask = 0 body_collision = CollisionShape2D.new() var shape := CircleShape2D.new() shape.radius = 20.0 body_collision.shape = shape body_collision.disabled = true body.add_child(body_collision) add_child(body) func _refresh_collision() -> void: if body_collision == null: return var seated := state == State.WAITING or state == State.EATING body_collision.set_deferred("disabled", not seated) func setup(new_profile_id: String, manager: Node, new_table_index: int, new_seat_index: int, target: Vector2, entrance: Vector2) -> void: restaurant = manager profile_id = new_profile_id var profile: Dictionary = DataCatalog.CUSTOMER_PROFILES.get(profile_id, {}) display_name = str(profile.get("display_name", "Customer")) var configured_color: Variant = profile.get("color", Color("06d6a0")) body_color = configured_color if configured_color is Color else Color(str(configured_color)) speed = float(profile.get("speed", 180.0)) cash = int(profile.get("cash", 30)) appetite = maxi(1, int(profile.get("appetite", 1))) favorite_meal = str(profile.get("favorite_meal", "fried_egg")) favorite_chance = clampf(float(profile.get("favorite_chance", 0.5)), 0.0, 1.0) var starting_patience := float(profile.get("patience", 60.0)) max_patience = float(GameState.resolve_game_variable("patience", starting_patience)) patience = max_patience meal_consumption_speed = maxf(0.05, float(profile.get("meal_consumption_speed", 1.0))) generosity = clampf(float(profile.get("generosity", 0.15)), 0.0, 1.0) hunger = clampf(float(profile.get("hunger", 0.5)), 0.0, 1.0) queue_tolerance = maxf(0.0, float(profile.get("queue_tolerance", 1.0))) table_index = new_table_index seat_index = new_seat_index seat_position = target entrance_position = entrance global_position = entrance_position state = State.ARRIVING crossed_door = false queue_redraw() # Entrée par le trottoir: le passant marche et décidera en arrivant devant la # porte. Il n'a aucune table réservée — il n'en obtiendra une qu'en sortant de # la file. func setup_passerby(new_profile_id: String, manager: Node, start: Vector2, finish: Vector2, door_x: float) -> void: setup(new_profile_id, manager, -1, -1, finish, start) stroll_target = finish decision_x = door_x decided = false state = State.STROLLING queue_redraw() # Le passant s'arrête: il prend la place que le restaurant lui indique. func join_queue(slot: Vector2, wait_seconds: float) -> void: queue_position = slot max_queue_patience = maxf(1.0, wait_seconds) queue_patience = max_queue_patience state = State.QUEUED queue_redraw() func move_in_queue(slot: Vector2) -> void: queue_position = slot # Sortie de file: la table est libre, le client entre pour de bon. func take_seat(new_table_index: int, new_seat_index: int, target: Vector2, entrance: Vector2) -> void: table_index = new_table_index seat_index = new_seat_index seat_position = target entrance_position = entrance crossed_door = false state = State.ARRIVING queue_redraw() # Un convive traverse le changement de scène. Seul l'état qui ne se recalcule # pas est stocké: le profil rend couleur, vitesse et générosité, la table rend # la place assise. func to_save_dictionary() -> Dictionary: return { "profile_id": profile_id, "state": int(state), "table_index": table_index, "seat_index": seat_index, "position_x": global_position.x, "position_y": global_position.y, "requested_recipe": requested_recipe, "patience": patience, "max_patience": max_patience, "consumption_remaining": consumption_remaining, "meals_eaten": meals_eaten, "cash": cash, "crossed_door": crossed_door, "order_known": order_known, "order_memory_remaining": order_memory_remaining, "queue_patience": queue_patience, "max_queue_patience": max_queue_patience, } func apply_save_dictionary(entry: Dictionary) -> void: state = int(entry.get("state", int(State.WAITING))) as State global_position = Vector2(float(entry.get("position_x", 0.0)), float(entry.get("position_y", 0.0))) requested_recipe = str(entry.get("requested_recipe", "")) max_patience = maxf(0.1, float(entry.get("max_patience", max_patience))) patience = clampf(float(entry.get("patience", max_patience)), 0.0, max_patience) consumption_remaining = maxf(0.0, float(entry.get("consumption_remaining", 0.0))) meals_eaten = maxi(0, int(entry.get("meals_eaten", 0))) cash = int(entry.get("cash", cash)) crossed_door = bool(entry.get("crossed_door", false)) order_known = bool(entry.get("order_known", false)) order_memory_remaining = maxf(0.0, float(entry.get("order_memory_remaining", 0.0))) max_queue_patience = maxf(1.0, float(entry.get("max_queue_patience", 1.0))) queue_patience = clampf(float(entry.get("queue_patience", max_queue_patience)), 0.0, max_queue_patience) queue_redraw() func _process(delta: float) -> void: match state: State.STROLLING: _move_toward(stroll_target, delta) # La décision se prend une seule fois, en passant devant la porte. if not decided and absf(global_position.x - decision_x) < 24.0: decided = true if restaurant != null and restaurant.has_method("passerby_reached_door"): restaurant.passerby_reached_door(self) elif global_position.distance_to(stroll_target) < 4.0: departed.emit(self) queue_free() State.QUEUED: _move_toward(queue_position, delta) queue_patience -= delta if queue_patience <= 0.0: GameState.toast_requested.emit(Loc.ui("ui.restaurant.queue_gave_up") % display_name) if restaurant != null and restaurant.has_method("customer_left_queue"): restaurant.customer_left_queue(self) begin_leaving() State.ARRIVING: if not crossed_door: var outside_door := entrance_position + Vector2(0, 15) _move_toward(outside_door, delta) if global_position.distance_to(outside_door) < 2.0: if restaurant != null and restaurant.can_customer_cross_door(): crossed_door = true else: begin_leaving() return _move_toward(seat_position, delta) if global_position.distance_to(seat_position) < 2.0: global_position = seat_position if restaurant != null: restaurant.customer_seated(self) State.WAITING: patience -= delta if patience <= 0.0: GameState.toast_requested.emit(Loc.ui("ui.restaurant.customer_left_hungry") % display_name) if restaurant != null and restaurant.has_method("customer_left_unserved"): restaurant.customer_left_unserved(self) begin_leaving() State.EATING: consumption_remaining -= delta if consumption_remaining <= 0.0 and restaurant != null: restaurant.customer_finished_meal(self) State.LEAVING: _move_toward(entrance_position if table_index >= 0 else stroll_target, delta) if global_position.distance_to(entrance_position if table_index >= 0 else stroll_target) < 3.0: departed.emit(self) queue_free() _update_order_knowledge(delta) _refresh_collision() queue_redraw() func _update_order_knowledge(delta: float = 0.0) -> void: if state != State.WAITING: return if _player_in_vision_range(): # In range the order is legible and its memory keeps being refreshed. order_known = true order_memory_remaining = get_order_memory_seconds() return if not order_known: return order_memory_remaining -= delta if order_memory_remaining <= 0.0: order_known = false func _player_in_vision_range() -> bool: var current_player := get_tree().get_first_node_in_group("player") as Node2D if current_player == null or not is_instance_valid(current_player): return false return global_position.distance_to(current_player.global_position) <= get_player_vision_range() func get_order_memory_seconds() -> float: return maxf(0.0, float(GameState.resolve_game_variable("order_memory_seconds"))) func _move_toward(target: Vector2, delta: float) -> void: global_position = global_position.move_toward(target, speed * delta) func assign_order(recipe_id: String) -> void: requested_recipe = recipe_id patience = max_patience # A new order has to be read again before it is known. order_known = false order_memory_remaining = 0.0 state = State.WAITING GameState.toast_requested.emit(Loc.ui("ui.restaurant.customer_ordered") % [display_name, DataCatalog.recipe_name(recipe_id)]) func force_waiting(recipe_id: String) -> void: global_position = seat_position if seat_position != Vector2.ZERO else global_position assign_order(recipe_id) func begin_eating(duration: float) -> void: state = State.EATING consumption_remaining = maxf(0.1, duration / meal_consumption_speed) queue_redraw() func begin_leaving() -> void: if state == State.LEAVING: return state = State.LEAVING requested_recipe = "" queue_redraw() func interact(player: Player) -> void: if state != State.WAITING: return var carried_dish := player.held_dish as Dish if carried_dish == null or carried_dish.state != Dish.State.FILLED: GameState.toast_requested.emit(Loc.ui("ui.plate.bring_order") % [display_name, DataCatalog.recipe_name(requested_recipe)]) return if carried_dish.recipe_id != requested_recipe: GameState.toast_requested.emit(Loc.ui("ui.plate.serve_wrong") % [display_name, DataCatalog.recipe_name(requested_recipe)]) return if restaurant != null: restaurant.serve_customer(self, carried_dish) func get_prompt() -> String: match state: State.ARRIVING: return Loc.ui("ui.customer.arriving") % display_name State.EATING: return Loc.ui("ui.customer.eating") % display_name State.LEAVING: return Loc.ui("ui.customer.leaving") % display_name var player := get_tree().get_first_node_in_group("player") as Player if player != null and player.held_dish is Dish: var dish := player.held_dish as Dish if dish.state == Dish.State.FILLED and dish.recipe_id == requested_recipe: return Loc.ui("ui.customer.serve") % DataCatalog.recipe_name(requested_recipe) if not player_can_read_order(): return Loc.ui("ui.customer.step_closer") % display_name return Loc.ui("ui.customer.order") % DataCatalog.recipe_name(requested_recipe) func _draw() -> void: var alpha := 1.0 if state != State.LEAVING else 0.72 var color := body_color color.a = alpha draw_circle(Vector2.ZERO, 20.0, color) draw_circle(Vector2(-6, -4), 3.0, Color(0.075, 0.165, 0.18, alpha)) draw_circle(Vector2(6, -4), 3.0, Color(0.075, 0.165, 0.18, alpha)) if state == State.WAITING: if player_can_read_order(): draw_rect(Rect2(-60, -54, 120, 25), Color(0.08, 0.05, 0.13, 0.9)) draw_string(ThemeDB.fallback_font, Vector2(-55, -36), DataCatalog.recipe_name(requested_recipe), HORIZONTAL_ALIGNMENT_CENTER, 110, 12, Color.WHITE) else: # Out of reading range the order stays unknown; only the fact that this # customer wants something is visible. draw_circle(Vector2(0, -42), 7.0, Color(0.08, 0.05, 0.13, 0.9)) draw_string(ThemeDB.fallback_font, Vector2(-7, -37), "?", HORIZONTAL_ALIGNMENT_CENTER, 14, 13, Color.WHITE) draw_rect(Rect2(-45, 29, 90, 5), Color("392f45")) draw_rect(Rect2(-45, 29, 90 * clampf(patience / maxf(max_patience, 0.1), 0.0, 1.0), 5), body_color) elif state == State.EATING: draw_string(ThemeDB.fallback_font, Vector2(-34, -36), "eating…", HORIZONTAL_ALIGNMENT_CENTER, 68, 12, Color.WHITE) func player_can_read_order() -> bool: return order_known func get_player_vision_range() -> float: return maxf(0.0, float(GameState.resolve_game_variable("player_vision")))