class_name GameHUD extends CanvasLayer signal upgrades_requested signal dungeon_requested @export var location_name := "BIG EGG" @export var show_restaurant_actions := false @onready var location_label: Label = %LocationLabel @onready var coins_label: Label = %CoinsLabel @onready var reputation_label: Label = %ReputationLabel @onready var health_bar: ProgressBar = %HealthBar @onready var health_label: Label = %HealthLabel @onready var prompt_label: Label = %PromptLabel @onready var action_prompt_panel: PanelContainer = %ActionPromptPanel @onready var action_key_label: Label = %ActionKeyLabel @onready var context_label: Label = %ContextLabel @onready var hud_root: Control = $Root @onready var toast_label: Label = %ToastLabel @onready var objective_label: Label = %ObjectiveLabel @onready var restaurant_closed_label: Label = %RestaurantClosedLabel @onready var stamina_bar: ProgressBar = %StaminaBar @onready var stamina_label: Label = %StaminaLabel @onready var restaurant_actions: HBoxContainer = %RestaurantActions @onready var upgrade_button: Button = %UpgradeButton @onready var dungeon_button: Button = %DungeonButton @onready var weapon_label: Label = %WeaponLabel @onready var consumables_label: Label = %ConsumablesLabel const ScreenFit := preload("res://scripts/core/screen_fit.gd") const ActionSlots := preload("res://scripts/core/action_slots.gd") const HoldRing := preload("res://scripts/core/hold_ring.gd") const InputPrompts := preload("res://scripts/core/input_prompts.gd") const MENU_SCENE := preload("res://scenes/ui/recipe_menu.tscn") ## Le menu joueur (réorganisation du 12/10) : Tab ou Select l'ouvrent partout, ## plongée comprise. Même composant que la gestion du resto, en mode PLAYER. var player_menu: RecipeMenu ## L'icône de la touche d'action, devant l'invite (étape 5). var action_key_icon: TextureRect ## L'anneau d'un geste maintenu au-dessus du héros (la plongée, étape 3). var hold_ring: Control var action_slots: ActionSlots const HudOrderBoard := preload("res://scripts/core/hud_order_board.gd") ## L'ardoise des commandes, en haut a droite. N'existe qu'au restaurant. var order_board_panel: Control const BossHealthBar := preload("res://scripts/core/boss_health_bar.gd") ## La grande barre des boss, en haut au centre. N'existe qu'en plongee. var boss_health_bar: Control var player: Player var toast_tween: Tween var stamina_pause_tween: Tween var stamina_paused := false # --- Les soldes --------------------------------------------------------------- # # Le chiffre affiché n'est pas le chiffre réel: il attend que le petit texte qui # annonce le changement l'ait rejoint. Sans ce décalage, le solde aurait déjà # bougé avant que le joueur ait vu de combien — et un gain de 5 rondelles au # milieu d'un combat passerait inaperçu. # # `_shown` est ce qui est écrit à l'écran, `_pending` la valeur que le dernier # texte en vol fera apparaître en atterrissant. Comparer à `_pending` et non à # `_shown` évite de réannoncer la même différence à chaque rafraîchissement. const BALANCE_RISE := 26.0 const BALANCE_SECONDS := 0.95 const BALANCE_GAIN := Color("4ade80") const BALANCE_LOSS := Color("ff5d73") var _balance_shown := {"coins": 0.0, "reputation": 0.0} var _balance_pending := {"coins": 0.0, "reputation": 0.0} var _balances_primed := false ## --- Les barres de vie et de stamina ------------------------------------------ ## ## Elles ne servent qu'en plongee: au restaurant, personne ne se bat et rien ne ## s'epuise. Leur couleur suit ce qui reste — vert, jaune, rouge — et la vie en ## danger se signale franchement: barre qui clignote, bords de l'ecran rougis. const BAR_HEALTHY := Color("4ade80") const BAR_WARNING := Color("facc15") const BAR_DANGER := Color("ef4444") const BAR_TRACK := Color(0.1, 0.08, 0.13, 0.9) ## Le seuil critique: 5 % des PV max, mais jamais moins de 5 PV — sous 100 PV, ## un pourcentage tomberait a presque rien. const CRITICAL_HP := 5.0 const CRITICAL_RATIO := 0.05 var combat_bars := true var _health_fill: StyleBoxFlat var _stamina_fill: StyleBoxFlat var _danger_vignette: TextureRect var _health_critical := false var _pulse_clock := 0.0 ## La couleur de ce qui reste. Pleine au-dessus de 60 %, jaune vers 30 %, rouge ## a vide, avec des transitions douces entre les trois. static func vitality_color(ratio: float) -> Color: var r := clampf(ratio, 0.0, 1.0) if r >= 0.6: return BAR_HEALTHY if r >= 0.3: return BAR_WARNING.lerp(BAR_HEALTHY, (r - 0.3) / 0.3) return BAR_DANGER.lerp(BAR_WARNING, r / 0.3) static func health_threshold(maximum: float) -> float: return maxf(CRITICAL_HP, maximum * CRITICAL_RATIO) static func health_is_critical(current: float, maximum: float) -> bool: return current > 0.0 and current <= health_threshold(maximum) func _style_bar(bar: ProgressBar) -> StyleBoxFlat: var track := StyleBoxFlat.new() track.bg_color = BAR_TRACK track.set_corner_radius_all(5) bar.add_theme_stylebox_override("background", track) var fill := StyleBoxFlat.new() fill.bg_color = BAR_HEALTHY fill.set_corner_radius_all(5) bar.add_theme_stylebox_override("fill", fill) return fill ## Un voile rouge sur les bords de l'ecran, transparent au centre. Invisible tant ## que la vie n'est pas en danger; il bat alors avec la barre. func _build_danger_vignette() -> void: var gradient := Gradient.new() gradient.set_color(0, Color(BAR_DANGER.r, BAR_DANGER.g, BAR_DANGER.b, 0.0)) gradient.set_color(1, Color(BAR_DANGER.r, BAR_DANGER.g, BAR_DANGER.b, 0.55)) gradient.add_point(0.62, Color(BAR_DANGER.r, BAR_DANGER.g, BAR_DANGER.b, 0.0)) var texture := GradientTexture2D.new() texture.gradient = gradient texture.fill = GradientTexture2D.FILL_RADIAL texture.fill_from = Vector2(0.5, 0.5) texture.fill_to = Vector2(1.05, 1.05) texture.width = 256 texture.height = 256 _danger_vignette = TextureRect.new() _danger_vignette.name = "DangerVignette" _danger_vignette.texture = texture _danger_vignette.stretch_mode = TextureRect.STRETCH_SCALE _danger_vignette.expand_mode = TextureRect.EXPAND_IGNORE_SIZE _danger_vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE _danger_vignette.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _danger_vignette.visible = false hud_root.add_child(_danger_vignette) hud_root.move_child(_danger_vignette, 0) func _animate_danger(delta: float) -> void: if not _health_critical: return _pulse_clock += delta var beat := 0.5 + 0.5 * sin(_pulse_clock * TAU * 2.2) health_bar.self_modulate = Color.WHITE.lerp(Color(1.8, 0.7, 0.7), beat) health_label.modulate = Color("ff6b6b").lerp(Color.WHITE, beat) if _danger_vignette != null: _danger_vignette.modulate.a = lerpf(0.35, 1.0, beat) func _ready() -> void: # D'abord la taille: tout ce qui suit s'ancre aux bords de la racine. _fit_to_screen() get_viewport().size_changed.connect(_fit_to_screen) combat_bars = not show_restaurant_actions _health_fill = _style_bar(health_bar) _stamina_fill = _style_bar(stamina_bar) _build_danger_vignette() if combat_bars: boss_health_bar = BossHealthBar.new() hud_root.add_child(boss_health_bar) health_bar.visible = combat_bars health_label.visible = combat_bars _build_action_slots() location_label.text = location_name # Agir : le clic au resto, F en donjon (où le clic attaque) ; A à la manette. _refresh_action_key() Stage.input_device_changed.connect(func(_device: String) -> void: _refresh_action_key()) player_menu = MENU_SCENE.instantiate() player_menu.name = "PlayerMenu" add_child(player_menu) action_prompt_panel.visible = false GameState.state_changed.connect(refresh) GameState.toast_requested.connect(show_toast) restaurant_actions.visible = show_restaurant_actions if show_restaurant_actions and GameState.order_board_in_hud: _build_order_board() reputation_label.visible = show_restaurant_actions upgrade_button.pressed.connect(func() -> void: upgrades_requested.emit()) dungeon_button.pressed.connect(func() -> void: dungeon_requested.emit()) call_deferred("_find_player") set_stamina(0.0, 1.0, false) refresh() func _fit_to_screen() -> void: ScreenFit.fit(hud_root) ## Ancree au coin haut-droit, sous la barre du haut. Les ancres ET les ## decalages sont poses ensemble: melanger ancres et `position` a deja envoye ## un panneau hors de l'ecran dans ce projet. func _build_order_board() -> void: order_board_panel = HudOrderBoard.new() order_board_panel.name = "OrderBoardPanel" order_board_panel.anchor_left = 1.0 order_board_panel.anchor_right = 1.0 order_board_panel.anchor_top = 0.0 order_board_panel.anchor_bottom = 0.0 order_board_panel.offset_right = -16.0 order_board_panel.offset_left = -16.0 - HudOrderBoard.WIDTH order_board_panel.offset_top = STATUS_TOP order_board_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN hud_root.add_child(order_board_panel) func _find_player() -> void: player = GameState.scoped_first(self, "player") as Player if player != null: player.health_changed.connect(_on_health_changed) _on_health_changed(player.health, player.get_max_health()) func _process(_delta: float) -> void: _animate_danger(_delta) # Les cases d'abord, et sans dependre du heros: la rangee doit paraitre meme # avant que `_find_player` ait abouti, sinon elle clignote a chaque scene. _refresh_action_slots() if player == null or not is_instance_valid(player): return var closest := player.get_contextual_interactable() if not player.controls_enabled or closest == null or not closest.has_method("get_prompt"): _hide_action_prompt() return var actionable := player.target_is_actionable(closest) # Once a valid Action is being held, the prompt has done its job. Keeping it # over an ongoing cooking or service animation only hides the feedback. # Unavailable prompts remain visible in red even if Action is pressed. if actionable and Stage.gesture_held("action"): _hide_action_prompt() return var prompt := str(closest.get_prompt()).strip_edges() if prompt.is_empty(): _hide_action_prompt() return var context := str(closest.call("get_context_label")) \ if closest.has_method("get_context_label") else "" context_label.text = context.strip_edges() context_label.visible = not context_label.text.is_empty() prompt_label.text = prompt var prompt_color := Color("f4f0f8") if actionable else Color("ff6b6b") var key_color := Color("e8cf72") if actionable else Color("ff6b6b") prompt_label.add_theme_color_override("font_color", prompt_color) action_key_label.add_theme_color_override("font_color", key_color) if action_key_icon != null: action_key_icon.modulate = Color.WHITE if actionable else key_color action_prompt_panel.visible = true _place_action_prompt(closest) func _hide_action_prompt() -> void: action_prompt_panel.visible = false prompt_label.text = "" context_label.text = "" func _place_action_prompt(target: Node2D) -> void: var screen_position := _prompt_screen_position(target) var root_position := hud_root.get_global_transform().affine_inverse() * screen_position var panel_size := Vector2(220, 46 if context_label.visible else 28) action_prompt_panel.size = panel_size # Le centre inférieur du popup reste juste au-dessus du corps qu'il décrit. # Il ne suit plus la face de travail latérale, qui est une règle de portée et # non une position visuelle pertinente pour une étiquette. var wanted := root_position + Vector2(-panel_size.x * 0.5, -panel_size.y - 8.0) wanted.x = clampf(wanted.x, 10.0, hud_root.size.x - panel_size.x - 10.0) wanted.y = clampf(wanted.y, 68.0, hud_root.size.y - panel_size.y - 10.0) action_prompt_panel.position = wanted func _prompt_screen_position(target: Node2D) -> Vector2: if Render3D.active: var projected := Bridge3D.interaction_screen_position(target) if projected != Vector2.INF: return projected var world_position := target.global_position if target.has_method("get_prompt_anchor_position"): var reported: Variant = target.call("get_prompt_anchor_position") if reported is Vector2: world_position = reported return get_viewport().get_canvas_transform() * world_position func refresh() -> void: _refresh_balances() var weapon_id := GameState.active_weapon_id() var weapon_name := DataCatalog.weapon_name(weapon_id) if not weapon_id.is_empty() else "—" weapon_label.text = "ARME %s" % weapon_name if GameState.secondary_weapon_slot_count() > 0: var secondary_name := DataCatalog.weapon_name(GameState.secondary_weapon_id) \ if not GameState.secondary_weapon_id.is_empty() else "—" weapon_label.text += " [%s] %s" % [InputPrompts.label("secondary_action"), secondary_name] var consumable_parts: Array[String] = [] for slot: int in range(GameState.consumable_slot_count()): var id := GameState.consumable_in_slot(slot) var name := DataCatalog.recipe_name(id) if DataCatalog.RECIPES.has(id) else "—" consumable_parts.append("[%d] %s ×%d" % [slot + 1, name, GameState.get_consumable_amount(id)]) consumables_label.text = " ".join(consumable_parts) # --- La rangée de cases, en bas à gauche --------------------------------------- # # Elle dit ce que le héros porte et ce qu'il peut déclencher. Une case n'existe # que si son slot est **ouvert**: les compteurs de `GameState` portent déjà les # déblocages, donc la rangée grandit toute seule au fil de la partie. # # Les cases se reconstruisent à chaque image, pas au signal: une recharge change # en continu, et un rafraîchissement piloté par `state_changed` la laisserait # figée entre deux événements. const SLOTS_MARGIN := Vector2(18.0, 16.0) # Juste sous la barre du haut, qui descend a 54. const STATUS_LEFT := 16.0 const STATUS_TOP := 62.0 const STATUS_LINE := 26.0 # La rangee se redessine a chaque image: sans memoire, chaque case refaisait la # resolution du catalogue et un `ResourceLoader.exists` sur disque, soixante fois # par seconde. L'absence d'image se retient aussi. var _slot_icons := {} func _build_action_slots() -> void: action_slots = ActionSlots.new() action_slots.name = "ActionSlots" # Aucun ancrage: on pose la position a la main depuis la taille du parent. # Melanger les deux donnait un resultat imprevisible — la rangee se retrouvait # a `global_position.y = -165`, soit au-dessus de l'ecran. action_slots.mouse_filter = Control.MOUSE_FILTER_IGNORE hud_root.add_child(action_slots) # Le coin bas-gauche appartient desormais aux cases. Les messages d'etat y # etaient: on les remonte sous la barre du haut, ou ils se lisent sans # disputer la place a ce qu'on declenche. restaurant_closed_label.position = Vector2(STATUS_LEFT, STATUS_TOP) objective_label.position = Vector2(STATUS_LEFT, STATUS_TOP + STATUS_LINE) # Les deux étiquettes disaient la même chose en texte. Les cases les # remplacent; on les masque plutôt que de les retirer, pour pouvoir revenir # en arrière sans rouvrir la scène. weapon_label.visible = false consumables_label.visible = false func _refresh_action_slots() -> void: if action_slots == null or not is_instance_valid(action_slots): return action_slots.slots = _collect_action_slots() action_slots.size = action_slots.custom_minimum_size var height := action_slots.custom_minimum_size.y action_slots.position = Vector2(SLOTS_MARGIN.x, hud_root.size.y - height - SLOTS_MARGIN.y) ## Tab et Select : le menu joueur, partout où l'on joue. C'est `_input` : la ## tabulation sert aussi à l'interface pour passer d'un bouton à l'autre, et un ## bouton qui a le focus la mangerait. Ouvert, c'est le menu qui la reprend ## pour se fermer. func _input(event: InputEvent) -> void: if not event.is_action_pressed("console_toggle") or not can_open_player_menu(): return get_viewport().set_input_as_handled() open_player_menu() func can_open_player_menu() -> bool: if player_menu == null or player_menu.visible or get_tree().paused or Stage.top_interface() != null: return false var hero := player if player != null and is_instance_valid(player) else null if hero == null or not hero.controls_enabled or GameState.is_offstage(self): return false # Le donjon n'a pas de `transitioning` : absent, rien ne bloque. var level := GameState.scoped_first(self, "level") return level == null or level.get("transitioning") != true func open_player_menu() -> void: player_menu.open(RecipeMenu.Mode.PLAYER) ## L'invite d'action suit l'appareil : au resto, agir ; en donjon, F au clavier ## (le clic attaque) et A à la manette. L'icône de la touche, sinon son nom : ## jamais les deux (ligne 76). func _refresh_action_key() -> void: action_key_label.text = Loc.ui("ui.prompt.action_key_restaurant" if show_restaurant_actions else "ui.prompt.action_key_dungeon") var action := "action" if show_restaurant_actions or InputPrompts.device() == "pad" else "interact" var texture := InputPrompts.icon(action) if action_key_icon == null: action_key_icon = TextureRect.new() action_key_icon.name = "ActionKeyIcon" action_key_icon.custom_minimum_size = Vector2(26.0, 26.0) action_key_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE action_key_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED action_key_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE action_key_label.get_parent().add_child(action_key_icon) action_key_label.get_parent().move_child(action_key_icon, action_key_label.get_index()) action_key_icon.texture = texture action_key_icon.visible = texture != null action_key_label.visible = texture == null ## Montre l'anneau à `at`, en pixels d'écran ; zéro ou une position hors champ ## le cache. `icon` : la touche à garder, au centre. func show_hold_ring(progress: float, at: Vector2, icon: Texture2D = null) -> void: if progress <= 0.0 or at == Vector2.INF: if hold_ring != null and is_instance_valid(hold_ring): hold_ring.visible = false return if hold_ring == null or not is_instance_valid(hold_ring): hold_ring = HoldRing.new() hold_ring.name = "HoldRing" hold_ring.size = Vector2(72.0, 72.0) hud_root.add_child(hold_ring) hold_ring.visible = true hold_ring.position = at - hold_ring.size * 0.5 hold_ring.progress = progress hold_ring.icon = icon func _collect_action_slots() -> Array[Dictionary]: var built: Array[Dictionary] = [] var hero := player if player != null and is_instance_valid(player) else null # Le dash d'abord: c'est le geste qu'on déclenche le plus, et le premier # débloqué. Aucune charge signifie qu'il n'est pas encore acquis. if hero != null and hero.get_dash_charges() > 0: var total := hero.get_dash_cooldown_total() built.append({ "type": "DASH", "key": InputPrompts.label("dash").to_upper(), "key_icon": InputPrompts.icon("dash"), "label": "Dash", "icon": null, "ready_ratio": 1.0 - clampf(hero.get_dash_cooldown_remaining() / total, 0.0, 1.0), "count": hero.get_dash_charges(), }) var weapon_id := GameState.active_weapon_id() # Le heros sait ce que son cadran doit montrer: voile pendant le geste, puis # un seul tour a la cadence d'un coup clique. Ce que le maintien coute en # plus ne ralentit pas le cercle, il ajoute une pause apres lui. var weapon_ratio := 1.0 if hero != null: weapon_ratio = hero.get_attack_ready_ratio() built.append(_equipment_slot("ARME", "", weapon_id, DataCatalog.weapon_name(weapon_id) if not weapon_id.is_empty() else "", "weapons", weapon_ratio)) if GameState.secondary_weapon_slot_count() > 0: var second := GameState.secondary_weapon_id var second_slot := _equipment_slot("ARME 2", InputPrompts.label("secondary_action").to_upper(), second, DataCatalog.weapon_name(second) if not second.is_empty() else "", "weapons", weapon_ratio) second_slot["key_icon"] = InputPrompts.icon("secondary_action") built.append(second_slot) # L'équipement passif ne s'active pas: ni touche, ni horloge. Il est là pour # rappeler ce qui est porté. for index: int in range(GameState.equipment_slot_count()): var worn := GameState.equipped_equipment[index] if index < GameState.equipped_equipment.size() else "" built.append(_equipment_slot("PASSIF", "", worn, DataCatalog.equipment_name(worn) if not worn.is_empty() else "", "equipment", 1.0)) for index: int in range(GameState.consumable_slot_count()): var id := GameState.consumable_in_slot(index) var slot := _equipment_slot("OBJET", "%d" % (index + 1), id, DataCatalog.recipe_name(id) if DataCatalog.RECIPES.has(id) else "", "recipes", 1.0) slot["count"] = GameState.get_consumable_amount(id) if not id.is_empty() else 0 # La case que ZL et Shift déclenchent, quand il y en a plusieurs. slot["selected"] = GameState.consumable_slot_count() > 1 and index == GameState.selected_consumable() # Au clavier, chaque case a son chiffre ; à la manette, seule la case # choisie a une touche, celle qui la déclenche. if InputPrompts.device() == "pad": var chosen := index == GameState.selected_consumable() slot["key"] = InputPrompts.label("consumable_use") if chosen else "" slot["key_icon"] = InputPrompts.icon("consumable_use") if chosen else null else: slot["key"] = InputPrompts.label("consumable_%d" % (index + 1)) slot["key_icon"] = InputPrompts.icon("consumable_%d" % (index + 1)) built.append(slot) return built ## L'image d'une case, si elle existe. Les armes et les equipements n'ont recu ## leur etat « icon » que recemment: tant qu'aucune image n'est fournie, la ## case retombe sur l'initiale du nom plutot que de rester muette. func _slot_icon(collection: String, id: String) -> Texture2D: if id.is_empty(): return null var key := collection + ":" + id if _slot_icons.has(key): return _slot_icons[key] var icon: Texture2D = null var visual_id := VisualCatalog.resolve(collection, id, "icon") if not visual_id.is_empty(): var path := str(VisualCatalog.definition(visual_id).get("path", "")) if not path.is_empty() and ResourceLoader.exists(path): icon = load(path) as Texture2D _slot_icons[key] = icon return icon ## Une case résolue. Le visuel « icon » n'existe pas encore pour toutes les ## collections: quand il manque, la case retombe sur l'initiale du nom plutôt que ## de rester muette. func _equipment_slot(kind: String, key: String, id: String, label: String, collection: String, ready_ratio: float) -> Dictionary: var icon := _slot_icon(collection, id) return { "type": kind, "key": key, "label": label, "icon": icon, "ready_ratio": ready_ratio, "count": -1, "empty": id.is_empty(), } # --- Ce que valent les soldes, et quand on le montre -------------------------- func _live_balance(key: String) -> float: return float(GameState.coins) if key == "coins" \ else float(GameState.get_fact_value("restaurant_popularity")) func _balance_label(key: String) -> Label: return coins_label if key == "coins" else reputation_label func _refresh_balances() -> void: # À la première image, rien à annoncer: on prend l'état tel qu'il est. if not _balances_primed: _balances_primed = true for key: String in _balance_shown: _balance_shown[key] = _live_balance(key) _balance_pending[key] = _balance_shown[key] _render_balances() return for key: String in _balance_shown: var live := _live_balance(key) var delta := live - float(_balance_pending[key]) if is_zero_approx(delta): continue _balance_pending[key] = live # Un changement trop petit pour se voir dans l'unité affichée n'a rien à # annoncer, mais il ne doit pas rester en attente pour autant. if absf(delta) < 0.5 or not _balance_label(key).visible: _balance_shown[key] = live _render_balances() continue _announce_balance(key, delta, live) _render_balances() func _render_balances() -> void: coins_label.text = "RONDELLES %d" % roundi(float(_balance_shown.coins)) reputation_label.text = "RÉPUTATION %.0f" % float(_balance_shown.reputation) # Le texte part juste sous le solde et le rejoint vite. C'est **son arrivée** qui # met le chiffre à jour: le joueur lit d'abord de combien, puis voit le total. func _announce_balance(key: String, delta: float, target: float) -> void: # Un seul chiffre en vol par solde. # # Chaque changement créait sa propre étiquette et son propre tween, sans # plafond: une rafale de gains en empilait autant qu'il y avait eu de pas. # Illisible à l'écran — cinquante « +5 » superposés — et une charge qui # grandit avec la partie, d'autant plus depuis que chaque chiffre reste en # l'air presque une seconde. Le nouveau annonce donc tout ce qui n'est pas # encore affiché, et non le seul dernier pas. _drop_balance_floaters(key) var announced := target - float(_balance_shown[key]) if is_zero_approx(announced): announced = delta var label := _balance_label(key) var floater := Label.new() floater.text = "%+d" % roundi(announced) floater.add_theme_font_size_override("font_size", 17) floater.add_theme_color_override("font_color", BALANCE_GAIN if announced > 0.0 else BALANCE_LOSS) floater.add_theme_color_override("font_outline_color", Color(0.05, 0.03, 0.08)) floater.add_theme_constant_override("outline_size", 5) floater.mouse_filter = Control.MOUSE_FILTER_IGNORE floater.z_index = 50 floater.set_meta("balance_key", key) floater.add_to_group("balance_delta") hud_root.add_child(floater) # Le solde est écrit dans une étiquette large et **alignée à droite**: son # origine est son bord gauche, à plus de deux cents pixels du chiffre qu'on # lit. Viser l'origine faisait partir l'annonce loin à gauche de sa cible. var landing := _balance_text_anchor(label, floater) floater.global_position = landing + Vector2(0.0, BALANCE_RISE) var tween := floater.create_tween().set_parallel(true) tween.tween_property(floater, "global_position", landing, BALANCE_SECONDS) \ .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) tween.tween_property(floater, "modulate:a", 0.0, BALANCE_SECONDS * 0.45) \ .set_delay(BALANCE_SECONDS * 0.55) tween.chain().tween_callback(func() -> void: _land_balance(key, target) floater.remove_from_group("balance_delta") floater.queue_free()) # Où le texte du solde est réellement dessiné dans son étiquette. func _balance_text_anchor(label: Label, floater: Label) -> Vector2: var rect := label.get_global_rect() var floater_width := floater.get_minimum_size().x match label.horizontal_alignment: HORIZONTAL_ALIGNMENT_RIGHT: return Vector2(rect.end.x - floater_width, rect.position.y) HORIZONTAL_ALIGNMENT_CENTER: return Vector2(rect.position.x + (rect.size.x - floater_width) * 0.5, rect.position.y) return rect.position func _land_balance(key: String, value: float) -> void: _balance_shown[key] = value _render_balances() # Retire les chiffres encore en vol pour ce solde-là, sans toucher aux autres. # Le remplaçant porte déjà tout l'écart non affiché: les faire atterrir un par un # n'ajouterait rien qu'un scintillement. func _drop_balance_floaters(key: String) -> void: for floater: Node in GameState.scoped_nodes(self, "balance_delta"): if not is_instance_valid(floater) or str(floater.get_meta("balance_key", "")) != key: continue floater.remove_from_group("balance_delta") floater.queue_free() # Termine sur-le-champ ce qui est en vol. Un changement de scène ne doit pas # emporter un texte à mi-course, et un contrôle qui lit le solde juste après # l'avoir changé doit lire le chiffre final, pas celui d'avant. func settle_balances() -> void: for floater: Node in GameState.scoped_nodes(self, "balance_delta"): if not is_instance_valid(floater): continue floater.remove_from_group("balance_delta") floater.queue_free() for key: String in _balance_shown: _balance_shown[key] = _live_balance(key) _balance_pending[key] = _balance_shown[key] _render_balances() func set_objective(text: String) -> void: objective_label.text = text ## Un temps de la mesure (le défi du rythme) : la ligne d'objectif s'illumine un ## instant, puis revient. var _beat_tween: Tween func pulse_beat() -> void: if _beat_tween != null and _beat_tween.is_valid(): _beat_tween.kill() objective_label.modulate = Color(1.0, 0.82, 0.3) _beat_tween = create_tween() _beat_tween.tween_property(objective_label, "modulate", Color.WHITE, 0.2) # Un rideau baissé dit pourquoi. Sans raison, la ligne garde son texte de scène: # c'est le cas du donjon, qui n'a pas de magasin à expliquer. func set_restaurant_closed(closed: bool, reason: String = "") -> void: restaurant_closed_label.visible = closed if closed and not reason.is_empty(): restaurant_closed_label.text = reason func set_stamina(current: float, maximum: float, show: bool = true, paused: bool = false) -> void: # Au restaurant, rien ne s'epuise: la barre n'a rien a dire. show = show and combat_bars stamina_bar.visible = show stamina_label.visible = show var should_pause := show and paused if stamina_paused != should_pause: stamina_paused = should_pause if stamina_pause_tween != null and stamina_pause_tween.is_valid(): stamina_pause_tween.kill() stamina_bar.modulate.a = 1.0 if stamina_paused: stamina_pause_tween = create_tween().set_loops().set_trans(Tween.TRANS_SINE) stamina_pause_tween.tween_property(stamina_bar, "modulate:a", 0.4, 1.0) stamina_pause_tween.tween_property(stamina_bar, "modulate:a", 1.0, 1.0) if not show: return stamina_bar.max_value = maximum stamina_bar.value = current if _stamina_fill != null: _stamina_fill.bg_color = vitality_color(current / maxf(1.0, maximum)) stamina_label.text = "STAMINA || / %d" % ceili(maximum) if stamina_paused else "STAMINA %d" % ceili(current) if current <= 10.0: stamina_label.modulate = Color("ff6b6b") else: stamina_label.modulate = Color.WHITE func _on_health_changed(current: float, maximum: float) -> void: health_bar.max_value = maximum health_bar.value = current health_label.text = "HP %d / %d" % [ceili(current), ceili(maximum)] if _health_fill != null: _health_fill.bg_color = vitality_color(current / maxf(1.0, maximum)) var critical := combat_bars and health_is_critical(current, maximum) if critical != _health_critical: _health_critical = critical _pulse_clock = 0.0 if _danger_vignette != null: _danger_vignette.visible = critical if not critical: health_bar.self_modulate = Color.WHITE health_label.modulate = Color.WHITE func show_toast(message: String) -> void: toast_label.text = message toast_label.modulate = Color.WHITE if toast_tween != null and toast_tween.is_valid(): toast_tween.kill() toast_tween = create_tween() toast_tween.tween_interval(1.5) toast_tween.tween_property(toast_label, "modulate:a", 0.0, 0.5)