class_name Customer extends CharacterBody2D const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/core/speech_bubble.gd") const CROWD_COLLISION_LAYER := 8 const BODY_RADIUS := 20.0 const CROWD_LOOK_AHEAD := 86.0 const CROWD_DETOUR_DISTANCE := 52.0 const REORDER_DELAY_SECONDS := 1.35 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. # New states are appended so the numeric values stored by older saves remain # unchanged for every pre-existing customer state. enum State { STROLLING, QUEUED, ARRIVING, WAITING, EATING, LEAVING, TAKEOUT_QUEUED, BETWEEN_ORDERS } # La visite se lit sur deux axes. `State` dit ou se trouve le corps — dehors, en # file, assis, parti — et reste la seule autorite du deplacement, de la collision # et des sauvegardes. `ServicePhase` dit ce que le client attend, et c'est elle # qui porte la patience, la note de l'etape et le symbole affiche. Un meme # `WAITING` couvre trois attentes qui n'ont ni la meme duree ni le meme sens. enum ServicePhase { NONE, THINKING, ORDERING, AWAITING_MEAL, EATING, AWAITING_CLEANUP } # La note d'une attente descend par crans plutot que continument: le premier # cran laisse la note pleine, ce qui donne quelques secondes de grace avant la # moindre penalite. Dix crans, donc un dixieme de la patience par cran. const EXPERIENCE_TIERS := 10 # Les fractions de patience par etape, en dernier recours. # # Une donnee absente ne doit jamais tuer un client. Le gestionnaire de donnees # reecrit le maitre en entier, et une statistique qu'il ne connait pas disparait # sans bruit; sans ce plancher, un coefficient tombe a zero ramenait l'attente a # un dixieme de seconde et le convive repartait aussitot assis. const FALLBACK_COEF_ORDER := 0.6 const FALLBACK_COEF_SERVICE := 1.0 const FALLBACK_COEF_CLEANUP := 0.4 # La note qui ne marque personne. Une etape rendue exactement a ce niveau ne # laisse aucun souvenir: elle ne pese donc rien dans le jugement de la visite. const NEUTRAL_SCORE := 0.5 # Ce que vaut, dans le souvenir d'un client, une envie de continuer que la # cuisine n'a pas pu satisfaire. Nettement sous l'ordinaire, mais loin du zero # d'une attente depassee: on ne lui a pas fait faux bond, on n'a plus rien eu a # lui vendre. const DISAPPOINTED_SCORE := 0.3 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 # Le gout du client vit sur les categories du plat, pas sur un plat nomme. # 1 est le poids neutre: une categorie absente de la table vaut donc 1. var category_preferences: Dictionary = {} var spending := 0.5 # Ce que ce client vaut pour la reputation. Chaque situation multiplie ce poids # par son propre coefficient; le client ne decide de rien d'autre. var popularity_weight := 5.0 var generosity := 0.15 var meals_eaten := 0 var requested_recipe := "" # `patience` est le compte a rebours de la phase COURANTE, pas de la visite: # chaque attente repart a plein. `max_patience` reste la valeur de base du # client, dont chaque phase prend sa fraction; `phase_allowed` est ce que la # phase en cours a reellement accorde, et donc le denominateur de sa barre. var patience := 60.0 var max_patience := 60.0 var service_phase := ServicePhase.NONE var phase_allowed := 0.0 var phase_elapsed := 0.0 # Chaque attente vecue laisse sa note et sa duree. La note globale est leur # moyenne ponderee par le temps: une longue attente pese plus qu'une breve, et # une etape jamais vecue — pas de file dehors — pese zero et disparait seule. var experience_steps: Array[Dictionary] = [] # Une attente depassee fait partir le client et annule le pourboire, quoi qu'il # ait vecu avant. Le paiement des plats, lui, reste du sur l'assiette sale. var tip_forfeited := false # Ce que ce client a deja mange, en argent. C'est l'assiette du pourboire final. var meals_value := 0 # Un jet impose au prochain choix de reprendre un plat. Negatif signifie un vrai # tirage, et le jet impose se consomme des qu'il a servi. var forced_reorder_roll := -1.0 # Le pourboire d'une visite ne se regle qu'une fois. Sans ce drapeau, un client # encaisse par une porte puis par une autre, ou pas du tout. var tip_settled := false # Actual waiting time for this meal; independent of patience bonuses and reorders. var order_wait_elapsed := 0.0 var consumption_remaining := 0.0 var reorder_delay_remaining := -1.0 ## Combien de temps ce client met a finir son plat, EN SECONDES. ## ## C'etait un ratio qui divisait une constante de huit secondes cachee dans le ## restaurant: « 3,2 » ne disait rien a personne, et il fallait connaitre le ## huit pour traduire. La duree se lit maintenant telle quelle. var meal_seconds := 8.0 ## Et le temps qu'il met a choisir. Chaque profil a le sien: un gourmet flane ## devant la carte, un pressé sait deja ce qu'il veut. var thinking_seconds := 3.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 stroll_waypoints: Array[Vector2] = [] var stroll_waypoint_index := 0 var decision_x := 0.0 var decided := false var takeout_decision_x := -1.0 var door_decision_position := Vector2.ZERO var takeout_decision_position := Vector2.ZERO var uses_spatial_decisions := false # L'axe sur lequel se lisent les deux points de décision. La rue peut longer # n'importe quelle face de la salle: sans cet axe, le passant ne franchissait # jamais sa porte dès qu'elle cessait d'être horizontale. var street_axis := Vector2.RIGHT var takeout_decided := false var queue_position := Vector2.ZERO var queue_patience := 0.0 var max_queue_patience := 0.0 var queue_kind := "" var body: StaticBody2D var body_collision: CollisionShape2D var crowd_collision: CollisionShape2D var visuals: VisualHolder var speech_bubble: Node2D var _visual_state := "" var target_glowing := false var facing := Vector2.DOWN const TARGET_GLOW_COLOR := Color(1.0, 0.94, 0.84, 0.58) # Le pas réellement parcouru sur la dernière frame. Le client se déplace par # `move_toward` sans vélocité stockée: c'est la seule mesure honnête de sa marche. var _last_step := Vector2.ZERO # Mémoire de commande, le système d'avant l'ardoise. Il reste en service tant # qu'aucune upgrade n'a ouvert de ligne: une commande lue s'oublie en # s'éloignant, et doit être relue. var order_known := false 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") add_to_group("customer_crowd") _build_collision() _build_visual_holder() queue_redraw() func _build_visual_holder() -> void: if visuals == null: visuals = VisualHolder.new() visuals.name = "Visuals" add_child(visuals) visuals.bind("customer_profiles", profile_id) _visual_state = "" _refresh_visual_state() # L'état se déduit de ce que le client fait déjà. `angry` est le départ sans # avoir été servi: repartir rassasié n'a rien de fâché. func _current_visual_state() -> String: if state == State.EATING: return "eat" if state == State.LEAVING and requested_recipe != "": return "angry" if _last_step.length_squared() > 0.01: return "walk" return "idle" func _refresh_visual_state() -> void: if visuals == null or not is_instance_valid(visuals): return var wanted := _current_visual_state() if wanted != _visual_state: _visual_state = wanted visuals.show_state(wanted) # Eating always faces south: with the default stool north of the table this # turns the customer toward both their meal and the room. Other states retain # the last real travel direction. if state == State.EATING: facing = Vector2.DOWN elif _last_step.length_squared() > 0.01: facing = _last_step.normalized() visuals.face(facing) func _refresh_target_glow() -> void: var current_player := get_tree().get_first_node_in_group("player") as Player var glow_now := current_player != null and is_instance_valid(current_player) \ and current_player.current_target == self \ and current_player.target_is_actionable(self) if glow_now == target_glowing: return target_glowing = glow_now if visuals != null and is_instance_valid(visuals): visuals.set_target_glow(target_glowing, TARGET_GLOW_COLOR) queue_redraw() # La silhouette procédurale ne se dessine que faute d'image. La bulle de commande # et la jauge de patience restent dans tous les cas: ce sont des informations, # pas de la décoration. func should_draw_body() -> bool: return visuals == null or not visuals.is_showing() # 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: # Chaque client occupe réellement sa place sur la couche de foule. Le joueur # ne lit pas cette couche: son blocage historique par les convives assis reste # porté par `CustomerBody`, sur la couche du monde. collision_layer = CROWD_COLLISION_LAYER collision_mask = 0 motion_mode = CharacterBody2D.MOTION_MODE_FLOATING crowd_collision = CollisionShape2D.new() crowd_collision.name = "CrowdCollision" var crowd_shape := CircleShape2D.new() crowd_shape.radius = BODY_RADIUS crowd_collision.shape = crowd_shape add_child(crowd_collision) # 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 = BODY_RADIUS 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 or state == State.BETWEEN_ORDERS body_collision.set_deferred("disabled", not seated) # Seuls les passants se déplacent avec résolution de foule. Les clients en # file restent des obstacles stables; leur propre marche vers une place ne # doit pas pousser toute la file. collision_mask = CROWD_COLLISION_LAYER if state == State.STROLLING else 0 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)) # La valeur du profil sert de base a la statistique: un upgrade empile # par-dessus sans que la fiche du client change, comme pour `patience`. cash = int(GameState.resolve_game_variable("customer_cash", float(profile.get("cash", 30)))) appetite = maxi(1, int(round(float(GameState.resolve_game_variable( "customer_appetite", float(profile.get("appetite", 1))))))) spending = clampf(float(GameState.resolve_game_variable( "customer_spending", float(profile.get("spending", 0.5)))), 0.0, 1.0) popularity_weight = maxf(0.0, float(GameState.resolve_game_variable( "customer_popularity_weight", float(profile.get("popularity_weight", 5.0))))) var raw_preferences: Variant = profile.get("category_preferences", {}) category_preferences = (raw_preferences as Dictionary).duplicate() if raw_preferences is Dictionary else {} var starting_patience := float(profile.get("patience", 60.0)) max_patience = float(GameState.resolve_game_variable("patience", starting_patience)) patience = max_patience meal_seconds = maxf(0.1, float(GameState.resolve_game_variable( "meal_seconds", float(profile.get("meal_seconds", 8.0))))) thinking_seconds = maxf(0.0, float(GameState.resolve_game_variable( "customer_thinking_seconds", float(profile.get("thinking_seconds", 3.0))))) generosity = clampf(float(GameState.resolve_game_variable( "customer_generosity", float(profile.get("generosity", 0.15)))), 0.0, 1.0) # Une visite neuve n'a rien vécu: ni note, ni repas, ni rancune. experience_steps.clear() meals_value = 0 tip_forfeited = false tip_settled = false service_phase = ServicePhase.NONE phase_allowed = 0.0 phase_elapsed = 0.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 # Le profil arrive apres _ready: le porteur doit se relier au bon visuel. _build_visual_holder() _build_speech_bubble(profile.get("speech_bubbles", {})) say("arrive") queue_redraw() func recipe_taste(recipe_id: String) -> float: var recipe: Dictionary = DataCatalog.RECIPES.get(recipe_id, {}) var categories: Variant = recipe.get("categories", []) if not categories is Array: return 1.0 var taste := 1.0 for raw_category: Variant in categories: taste *= maxf(0.0, float(category_preferences.get(str(raw_category), 1.0))) return taste # --- Les attentes du service et ce qu'elles valent ----------------------------- # La patience accordee a une attente. Une seule statistique porte la valeur de # base; chaque etape en prend sa fraction, reglable dans les donnees sans # multiplier les leviers d'amelioration. Zero signifie une phase qui n'attend # personne: la reflexion et le repas ne peuvent pas expirer. func phase_patience(phase: int) -> float: var coefficient := "" var fallback := 0.0 match phase: ServicePhase.ORDERING: coefficient = "patience_coef_order" fallback = FALLBACK_COEF_ORDER ServicePhase.AWAITING_MEAL: coefficient = "patience_coef_service" fallback = FALLBACK_COEF_SERVICE ServicePhase.AWAITING_CLEANUP: coefficient = "patience_coef_cleanup" fallback = FALLBACK_COEF_CLEANUP _: return 0.0 var resolved := float(GameState.resolve_game_variable(coefficient, fallback)) # Une fraction nulle ne decrit aucune attente jouable: c'est le signe d'une # donnee perdue, pas d'un reglage. On retombe sur la valeur d'auteur. if resolved <= 0.0: resolved = fallback return maxf(0.1, max_patience * resolved) func phase_is_wait() -> bool: return phase_patience(service_phase) > 0.0 # Ce que la barre montre. Negatif signifie aucune barre. func phase_ratio() -> float: if not phase_is_wait() or phase_allowed <= 0.0: return -1.0 return clampf(patience / phase_allowed, 0.0, 1.0) # Entrer dans une attente remet son compteur a plein: chaque etape se juge sur # elle-meme, jamais sur ce qui restait de la precedente. `patience` sert aussi # de compte a rebours a la reflexion, qui n'a pas de barre et ne penalise rien. func begin_phase(phase: int) -> void: service_phase = phase as ServicePhase phase_elapsed = 0.0 phase_allowed = phase_patience(phase) if phase_allowed > 0.0: patience = phase_allowed elif phase == ServicePhase.THINKING: patience = thinking_seconds else: patience = 0.0 queue_redraw() # La note d'une attente: pleine tant qu'on reste dans le premier cran, puis un # dixieme de moins par cran entame. Les crans donnent la marge de grace — servir # a la seconde pres ne rapporte pas plus que servir vite. static func wait_score(elapsed: float, allowed: float) -> float: if allowed <= 0.0: return 1.0 var ratio := clampf(elapsed / allowed, 0.0, 1.0) var tier := mini(EXPERIENCE_TIERS, floori(ratio * float(EXPERIENCE_TIERS))) return clampf(1.0 - float(tier) / float(EXPERIENCE_TIERS), 0.0, 1.0) # Le charisme neutre vaut 1. La statistique dit quelle part de la note en depend: # a 0 le serveur ne change rien, a 0.5 un charisme de 1.8 vaut 1.4 fois la note. # C'est ainsi que deux serveurs inegaux laissent deux notes differentes sur la # meme attente. func charisma_factor(charisma: float) -> float: var weight := maxf(0.0, float(GameState.resolve_game_variable("service_charisma_weight"))) return maxf(0.0, 1.0 - weight + weight * maxf(0.0, charisma)) # Fermer l'attente en cours et l'inscrire au dossier du client. `charisma` vaut 1 # quand personne d'identifiable ne l'a rendue: le joueur compte pour neutre. func record_wait(charisma: float = 1.0) -> void: if not phase_is_wait(): return experience_steps.append({ "phase": int(service_phase), "score": clampf(wait_score(phase_elapsed, phase_allowed) * charisma_factor(charisma), 0.0, 1.0), "duration": maxf(0.0, phase_elapsed), }) # La file dehors compte comme les autres attentes, mais elle se joue avant que le # client soit un convive: elle n'a pas de phase et s'inscrit a l'assise. # # Entrer sans faire la queue est une etape vecue, notee au mieux — pas une etape # absente. C'est un bon moment, et il doit compter comme tel: sans lui, la salle # la plus fluide n'aurait rien a montrer au moment du pourboire. func record_queue_wait() -> void: var allowed := maxf(0.0, max_queue_patience) var waited := clampf(allowed - queue_patience, 0.0, allowed) if allowed > 0.0 else 0.0 experience_steps.append({ "phase": int(ServicePhase.NONE), "score": wait_score(waited, allowed), "duration": waited, }) # La note globale de la visite. # # Ce qui marque un client, c'est l'ecart a l'ordinaire: un service fulgurant et # une attente interminable se racontent, une attente moyenne s'oublie. Le poids # d'une etape est donc sa SAILLANCE, pas sa duree. # # Ponderer par le temps, comme avant, avait deux defauts qui se renforcaient: # une longue attente etait punie deux fois — par sa note et par son poids — et # surtout une etape parfaite mais instantanee pesait presque zero, si bien que # l'excellence restait invisible dans la moyenne. # # `experience_salience` dose le melange: a 0 toutes les etapes comptent pareil, # a 1 seules les remarquables decident. Une attente depassee a fait partir le # client et vaut zero net, sans egard pour ce qui s'etait bien passe avant. func experience_score() -> float: if tip_forfeited: return 0.0 if experience_steps.is_empty(): return 1.0 var plain := 0.0 var salient := 0.0 var salience_total := 0.0 for step: Dictionary in experience_steps: var score := clampf(float(step.get("score", 1.0)), 0.0, 1.0) plain += score var salience := absf(score - NEUTRAL_SCORE) * 2.0 salient += score * salience salience_total += salience plain /= float(experience_steps.size()) # Une visite sans rien de remarquable vaut sa moyenne, tout simplement. if salience_total <= 0.0: return clampf(plain, 0.0, 1.0) var mix := clampf(float(GameState.resolve_game_variable("experience_salience")), 0.0, 1.0) return clampf(lerpf(plain, salient / salience_total, mix), 0.0, 1.0) # Une deception qui n'est pas une attente. # # Le client voulait reprendre un plat et la cuisine ne pouvait plus rien lui # servir. Cela compte dans son souvenir — donc dans son pourboire — sans # l'annuler comme le ferait une attente depassee. Sa duree est nulle: ce n'est # pas du temps subi, c'est un moment qui marque. func record_disappointment() -> void: if meals_value <= 0: return experience_steps.append({ "phase": int(ServicePhase.NONE), "score": DISAPPOINTED_SCORE, "duration": 0.0, }) # Le pourboire de fin de visite. Il se calcule sur ce que le client a mange, se # module par sa generosite et par l'experience qu'il emporte, puis bute sur ce # qui lui reste en poche. Une attente depassee l'a deja ramene a zero. func final_tip() -> int: if tip_settled or tip_forfeited or meals_value <= 0: return 0 return clampi(roundi(float(meals_value) * generosity * experience_score()), 0, maxi(0, cash)) # Solder la visite. Le pourboire se calcule une fois et une seule, quelle que # soit la porte par laquelle le client sort, et se retient sur ce qui lui # restait en poche. func settle_tip() -> int: var tip := final_tip() tip_settled = true cash = maxi(0, cash - tip) return tip # Jusqu'ou ce client commande. `appetite` reste le plafond du profil, atteint une # fois sur deux quand le service est ordinaire; le reste des visites se repartit # lineairement sur les comptes inferieurs. # # La cible ne se tire pas a l'avance — l'experience n'existerait pas encore — # mais la probabilite de poursuivre apres chaque plat, qui redonne exactement # cette distribution quand rien ne la module. func reorder_probability() -> float: if meals_eaten >= appetite: return 0.0 var reachable := 0.0 var beyond := 0.0 for count: int in range(maxi(1, meals_eaten), appetite + 1): var chance := appetite_chance(count) reachable += chance if count > meals_eaten: beyond += chance if reachable <= 0.0: return 0.0 var swing := clampf(float(GameState.resolve_game_variable("appetite_experience_swing")), 0.0, 1.0) return clampf(beyond / reachable * (1.0 - swing + 2.0 * swing * experience_score()), 0.0, 1.0) # Ce client reprend-il un plat? Le tirage se compare a la probabilite que lui # laissent sa distribution d'appetit et l'experience deja vecue. Un jet impose # sert les controles qui doivent voir un flux et non un hasard; il se consomme, # comme les `forced_rolls` d'une bulle de dialogue. func wants_another_meal() -> bool: var roll := forced_reorder_roll if forced_reorder_roll >= 0.0 else randf() forced_reorder_roll = -1.0 return roll < reorder_probability() # La part des visites qui s'arretent exactement a `count` plats. func appetite_chance(count: int) -> float: if count >= appetite: return 1.0 if appetite <= 1 else 0.5 return float(count) / float((appetite - 1) * appetite) # La commande est prise: l'attente d'un serveur se ferme et se note, celle du # plat s'ouvre à plein. `charisma` est celui de qui l'a prise — le joueur compte # pour neutre. Reprendre une commande déjà prise ne fait rien. func take_order_by(charisma: float = 1.0) -> void: if service_phase != ServicePhase.ORDERING: return record_wait(charisma) begin_phase(ServicePhase.AWAITING_MEAL) # Une attente dépassée: le client s'en va et emporte le pourboire avec lui, # quoi qu'il ait vécu de bon avant. Ce qu'il a déjà mangé reste dû: le paiement # dort sur l'assiette sale jusqu'à ce qu'on la ramasse. func abandon_wait() -> void: tip_forfeited = true var was_fed := service_phase == ServicePhase.AWAITING_CLEANUP var message := "ui.restaurant.customer_left_dirty" if was_fed else "ui.restaurant.customer_left_hungry" GameState.toast_requested.emit(Loc.ui(message) % display_name) if restaurant != null: if was_fed and restaurant.has_method("customer_left_dirty_table"): restaurant.customer_left_dirty_table(self) elif not was_fed and restaurant.has_method("customer_left_unserved"): restaurant.customer_left_unserved(self) begin_leaving() func _build_speech_bubble(config: Variant) -> void: if speech_bubble != null and is_instance_valid(speech_bubble): speech_bubble.queue_free() speech_bubble = SPEECH_BUBBLE_SCRIPT.new() speech_bubble.name = "SpeechBubble" add_child(speech_bubble) var safe_config: Dictionary = config if config is Dictionary else {} speech_bubble.call("bind_to_actor", self, safe_config, Vector2(0.0, -32.0)) if Render3D.active and speech_bubble.has_method("set_external_visual"): speech_bubble.call("set_external_visual", true) func say(trigger_id: String) -> bool: return speech_bubble != null and is_instance_valid(speech_bubble) \ and bool(speech_bubble.call("trigger_event", trigger_id)) # Le pont 3D reprend le message courant dans le même panneau que la commande et # la patience. La bulle garde ses probabilités et son cooldown: elle ne dessine # simplement plus un second panneau par-dessus le premier. func visible_speech_text() -> String: if speech_bubble == null or not is_instance_valid(speech_bubble) \ or not speech_bubble.has_method("visible_message"): return "" return str(speech_bubble.call("visible_message")) # Ce que l'affichage doit montrer: la replique telle qu'elle est en train d'etre # dite. `visible_speech_text` reste la replique entiere, pour qui la verifie. func spoken_speech_text() -> String: if speech_bubble == null or not is_instance_valid(speech_bubble) \ or not speech_bubble.has_method("spoken_message"): return "" return str(speech_bubble.call("spoken_message")) # 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, counter_x: float = -1.0, axis: Vector2 = Vector2.RIGHT) -> void: street_axis = axis.normalized() if axis.length_squared() > 0.001 else Vector2.RIGHT setup(new_profile_id, manager, -1, -1, finish, start) stroll_target = finish stroll_waypoints.clear() stroll_waypoints.append(start) stroll_waypoints.append(finish) stroll_waypoint_index = 1 decision_x = door_x decided = false takeout_decision_x = counter_x takeout_decided = false uses_spatial_decisions = false state = State.STROLLING _refresh_collision() queue_redraw() # Live passers follow an explicit polyline. Decision points are full positions, # because takeout belongs to the north leg while dining belongs to the east leg. func setup_passerby_path(new_profile_id: String, manager: Node, route: Array[Vector2], door_position: Vector2, counter_position: Vector2) -> void: if route.size() < 2: push_error("A passerby route needs at least two points") return setup(new_profile_id, manager, -1, -1, route[-1], route[0]) stroll_waypoints = route.duplicate() stroll_waypoint_index = 1 stroll_target = route[-1] door_decision_position = door_position takeout_decision_position = counter_position decided = false takeout_decided = false uses_spatial_decisions = true state = State.STROLLING _refresh_collision() 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 queue_kind = "dining" state = State.QUEUED _refresh_collision() queue_redraw() func join_takeout_queue(slot: Vector2, wait_seconds: float) -> void: queue_position = slot max_queue_patience = maxf(1.0, wait_seconds) queue_patience = max_queue_patience queue_kind = "takeout" state = State.TAKEOUT_QUEUED _refresh_collision() 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 _refresh_collision() 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, "order_wait_elapsed": order_wait_elapsed, "consumption_remaining": consumption_remaining, "reorder_delay_remaining": reorder_delay_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, "queue_kind": queue_kind, "service_phase": int(service_phase), "phase_allowed": phase_allowed, "phase_elapsed": phase_elapsed, "experience_steps": experience_steps.duplicate(true), "tip_forfeited": tip_forfeited, "tip_settled": tip_settled, "meals_value": meals_value, } 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) var legacy_wait := maxf(0.0, max_patience - patience) if state == State.WAITING else 0.0 order_wait_elapsed = maxf(0.0, float(entry.get("order_wait_elapsed", legacy_wait))) consumption_remaining = maxf(0.0, float(entry.get("consumption_remaining", 0.0))) reorder_delay_remaining = float(entry.get("reorder_delay_remaining", -1.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_kind = str(entry.get("queue_kind", "dining" if state == State.QUEUED else "")) meals_value = maxi(0, int(entry.get("meals_value", 0))) tip_forfeited = bool(entry.get("tip_forfeited", false)) tip_settled = bool(entry.get("tip_settled", false)) # Une sauvegarde écrite avant les phases ne connaît que l'état du corps. On # la relit dans la phase que ce corps occupait alors: un convive assis # attendait son plat, un convive repu attendait qu'on débarrasse. var legacy_phase := int(ServicePhase.NONE) match state: State.WAITING: legacy_phase = int(ServicePhase.AWAITING_MEAL) State.EATING: legacy_phase = int(ServicePhase.EATING) State.BETWEEN_ORDERS: legacy_phase = int(ServicePhase.AWAITING_CLEANUP) service_phase = int(entry.get("service_phase", legacy_phase)) as ServicePhase phase_elapsed = maxf(0.0, float(entry.get("phase_elapsed", order_wait_elapsed))) phase_allowed = maxf(0.0, float(entry.get("phase_allowed", phase_patience(service_phase)))) if phase_allowed > 0.0: patience = clampf(patience, 0.0, phase_allowed) experience_steps.clear() var stored: Variant = entry.get("experience_steps", []) if stored is Array: for raw: Variant in stored as Array: if raw is Dictionary: experience_steps.append((raw as Dictionary).duplicate(true)) _refresh_collision() queue_redraw() func _process(delta: float) -> void: match state: State.STROLLING: var waypoint := _current_stroll_waypoint() _move_toward(waypoint, delta) # Takeout and dining are two physical decisions. The first callback may # change state, in which case the passerby must not also enter the store. var reached_takeout := _crossed_point(takeout_decision_position) \ if uses_spatial_decisions else \ (takeout_decision_x >= 0.0 and _crossed_x(takeout_decision_x)) if not takeout_decided and reached_takeout: takeout_decided = true if restaurant != null and restaurant.has_method("passerby_reached_takeout"): restaurant.passerby_reached_takeout(self) var reached_door := _crossed_point(door_decision_position) \ if uses_spatial_decisions else _crossed_x(decision_x) if state == State.STROLLING and not decided and reached_door: decided = true if restaurant != null and restaurant.has_method("passerby_reached_door"): restaurant.passerby_reached_door(self) elif state == State.STROLLING and global_position.distance_to(waypoint) < 4.0: if stroll_waypoint_index < stroll_waypoints.size() - 1: stroll_waypoint_index += 1 else: departed.emit(self) queue_free() State.QUEUED, State.TAKEOUT_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: # `entrance_position` est déjà le seuil extérieur, quelle que soit la # façade. Un décalage en Y supposait une porte au nord et faisait # longer le mur aux clients depuis que la porte est à l'est. _move_toward(entrance_position, delta) if global_position.distance_to(entrance_position) < 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: order_wait_elapsed += maxf(0.0, delta) phase_elapsed += maxf(0.0, delta) patience -= delta if patience <= 0.0: if service_phase == ServicePhase.THINKING: # Le choix est arrete: la commande existe enfin, et l'attente # d'un serveur commence. Rien n'a ete penalise jusqu'ici. if restaurant != null and restaurant.has_method("customer_finished_thinking"): restaurant.customer_finished_thinking(self) else: abandon_wait() State.EATING: consumption_remaining -= delta if consumption_remaining <= 0.0 and restaurant != null: restaurant.customer_finished_meal(self) State.BETWEEN_ORDERS: # Une valeur négative signifie que l'assiette sale est encore sur la # table. Tant qu'elle y reste, le client attend qu'on débarrasse, et # cette attente compte comme les autres. Son ramassage ferme la phase # et lance seulement alors le compte à rebours de la commande suivante. if reorder_delay_remaining < 0.0: phase_elapsed += maxf(0.0, delta) patience -= delta if patience <= 0.0: abandon_wait() else: reorder_delay_remaining = maxf(0.0, reorder_delay_remaining - delta) if reorder_delay_remaining <= 0.0 and restaurant != null \ and restaurant.has_method("customer_reorder_delay_finished"): restaurant.customer_reorder_delay_finished(self) State.LEAVING: _move_toward(entrance_position, delta) if global_position.distance_to(entrance_position) < 3.0: _resume_sidewalk_departure() _update_order_knowledge(delta) _refresh_collision() _refresh_visual_state() _refresh_target_glow() _last_step = Vector2.ZERO queue_redraw() # Deux régimes. Sans ardoise, une commande lue s'oublie en s'éloignant — c'est # le jeu de départ. Avec, la repérer l'inscrit une fois pour toutes. # # L'ardoise pleine retombe volontairement sur la mémoire: un tableau saturé ne # doit jamais rendre une commande **moins** lisible qu'avant qu'on l'achète. func _update_order_knowledge(delta: float = 0.0) -> void: # Un client qui n'a pas fini de choisir n'a rien à faire lire: sa commande # n'existe pas encore, et personne ne peut donc la prendre. if state != State.WAITING or service_phase == ServicePhase.THINKING: return # Une commande devenue lisible est une commande prise, par quelque chemin # qu'elle le soit devenue: c'est elle qui ferme l'attente d'un serveur. Un # employé, lui, a déjà noté la sienne à son propre charisme, et la garde de # phase empêche que la prise soit comptée deux fois. if service_phase == ServicePhase.ORDERING and player_can_read_order(): take_order_by() var in_range := _player_in_vision_range() if table_index >= 0 and GameState.order_board_active() and (in_range or order_known): # Ce que le joueur savait déjà bascule sur l'ardoise au déverrouillage, # au lieu d'être perdu à la transition. if GameState.write_order_line(table_index, requested_recipe): return if in_range: 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 get_order_memory_seconds() -> float: return maxf(0.0, float(GameState.resolve_game_variable("order_memory_seconds"))) 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 _move_toward(target: Vector2, delta: float) -> void: var before := global_position var offset := target - global_position if offset.length_squared() <= 0.0001 or delta <= 0.0: velocity = Vector2.ZERO _last_step = Vector2.ZERO return var heading := offset.normalized() if state == State.STROLLING: heading = _crowd_avoidance_heading(heading) var travel_speed := minf(speed, offset.length() / delta) velocity = heading * travel_speed # Customer decisions run in `_process`, and smoke tests deliberately advance # one customer with a larger explicit delta. `move_and_slide()` silently uses # the engine physics delta instead, so a 0.2 s step moved only 1/60 s and could # miss both sidewalk decision points. Apply the requested motion explicitly, # then slide the remainder along a crowd body when contact still occurs. var collision := move_and_collide(velocity * delta) if collision != null: var slide := collision.get_remainder().slide(collision.get_normal()) if slide.length_squared() > 0.0001: move_and_collide(slide) velocity = Vector2.ZERO _last_step = global_position - before # Les corps empêchent le chevauchement; ce cap latéral leur donne un chemin au # lieu de laisser un passant pousser tout droit contre le centre d'un client en # file. On ne détourne que devant un obstacle proche, puis la cible de promenade # ramène naturellement le passant dans sa voie. func _crowd_avoidance_heading(desired: Vector2) -> Vector2: var blocker: Customer var nearest := INF for node: Node in get_tree().get_nodes_in_group("customer_crowd"): if node == self or not node is Customer: continue var other := node as Customer if not [State.QUEUED, State.TAKEOUT_QUEUED].has(other.state): continue var separation := other.global_position - global_position var ahead := separation.dot(desired) var lateral := absf(separation.cross(desired)) if ahead > 0.0 and ahead < CROWD_LOOK_AHEAD \ and lateral < CROWD_DETOUR_DISTANCE and ahead < nearest: blocker = other nearest = ahead if blocker == null: return desired var side := Vector2(-desired.y, desired.x) if restaurant != null and (restaurant.has_method("sidewalk_outward_at") \ or restaurant.has_method("street_outward")): var outward: Vector2 = restaurant.call("sidewalk_outward_at", global_position) \ if restaurant.has_method("sidewalk_outward_at") else restaurant.call("street_outward") if side.dot(outward) < 0.0: side = -side var detour := blocker.global_position + side * CROWD_DETOUR_DISTANCE \ + desired * CROWD_DETOUR_DISTANCE return global_position.direction_to(detour) # A-t-on franchi ce point de décision, le long de l'axe de la rue? # # La version d'origine ne lisait que l'abscisse: elle supposait une rue # horizontale. On projette donc la position sur l'axe déclaré, ce qui rend la # question vraie quelle que soit la face que la rue longe. func _crossed_x(target: float) -> bool: var here := global_position.dot(street_axis) var before := here - _last_step.dot(street_axis) return absf(here - target) < 24.0 \ or (before <= target and here >= target) \ or (before >= target and here <= target) func _crossed_point(target: Vector2) -> bool: var before := global_position - _last_step if _last_step.length_squared() <= 0.0001: return global_position.distance_to(target) < 24.0 var progress := clampf((target - before).dot(_last_step) \ / _last_step.length_squared(), 0.0, 1.0) var closest := before + _last_step * progress return closest.distance_to(target) < 24.0 func _current_stroll_waypoint() -> Vector2: if stroll_waypoint_index >= 0 and stroll_waypoint_index < stroll_waypoints.size(): return stroll_waypoints[stroll_waypoint_index] return stroll_target # Le client s'installe et se décide. Aucune patience ne court pendant ce temps: # il n'attend personne, et personne ne peut lire une commande qui n'existe pas # encore. C'est la seule étape de la visite que rien ne pénalise. func begin_thinking() -> void: requested_recipe = "" order_known = false order_memory_remaining = 0.0 reorder_delay_remaining = -1.0 order_wait_elapsed = 0.0 state = State.WAITING begin_phase(ServicePhase.THINKING) _refresh_collision() GameState.toast_requested.emit(Loc.ui("ui.restaurant.customer_seated") % display_name) # Le choix est arrêté: la commande existe, et l'attente d'un serveur commence. func assign_order(recipe_id: String) -> void: if state != State.WAITING: order_wait_elapsed = 0.0 requested_recipe = recipe_id # Une nouvelle commande efface ce qu'on savait de l'ancienne, des deux côtés: # elle doit être repérée à nouveau. GameState.clear_order_line(table_index) order_known = false order_memory_remaining = 0.0 state = State.WAITING begin_phase(ServicePhase.ORDERING) _refresh_collision() func force_waiting(recipe_id: String) -> void: global_position = seat_position if seat_position != Vector2.ZERO else global_position assign_order(recipe_id) ## La duree vient du client, pas de l'appelant: c'est lui qui mange. Un ## appelant peut encore l'imposer — une scene de controle veut un chiffre fixe. func begin_eating(duration: float = -1.0) -> void: order_wait_elapsed = 0.0 state = State.EATING begin_phase(ServicePhase.EATING) facing = Vector2.DOWN _refresh_visual_state() _refresh_collision() consumption_remaining = maxf(0.1, meal_seconds if duration < 0.0 else duration) queue_redraw() # Après un repas, le client reste assis sans patience ni commande. Le vrai # délai commence seulement lorsque Dish signale que l'assiette a quitté la table. func begin_reorder_pause() -> void: GameState.clear_order_line(table_index) requested_recipe = "" order_known = false order_memory_remaining = 0.0 reorder_delay_remaining = -1.0 state = State.BETWEEN_ORDERS # Le repas fini ouvre la dernière attente, celle du débarrassage. Elle pèse # sur le pourboire autant que les précédentes, et se dépasse aussi bien. begin_phase(ServicePhase.AWAITING_CLEANUP) _refresh_collision() queue_redraw() func start_reorder_delay() -> void: if state != State.BETWEEN_ORDERS or reorder_delay_remaining >= 0.0: return reorder_delay_remaining = REORDER_DELAY_SECONDS queue_redraw() func begin_leaving() -> void: if state == State.LEAVING: return # Un client qui s'en va emporte son addition. Ce qu'il doit en pourboire se # règle ici, quelle que soit la porte par laquelle il sort: rupture de stock # après un repas, rideau qui tombe, budget épuisé. Il n'y avait qu'une seule # sortie qui payait — celle du débarrassage — et toutes les autres perdaient # ce qui était dû. Une attente dépassée l'annule, mais elle a déjà marqué le # client bien avant d'arriver ici. if restaurant != null and is_instance_valid(restaurant) \ and restaurant.has_method("collect_departure_tip"): restaurant.collect_departure_tip(self) # A sidewalk customer already owns an L-shaped route around the building. # Reuse its remaining waypoints when a queue closes or a sale ends; aiming # straight at the far endpoint would draw a diagonal through the restaurant. if table_index < 0 and stroll_waypoints.size() >= 2: say("leave") requested_recipe = "" queue_kind = "" decided = true takeout_decided = true state = State.STROLLING _refresh_collision() queue_redraw() return say("leave") # Une ligne sans client ne serait jamais honorée: elle part avec lui. GameState.clear_order_line(table_index) state = State.LEAVING _refresh_collision() requested_recipe = "" queue_redraw() # Atteindre la porte libère la place, mais pas le personnage. Il reprend son # ancien trajet de passant (ou un trajet de secours) et ne disparaît qu'au bout # hors cadre du trottoir. func _resume_sidewalk_departure() -> void: if restaurant != null and is_instance_valid(restaurant) \ and restaurant.has_method("release_departing_customer_seat"): restaurant.release_departing_customer_seat(self) table_index = -1 seat_index = -1 queue_kind = "" decided = true takeout_decided = true if stroll_waypoints.size() < 2 or stroll_waypoint_index >= stroll_waypoints.size(): stroll_waypoints.clear() if restaurant != null and is_instance_valid(restaurant) \ and restaurant.has_method("customer_departure_route"): var fallback: Array[Vector2] = restaurant.customer_departure_route() stroll_waypoints.assign(fallback) stroll_waypoint_index = 1 if stroll_waypoints.size() > 1 else 0 if stroll_waypoints.is_empty(): departed.emit(self) queue_free() return stroll_target = stroll_waypoints[-1] state = State.STROLLING _refresh_collision() 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) # Reading an order is automatic at sight range. Action is only offered when it # can really hand the correct finished plate to this waiting customer. func can_interact(player: Player) -> bool: if state != State.WAITING or player == null or not is_instance_valid(player): return false var carried_dish := player.held_dish as Dish return carried_dish != null and carried_dish.state == Dish.State.FILLED \ and carried_dish.recipe_id == requested_recipe 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 State.BETWEEN_ORDERS: return "" 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") 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: # 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 var alpha := 1.0 if state != State.LEAVING else 0.72 var color := body_color color.a = alpha if should_draw_body(): draw_circle(Vector2.ZERO, 20.0, color) var forward := facing.normalized() * 4.0 if facing.length_squared() > 0.01 else Vector2(0.0, 4.0) var side := Vector2(-forward.y, forward.x).normalized() * 6.0 draw_circle(forward - side, 3.0, Color(0.075, 0.165, 0.18, alpha)) draw_circle(forward + side, 3.0, Color(0.075, 0.165, 0.18, alpha)) if target_glowing: draw_circle(Vector2.ZERO, 20.0, TARGET_GLOW_COLOR) if state == State.TAKEOUT_QUEUED: # Takeout patience remains part of the decision, but it is deliberately not # shown. The only useful bar is the live service progress of the first # customer; people behind them have not started being served yet. var service_progress := -1.0 if restaurant != null and restaurant.has_method("takeout_customer_service_progress"): service_progress = float(restaurant.takeout_customer_service_progress(self)) if service_progress >= 0.0: var service_bar := Rect2(-45, 29, 90, 6) draw_rect(service_bar, Color("392f45")) var filled := service_bar filled.size.x *= clampf(service_progress, 0.0, 1.0) draw_rect(filled, Color("4ade80")) draw_rect(service_bar, Color("20152c"), false, 1.5) elif state == State.WAITING: if shows_recipe_bubble(): 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 shows_recipe_bubble() -> bool: # Une commande pas encore choisie n'est lisible par personne. if service_phase == ServicePhase.THINKING: return false return state == State.WAITING and player_can_read_order() # Ce qu'un serveur peut encore prendre. Une commande déjà prise ne se reprend # pas: l'attente qu'elle fermait est close et notée. func can_take_order() -> bool: return state == State.WAITING and service_phase == ServicePhase.ORDERING # Ce client a-t-il une commande en cours? # # Depuis que choisir prend du temps, `WAITING` ne suffit plus à le dire: un # convive qui se décide encore est attablé et en attente, mais il n'a rien # commandé. Personne ne peut lui servir un plat, et surtout personne ne peut # juger que son plat est devenu impossible — il n'en a pas. func has_live_order() -> bool: if state != State.WAITING or service_phase == ServicePhase.THINKING: return false return not requested_recipe.is_empty() # Lisible si elle est sur l'ardoise, ou si le joueur s'en souvient encore. func player_can_read_order() -> bool: if table_index >= 0 and GameState.board_has_line(table_index): return true return order_known func get_player_vision_range() -> float: return maxf(0.0, float(GameState.resolve_game_variable("player_vision")))