feat(progression): quêtes rotatives court-terme + panel HUD

Nouvel autoload QuestManager avec 11 templates (casser X sable, récolter
coraux, collecter perles, plonger à -25m, crafter 2 objets, etc.).
3 quêtes actives simultanément; complétion → récompense XP + roll d'une
nouvelle quête.

HUD: panneau "OBJECTIFS" top-right avec progression couleur (gris→vert),
bannière centrale "✓ QUÊTE" + son bulle au complete.

Motivation moyen-terme (5-15 min): le joueur a toujours qqch à faire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 18:10:01 +00:00
parent 984754183f
commit 610d766cb2
6 changed files with 228 additions and 4 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()