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:
@@ -99,6 +99,7 @@ AudioManager="*res://scripts/ambience/AudioManager.gd"
|
|||||||
ItemDatabase="*res://scripts/inventory/ItemDatabase.gd"
|
ItemDatabase="*res://scripts/inventory/ItemDatabase.gd"
|
||||||
NetworkManager="*res://scripts/net/NetworkManager.gd"
|
NetworkManager="*res://scripts/net/NetworkManager.gd"
|
||||||
PlayerProgress="*res://scripts/progression/PlayerProgress.gd"
|
PlayerProgress="*res://scripts/progression/PlayerProgress.gd"
|
||||||
|
QuestManager="*res://scripts/progression/QuestManager.gd"
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
environment/defaults/default_clear_color=Color(0.05, 0.15, 0.25, 1)
|
environment/defaults/default_clear_color=Color(0.05, 0.15, 0.25, 1)
|
||||||
|
|||||||
@@ -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)
|
var gain: int = pp.XP_BREAK_BY_BLOCK.get(block_id, pp.XP_BREAK_DEFAULT)
|
||||||
pp.award(gain, "bloc", hit_position)
|
pp.award(gain, "bloc", hit_position)
|
||||||
_spawn_xp_popup(gain, 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:
|
func _spawn_xp_popup(amount: int, world_pos: Vector3) -> void:
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ var _xp_label: Label = null
|
|||||||
var _level_banner: Label = null
|
var _level_banner: Label = null
|
||||||
var _level_banner_timer: float = 0.0
|
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_cache_pos: Vector3 = Vector3(99999, 99999, 99999)
|
||||||
var _biome_cached_name: String = ""
|
var _biome_cached_name: String = ""
|
||||||
|
|
||||||
@@ -31,12 +36,18 @@ func _ready() -> void:
|
|||||||
_build_info_panel()
|
_build_info_panel()
|
||||||
_build_xp_panel()
|
_build_xp_panel()
|
||||||
_build_level_banner()
|
_build_level_banner()
|
||||||
|
_build_quest_panel()
|
||||||
if Engine.has_singleton("PlayerProgress") or has_node("/root/PlayerProgress"):
|
if Engine.has_singleton("PlayerProgress") or has_node("/root/PlayerProgress"):
|
||||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||||
if pp != null:
|
if pp != null:
|
||||||
pp.xp_changed.connect(_on_xp_changed)
|
pp.xp_changed.connect(_on_xp_changed)
|
||||||
pp.level_up.connect(_on_level_up)
|
pp.level_up.connect(_on_level_up)
|
||||||
_on_xp_changed(pp.current_xp, pp.xp_for_next(), pp.level)
|
_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:
|
func _build_info_panel() -> void:
|
||||||
@@ -133,6 +144,94 @@ func _build_xp_panel() -> void:
|
|||||||
add_child(_xp_panel)
|
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:
|
func _build_level_banner() -> void:
|
||||||
_level_banner = Label.new()
|
_level_banner = Label.new()
|
||||||
_level_banner.anchor_left = 0.5
|
_level_banner.anchor_left = 0.5
|
||||||
@@ -216,6 +315,11 @@ func _process(delta: float) -> void:
|
|||||||
_level_banner.modulate.a = a
|
_level_banner.modulate.a = a
|
||||||
_level_banner.scale = Vector2.ONE * (1.0 + (1.0 - a) * 0.2)
|
_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):
|
if not is_instance_valid(_dolphin):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -227,6 +331,9 @@ func _process(delta: float) -> void:
|
|||||||
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
||||||
if pp != null and pp.has_method("note_depth"):
|
if pp != null and pp.has_method("note_depth"):
|
||||||
pp.call("note_depth", depth_m)
|
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:
|
if _compass_label != null:
|
||||||
_compass_label.text = _compass_text(_dolphin.rotation.y)
|
_compass_label.text = _compass_text(_dolphin.rotation.y)
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ static func craft(inventory: Inventory, recipe_index: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
static func _award_craft_xp(recipe: Dictionary) -> void:
|
static func _award_craft_xp(recipe: Dictionary) -> void:
|
||||||
var pp: Node = Engine.get_main_loop().root.get_node_or_null("/root/PlayerProgress")
|
var root: Node = Engine.get_main_loop().root
|
||||||
if pp == null:
|
var pp: Node = root.get_node_or_null("/root/PlayerProgress")
|
||||||
return
|
if pp != null:
|
||||||
pp.award(pp.XP_CRAFT, "craft %s" % str(recipe.get("name", "")), Vector3.ZERO)
|
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()
|
||||||
|
|||||||
107
scripts/progression/QuestManager.gd
Normal file
107
scripts/progression/QuestManager.gd
Normal 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()
|
||||||
@@ -122,3 +122,6 @@ func _on_pearl_collected(item_id: int) -> void:
|
|||||||
pp.award(pp.XP_PEARL, "perle", player_pos)
|
pp.award(pp.XP_PEARL, "perle", player_pos)
|
||||||
if main.has_method("_spawn_xp_popup"):
|
if main.has_method("_spawn_xp_popup"):
|
||||||
main.call("_spawn_xp_popup", pp.XP_PEARL, player_pos + Vector3(0, 1.2, 0))
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user