diff --git a/project.godot b/project.godot index dae3933..40cc3ee 100644 --- a/project.godot +++ b/project.godot @@ -99,6 +99,7 @@ AudioManager="*res://scripts/ambience/AudioManager.gd" ItemDatabase="*res://scripts/inventory/ItemDatabase.gd" NetworkManager="*res://scripts/net/NetworkManager.gd" PlayerProgress="*res://scripts/progression/PlayerProgress.gd" +QuestManager="*res://scripts/progression/QuestManager.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 71500ad..2f7f4a3 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -248,6 +248,9 @@ func _award_break_xp(block_id: int, hit_position: Vector3) -> void: var gain: int = pp.XP_BREAK_BY_BLOCK.get(block_id, pp.XP_BREAK_DEFAULT) pp.award(gain, "bloc", hit_position) _spawn_xp_popup(gain, hit_position) + var qm: Node = get_node_or_null("/root/QuestManager") + if qm != null: + qm.note_block_break(block_id) func _spawn_xp_popup(amount: int, world_pos: Vector3) -> void: diff --git a/scripts/dolphin/HUD.gd b/scripts/dolphin/HUD.gd index 29b79a6..02724de 100644 --- a/scripts/dolphin/HUD.gd +++ b/scripts/dolphin/HUD.gd @@ -20,6 +20,11 @@ var _xp_label: Label = null var _level_banner: Label = null var _level_banner_timer: float = 0.0 +var _quest_panel: PanelContainer = null +var _quest_list: VBoxContainer = null +var _quest_complete_banner: Label = null +var _quest_banner_timer: float = 0.0 + var _biome_cache_pos: Vector3 = Vector3(99999, 99999, 99999) var _biome_cached_name: String = "" @@ -31,12 +36,18 @@ func _ready() -> void: _build_info_panel() _build_xp_panel() _build_level_banner() + _build_quest_panel() if Engine.has_singleton("PlayerProgress") or has_node("/root/PlayerProgress"): var pp: Node = get_node_or_null("/root/PlayerProgress") if pp != null: pp.xp_changed.connect(_on_xp_changed) pp.level_up.connect(_on_level_up) _on_xp_changed(pp.current_xp, pp.xp_for_next(), pp.level) + var qm: Node = get_node_or_null("/root/QuestManager") + if qm != null: + qm.quests_updated.connect(_on_quests_updated) + qm.quest_completed.connect(_on_quest_completed) + _on_quests_updated() func _build_info_panel() -> void: @@ -133,6 +144,94 @@ func _build_xp_panel() -> void: add_child(_xp_panel) +func _build_quest_panel() -> void: + _quest_panel = PanelContainer.new() + _quest_panel.anchor_left = 1.0 + _quest_panel.anchor_right = 1.0 + _quest_panel.anchor_top = 0.0 + _quest_panel.anchor_bottom = 0.0 + _quest_panel.offset_left = -260.0 + _quest_panel.offset_top = 130.0 + _quest_panel.offset_right = -16.0 + _quest_panel.offset_bottom = 260.0 + + var style := StyleBoxFlat.new() + style.bg_color = Color(0.04, 0.08, 0.12, 0.72) + style.border_color = Color(0.35, 0.85, 0.95, 0.65) + style.set_border_width_all(1) + style.set_corner_radius_all(8) + _quest_panel.add_theme_stylebox_override("panel", style) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 3) + _quest_panel.add_child(vbox) + + var title := Label.new() + title.text = "◈ OBJECTIFS" + title.add_theme_font_size_override("font_size", 12) + title.add_theme_color_override("font_color", Color(0.55, 0.95, 1.0)) + vbox.add_child(title) + + _quest_list = VBoxContainer.new() + _quest_list.add_theme_constant_override("separation", 2) + vbox.add_child(_quest_list) + + add_child(_quest_panel) + + +func _on_quests_updated() -> void: + if _quest_list == null: + return + for c in _quest_list.get_children(): + c.queue_free() + var qm: Node = get_node_or_null("/root/QuestManager") + if qm == null: + return + for q: Dictionary in qm.active: + var row := VBoxContainer.new() + row.add_theme_constant_override("separation", 0) + var desc := Label.new() + desc.text = "• %s" % q["desc"] + desc.add_theme_font_size_override("font_size", 11) + desc.add_theme_color_override("font_color", Color(0.92, 0.96, 1.0)) + row.add_child(desc) + var prog := Label.new() + prog.text = " %d/%d +%d XP" % [q["progress"], q["target"], q["reward_xp"]] + prog.add_theme_font_size_override("font_size", 10) + var completion: float = float(q["progress"]) / float(q["target"]) + var col: Color = Color(0.6, 0.7, 0.8).lerp(Color(0.4, 1.0, 0.5), completion) + prog.add_theme_color_override("font_color", col) + row.add_child(prog) + _quest_list.add_child(row) + + +func _on_quest_completed(q: Dictionary) -> void: + if _quest_complete_banner == null: + _quest_complete_banner = Label.new() + _quest_complete_banner.anchor_left = 0.5 + _quest_complete_banner.anchor_right = 0.5 + _quest_complete_banner.anchor_top = 0.33 + _quest_complete_banner.anchor_bottom = 0.33 + _quest_complete_banner.offset_left = -260.0 + _quest_complete_banner.offset_right = 260.0 + _quest_complete_banner.offset_top = -22.0 + _quest_complete_banner.offset_bottom = 22.0 + _quest_complete_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _quest_complete_banner.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _quest_complete_banner.add_theme_font_size_override("font_size", 22) + _quest_complete_banner.add_theme_color_override("font_color", Color(0.55, 1.0, 0.7)) + _quest_complete_banner.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9)) + _quest_complete_banner.add_theme_constant_override("outline_size", 5) + add_child(_quest_complete_banner) + _quest_complete_banner.text = "✓ QUÊTE : %s (+%d XP)" % [q["desc"], q["reward_xp"]] + _quest_complete_banner.modulate.a = 1.0 + _quest_banner_timer = 2.8 + var am: Node = get_node_or_null("/root/AudioManager") + if am != null and is_instance_valid(_dolphin): + if am.has_method("play_bubble_sfx"): + am.call("play_bubble_sfx", _dolphin.global_position) + + func _build_level_banner() -> void: _level_banner = Label.new() _level_banner.anchor_left = 0.5 @@ -216,6 +315,11 @@ func _process(delta: float) -> void: _level_banner.modulate.a = a _level_banner.scale = Vector2.ONE * (1.0 + (1.0 - a) * 0.2) + if _quest_banner_timer > 0.0: + _quest_banner_timer -= delta + if _quest_complete_banner != null: + _quest_complete_banner.modulate.a = clampf(_quest_banner_timer / 0.6, 0.0, 1.0) + if not is_instance_valid(_dolphin): return @@ -227,6 +331,9 @@ func _process(delta: float) -> void: var pp: Node = get_node_or_null("/root/PlayerProgress") if pp != null and pp.has_method("note_depth"): pp.call("note_depth", depth_m) + var qm: Node = get_node_or_null("/root/QuestManager") + if qm != null and qm.has_method("note_depth"): + qm.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 76eb35a..ac0e1f4 100644 --- a/scripts/inventory/CraftingRecipes.gd +++ b/scripts/inventory/CraftingRecipes.gd @@ -57,7 +57,10 @@ static func craft(inventory: Inventory, recipe_index: int) -> bool: static func _award_craft_xp(recipe: Dictionary) -> void: - var pp: Node = Engine.get_main_loop().root.get_node_or_null("/root/PlayerProgress") - if pp == null: - return - pp.award(pp.XP_CRAFT, "craft %s" % str(recipe.get("name", "")), Vector3.ZERO) + var root: Node = Engine.get_main_loop().root + var pp: Node = root.get_node_or_null("/root/PlayerProgress") + if pp != null: + pp.award(pp.XP_CRAFT, "craft %s" % str(recipe.get("name", "")), Vector3.ZERO) + var qm: Node = root.get_node_or_null("/root/QuestManager") + if qm != null: + qm.note_craft() diff --git a/scripts/progression/QuestManager.gd b/scripts/progression/QuestManager.gd new file mode 100644 index 0000000..347092c --- /dev/null +++ b/scripts/progression/QuestManager.gd @@ -0,0 +1,107 @@ +extends Node + +# Short-term objectives that rotate. Each quest has: +# id (string), desc, kind (break/pearl/depth/craft), target (int), reward_xp (int) +# Player always has up to MAX_ACTIVE quests active; completing one rolls a new one. + +signal quests_updated() +signal quest_completed(quest: Dictionary) + +const MAX_ACTIVE: int = 3 +const QUEST_POOL: Array = [ + {"id": "break_sand", "desc": "Casser 15 blocs de sable", "kind": "break", "item_id": 2, "target": 15, "reward_xp": 40}, + {"id": "break_rock", "desc": "Casser 10 blocs de roche", "kind": "break", "item_id": 3, "target": 10, "reward_xp": 50}, + {"id": "break_kelp", "desc": "Récolter 8 kelp", "kind": "break", "item_id": 6, "target": 8, "reward_xp": 35}, + {"id": "break_coral_r", "desc": "Récolter 5 coraux rouges", "kind": "break", "item_id": 4, "target": 5, "reward_xp": 60}, + {"id": "break_coral_b", "desc": "Récolter 5 coraux bleus", "kind": "break", "item_id": 5, "target": 5, "reward_xp": 60}, + {"id": "find_wreck", "desc": "Fouiller 3 épaves", "kind": "break", "item_id": 7, "target": 3, "reward_xp": 80}, + {"id": "pearl_3", "desc": "Collecter 3 perles", "kind": "pearl", "target": 3, "reward_xp": 70}, + {"id": "pearl_5", "desc": "Collecter 5 perles", "kind": "pearl", "target": 5, "reward_xp": 110}, + {"id": "depth_25", "desc": "Plonger à -25 m", "kind": "depth", "target": 25, "reward_xp": 50}, + {"id": "depth_50", "desc": "Plonger à -50 m", "kind": "depth", "target": 50, "reward_xp": 90}, + {"id": "craft_2", "desc": "Crafter 2 objets", "kind": "craft", "target": 2, "reward_xp": 45}, +] + +var active: Array = [] # each: Dictionary {template_ref, progress} +var _used_ids: Array[String] = [] + + +func _ready() -> void: + _ensure_active_count() + + +func _ensure_active_count() -> void: + while active.size() < MAX_ACTIVE: + var q: Dictionary = _pick_next_quest() + if q.is_empty(): + break + active.append({ + "id": q["id"], + "desc": q["desc"], + "kind": q["kind"], + "item_id": q.get("item_id", -1), + "target": q["target"], + "reward_xp": q["reward_xp"], + "progress": 0, + }) + quests_updated.emit() + + +func _pick_next_quest() -> Dictionary: + var candidates: Array = [] + var active_ids: Array[String] = [] + for q: Dictionary in active: + active_ids.append(q["id"]) + for tpl: Dictionary in QUEST_POOL: + if active_ids.has(tpl["id"]): + continue + candidates.append(tpl) + if candidates.is_empty(): + # all in use; allow recycle + _used_ids.clear() + candidates = QUEST_POOL + return candidates[randi() % candidates.size()] + + +func note_block_break(block_id: int) -> void: + for q: Dictionary in active: + if q["kind"] == "break" and q["item_id"] == block_id: + _progress(q, 1) + + +func note_pearl_collected() -> void: + for q: Dictionary in active: + if q["kind"] == "pearl": + _progress(q, 1) + + +func note_craft() -> void: + for q: Dictionary in active: + if q["kind"] == "craft": + _progress(q, 1) + + +func note_depth(depth_m: int) -> void: + for q: Dictionary in active: + if q["kind"] == "depth" and depth_m >= q["target"]: + q["progress"] = q["target"] + _check_complete(q) + + +func _progress(q: Dictionary, amount: int) -> void: + q["progress"] = mini(q["progress"] + amount, q["target"]) + _check_complete(q) + quests_updated.emit() + + +func _check_complete(q: Dictionary) -> void: + if q["progress"] < q["target"]: + return + # Complete + var snapshot: Dictionary = q.duplicate() + quest_completed.emit(snapshot) + var pp: Node = get_node_or_null("/root/PlayerProgress") + if pp != null: + pp.award(q["reward_xp"], "quête: %s" % q["desc"], Vector3.ZERO) + active.erase(q) + _ensure_active_count() diff --git a/scripts/world/PearlSpawner.gd b/scripts/world/PearlSpawner.gd index a00b6a1..dd4336d 100644 --- a/scripts/world/PearlSpawner.gd +++ b/scripts/world/PearlSpawner.gd @@ -122,3 +122,6 @@ func _on_pearl_collected(item_id: int) -> void: pp.award(pp.XP_PEARL, "perle", player_pos) if main.has_method("_spawn_xp_popup"): main.call("_spawn_xp_popup", pp.XP_PEARL, player_pos + Vector3(0, 1.2, 0)) + var qm: Node = get_node_or_null("/root/QuestManager") + if qm != null: + qm.note_pearl_collected()