From 7f6811995d3fde9e9f4091826a0573edc82a6eda Mon Sep 17 00:00:00 2001 From: "Poulpe (Silver Surfer deploy)" Date: Mon, 20 Apr 2026 18:11:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(progression):=20succ=C3=A8s/achievements?= =?UTF-8?q?=20+=20toasts=20notification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvel autoload AchievementManager avec 12 succès (Premier éclat, Mineur confirmé, Chasseur de perles, Fouilleur d'épaves, Abysses, Légende des océans, etc.). Chaque déblocage donne un bonus XP et joue un son. HUD: toasts dorés avec icône + titre + desc + fade-in/out, empilés en haut centre. Son bulle au déblocage. Long-terme: cible des milestones cumulatifs qui donnent un sentiment de progression durable. Co-Authored-By: Claude Opus 4.7 (1M context) --- project.godot | 1 + scripts/Main.gd | 6 ++ scripts/dolphin/HUD.gd | 97 +++++++++++++++++++++++ scripts/inventory/CraftingRecipes.gd | 3 + scripts/progression/AchievementManager.gd | 91 +++++++++++++++++++++ scripts/world/PearlSpawner.gd | 3 + 6 files changed, 201 insertions(+) create mode 100644 scripts/progression/AchievementManager.gd diff --git a/project.godot b/project.godot index 40cc3ee..e66d5b0 100644 --- a/project.godot +++ b/project.godot @@ -100,6 +100,7 @@ ItemDatabase="*res://scripts/inventory/ItemDatabase.gd" NetworkManager="*res://scripts/net/NetworkManager.gd" PlayerProgress="*res://scripts/progression/PlayerProgress.gd" QuestManager="*res://scripts/progression/QuestManager.gd" +AchievementManager="*res://scripts/progression/AchievementManager.gd" [rendering] environment/defaults/default_clear_color=Color(0.05, 0.15, 0.25, 1) diff --git a/scripts/Main.gd b/scripts/Main.gd index 2f7f4a3..58577bb 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -251,6 +251,9 @@ func _award_break_xp(block_id: int, hit_position: Vector3) -> void: var qm: Node = get_node_or_null("/root/QuestManager") if qm != null: qm.note_block_break(block_id) + var am: Node = get_node_or_null("/root/AchievementManager") + if am != null: + am.note_block_break(block_id) func _spawn_xp_popup(amount: int, world_pos: Vector3) -> void: @@ -289,6 +292,9 @@ func _on_block_place(hit_position: Vector3, normal: Vector3) -> void: func _on_echolocation(position: Vector3, _radius: float) -> void: AudioManager.play_bubble_sfx(position) + var am: Node = get_node_or_null("/root/AchievementManager") + if am != null: + am.note_echolocation() # Called by WorldSyncComponent when server confirms a block was broken by this client diff --git a/scripts/dolphin/HUD.gd b/scripts/dolphin/HUD.gd index 02724de..9805fb0 100644 --- a/scripts/dolphin/HUD.gd +++ b/scripts/dolphin/HUD.gd @@ -25,6 +25,9 @@ var _quest_list: VBoxContainer = null var _quest_complete_banner: Label = null var _quest_banner_timer: float = 0.0 +var _toast_container: VBoxContainer = null +var _toasts: Array = [] # each: {"node": PanelContainer, "timer": float} + var _biome_cache_pos: Vector3 = Vector3(99999, 99999, 99999) var _biome_cached_name: String = "" @@ -48,6 +51,10 @@ func _ready() -> void: qm.quests_updated.connect(_on_quests_updated) qm.quest_completed.connect(_on_quest_completed) _on_quests_updated() + _build_toast_container() + var am: Node = get_node_or_null("/root/AchievementManager") + if am != null: + am.achievement_unlocked.connect(_on_achievement_unlocked) func _build_info_panel() -> void: @@ -232,6 +239,91 @@ func _on_quest_completed(q: Dictionary) -> void: am.call("play_bubble_sfx", _dolphin.global_position) +func _build_toast_container() -> void: + _toast_container = VBoxContainer.new() + _toast_container.anchor_left = 0.5 + _toast_container.anchor_right = 0.5 + _toast_container.anchor_top = 0.0 + _toast_container.anchor_bottom = 0.0 + _toast_container.offset_left = -220.0 + _toast_container.offset_right = 220.0 + _toast_container.offset_top = 16.0 + _toast_container.offset_bottom = 16.0 + _toast_container.add_theme_constant_override("separation", 6) + add_child(_toast_container) + + +func _on_achievement_unlocked(ach: Dictionary) -> void: + if _toast_container == null: + return + var panel := PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = Color(0.18, 0.14, 0.05, 0.92) + style.border_color = Color(1.0, 0.82, 0.2) + style.set_border_width_all(2) + style.set_corner_radius_all(8) + panel.add_theme_stylebox_override("panel", style) + + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 10) + panel.add_child(hbox) + + var icon := Label.new() + icon.text = ach.get("icon", "★") + icon.add_theme_font_size_override("font_size", 28) + icon.add_theme_color_override("font_color", Color(1.0, 0.92, 0.4)) + hbox.add_child(icon) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 0) + hbox.add_child(vbox) + + var title := Label.new() + title.text = "SUCCÈS DÉBLOQUÉ : %s" % ach["name"] + title.add_theme_font_size_override("font_size", 13) + title.add_theme_color_override("font_color", Color(1.0, 0.95, 0.6)) + vbox.add_child(title) + + var desc := Label.new() + desc.text = "%s (+%d XP)" % [ach["desc"], ach["xp"]] + desc.add_theme_font_size_override("font_size", 11) + desc.add_theme_color_override("font_color", Color(0.88, 0.88, 0.88)) + vbox.add_child(desc) + + _toast_container.add_child(panel) + panel.modulate.a = 0.0 + _toasts.append({"node": panel, "timer": 4.0, "age": 0.0}) + + var audio: Node = get_node_or_null("/root/AudioManager") + if audio != null and is_instance_valid(_dolphin) and audio.has_method("play_bubble_sfx"): + audio.call("play_bubble_sfx", _dolphin.global_position) + + +func _update_toasts(delta: float) -> void: + if _toasts.is_empty(): + return + var i: int = _toasts.size() - 1 + while i >= 0: + var t: Dictionary = _toasts[i] + t["age"] += delta + t["timer"] -= delta + var node: PanelContainer = t["node"] + if not is_instance_valid(node): + _toasts.remove_at(i) + i -= 1 + continue + if t["age"] < 0.35: + node.modulate.a = t["age"] / 0.35 + elif t["timer"] < 0.5: + node.modulate.a = maxf(t["timer"] / 0.5, 0.0) + else: + node.modulate.a = 1.0 + if t["timer"] <= 0.0: + node.queue_free() + _toasts.remove_at(i) + i -= 1 + + func _build_level_banner() -> void: _level_banner = Label.new() _level_banner.anchor_left = 0.5 @@ -320,6 +412,8 @@ func _process(delta: float) -> void: if _quest_complete_banner != null: _quest_complete_banner.modulate.a = clampf(_quest_banner_timer / 0.6, 0.0, 1.0) + _update_toasts(delta) + if not is_instance_valid(_dolphin): return @@ -334,6 +428,9 @@ func _process(delta: float) -> void: var qm: Node = get_node_or_null("/root/QuestManager") if qm != null and qm.has_method("note_depth"): qm.call("note_depth", depth_m) + var am2: Node = get_node_or_null("/root/AchievementManager") + if am2 != null and am2.has_method("note_depth"): + am2.call("note_depth", depth_m) if _compass_label != null: _compass_label.text = _compass_text(_dolphin.rotation.y) diff --git a/scripts/inventory/CraftingRecipes.gd b/scripts/inventory/CraftingRecipes.gd index ac0e1f4..2e6e48b 100644 --- a/scripts/inventory/CraftingRecipes.gd +++ b/scripts/inventory/CraftingRecipes.gd @@ -64,3 +64,6 @@ static func _award_craft_xp(recipe: Dictionary) -> void: var qm: Node = root.get_node_or_null("/root/QuestManager") if qm != null: qm.note_craft() + var ach: Node = root.get_node_or_null("/root/AchievementManager") + if ach != null: + ach.note_craft() diff --git a/scripts/progression/AchievementManager.gd b/scripts/progression/AchievementManager.gd new file mode 100644 index 0000000..e2571c0 --- /dev/null +++ b/scripts/progression/AchievementManager.gd @@ -0,0 +1,91 @@ +extends Node + +signal achievement_unlocked(ach: Dictionary) + +const ACHIEVEMENTS: Array = [ + {"id": "first_break", "name": "Premier éclat", "desc": "Casser ton premier bloc", "xp": 10, "icon": "⛏"}, + {"id": "ten_breaks", "name": "Mineur apprenti", "desc": "Casser 10 blocs", "xp": 20, "icon": "⛏"}, + {"id": "hundred_breaks", "name": "Mineur confirmé", "desc": "Casser 100 blocs", "xp": 80, "icon": "⛏"}, + {"id": "first_pearl", "name": "Lueur nacrée", "desc": "Collecter ta première perle", "xp": 20, "icon": "◉"}, + {"id": "ten_pearls", "name": "Chasseur de perles", "desc": "Collecter 10 perles", "xp": 120, "icon": "◉"}, + {"id": "first_craft", "name": "Artisan", "desc": "Crafter ton premier objet", "xp": 15, "icon": "✦"}, + {"id": "first_wreck", "name": "Fouilleur d'épaves", "desc": "Piller une épave", "xp": 30, "icon": "⚓"}, + {"id": "depth_50", "name": "Plongée profonde", "desc": "Atteindre -50 m", "xp": 60, "icon": "▼"}, + {"id": "depth_100", "name": "Abysses", "desc": "Atteindre -100 m", "xp": 200, "icon": "▼"}, + {"id": "level_5", "name": "Dauphin aguerri", "desc": "Atteindre le niveau 5", "xp": 50, "icon": "★"}, + {"id": "level_10", "name": "Légende des océans", "desc": "Atteindre le niveau 10", "xp": 150, "icon": "★"}, + {"id": "echo_first", "name": "Sonar aiguisé", "desc": "Utiliser l'écholocation", "xp": 10, "icon": "))"}, +] + +var unlocked: Dictionary = {} # id -> true + +var _break_count: int = 0 +var _pearl_count: int = 0 + + +func _ready() -> void: + var pp: Node = get_node_or_null("/root/PlayerProgress") + if pp != null: + pp.level_up.connect(_on_level_up) + + +func note_block_break(block_id: int) -> void: + _break_count += 1 + if _break_count >= 1: + _unlock("first_break") + if _break_count >= 10: + _unlock("ten_breaks") + if _break_count >= 100: + _unlock("hundred_breaks") + if block_id == 7: + _unlock("first_wreck") + + +func note_pearl_collected() -> void: + _pearl_count += 1 + if _pearl_count >= 1: + _unlock("first_pearl") + if _pearl_count >= 10: + _unlock("ten_pearls") + + +func note_craft() -> void: + _unlock("first_craft") + + +func note_depth(depth_m: int) -> void: + if depth_m >= 50: + _unlock("depth_50") + if depth_m >= 100: + _unlock("depth_100") + + +func note_echolocation() -> void: + _unlock("echo_first") + + +func _on_level_up(new_level: int) -> void: + if new_level >= 5: + _unlock("level_5") + if new_level >= 10: + _unlock("level_10") + + +func _unlock(id: String) -> void: + if unlocked.has(id): + return + var ach: Dictionary = _find(id) + if ach.is_empty(): + return + unlocked[id] = true + achievement_unlocked.emit(ach) + var pp: Node = get_node_or_null("/root/PlayerProgress") + if pp != null: + pp.award(ach["xp"], "succès: %s" % ach["name"], Vector3.ZERO) + + +func _find(id: String) -> Dictionary: + for a: Dictionary in ACHIEVEMENTS: + if a["id"] == id: + return a + return {} diff --git a/scripts/world/PearlSpawner.gd b/scripts/world/PearlSpawner.gd index dd4336d..1da19c9 100644 --- a/scripts/world/PearlSpawner.gd +++ b/scripts/world/PearlSpawner.gd @@ -125,3 +125,6 @@ func _on_pearl_collected(item_id: int) -> void: var qm: Node = get_node_or_null("/root/QuestManager") if qm != null: qm.note_pearl_collected() + var ach: Node = get_node_or_null("/root/AchievementManager") + if ach != null: + ach.note_pearl_collected()