feat(progression): XP, niveaux et popups flottants (+N XP)

Nouvel autoload PlayerProgress qui gagne de l'XP sur:
- cassage de blocs (gain variable selon le type: perle, épave, coral > sand)
- collecte de perles (+25)
- crafting (+10)
- paliers de profondeur atteints (+20 à 10/25/50/75/100m)

HUD: barre XP + label niveau en bas centre, bannière "NIVEAU X" au level-up
(son bulle + fade). Popup 3D "+N XP" spawn au point d'action.

Boucle court-terme dopamine: chaque action = feedback visuel immédiat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 18:08:26 +00:00
parent 982a4ec23d
commit 984754183f
7 changed files with 260 additions and 2 deletions

View File

@@ -98,6 +98,7 @@ BlockDatabase="*res://scripts/world/BlockDatabase.gd"
AudioManager="*res://scripts/ambience/AudioManager.gd" 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"
[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)

View File

@@ -225,6 +225,7 @@ func _on_block_break(hit_position: Vector3, _normal: Vector3) -> void:
if broken_id > 0: if broken_id > 0:
inventory.add_item(broken_id, 1) inventory.add_item(broken_id, 1)
AudioManager.play_bubble_sfx(hit_position) AudioManager.play_bubble_sfx(hit_position)
_award_break_xp(broken_id, hit_position)
else: else:
_world_sync.server_break_block(hit_position) _world_sync.server_break_block(hit_position)
AudioManager.play_bubble_sfx(hit_position) AudioManager.play_bubble_sfx(hit_position)
@@ -240,6 +241,24 @@ func _on_block_break(hit_position: Vector3, _normal: Vector3) -> void:
burst.emit_burst(hit_position, broken_color) burst.emit_burst(hit_position, broken_color)
func _award_break_xp(block_id: int, hit_position: Vector3) -> void:
var pp: Node = get_node_or_null("/root/PlayerProgress")
if pp == null:
return
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)
func _spawn_xp_popup(amount: int, world_pos: Vector3) -> void:
if amount <= 0:
return
var popup_script: Script = load("res://scripts/progression/XpPopup.gd")
if popup_script == null:
return
popup_script.spawn(self, "+%d XP" % amount, world_pos + Vector3(0, 0.8, 0))
func _on_block_place(hit_position: Vector3, normal: Vector3) -> void: func _on_block_place(hit_position: Vector3, normal: Vector3) -> void:
var selected: Variant = inventory.get_selected_item() var selected: Variant = inventory.get_selected_item()
if selected == null: if selected == null:

View File

@@ -14,6 +14,12 @@ var _depth_label: Label = null
var _biome_label: Label = null var _biome_label: Label = null
var _compass_label: Label = null var _compass_label: Label = null
var _xp_panel: PanelContainer = null
var _xp_bar: ProgressBar = null
var _xp_label: Label = null
var _level_banner: Label = null
var _level_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 = ""
@@ -23,6 +29,14 @@ func _ready() -> void:
_style_bar(_health_bar, Color(0.91, 0.30, 0.24)) _style_bar(_health_bar, Color(0.91, 0.30, 0.24))
_style_bar(_hunger_bar, Color(0.95, 0.61, 0.07)) _style_bar(_hunger_bar, Color(0.95, 0.61, 0.07))
_build_info_panel() _build_info_panel()
_build_xp_panel()
_build_level_banner()
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)
func _build_info_panel() -> void: func _build_info_panel() -> void:
@@ -72,6 +86,93 @@ func _build_info_panel() -> void:
add_child(_info_panel) add_child(_info_panel)
func _build_xp_panel() -> void:
_xp_panel = PanelContainer.new()
_xp_panel.anchor_left = 0.5
_xp_panel.anchor_right = 0.5
_xp_panel.anchor_top = 1.0
_xp_panel.anchor_bottom = 1.0
_xp_panel.offset_left = -180.0
_xp_panel.offset_right = 180.0
_xp_panel.offset_top = -78.0
_xp_panel.offset_bottom = -68.0
var style := StyleBoxFlat.new()
style.bg_color = Color(0.03, 0.05, 0.09, 0.75)
style.set_corner_radius_all(6)
style.border_color = Color(1.0, 0.84, 0.2, 0.55)
style.set_border_width_all(1)
_xp_panel.add_theme_stylebox_override("panel", style)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 0)
_xp_panel.add_child(vbox)
_xp_label = Label.new()
_xp_label.text = "Niv. 1 — 0 / 50"
_xp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_xp_label.add_theme_font_size_override("font_size", 11)
_xp_label.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4))
vbox.add_child(_xp_label)
_xp_bar = ProgressBar.new()
_xp_bar.custom_minimum_size = Vector2(340, 6)
_xp_bar.max_value = 50.0
_xp_bar.value = 0.0
_xp_bar.show_percentage = false
var fill := StyleBoxFlat.new()
fill.bg_color = Color(1.0, 0.82, 0.25)
fill.set_corner_radius_all(3)
_xp_bar.add_theme_stylebox_override("fill", fill)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.08, 0.08, 0.10, 0.8)
bg.set_corner_radius_all(3)
_xp_bar.add_theme_stylebox_override("background", bg)
vbox.add_child(_xp_bar)
add_child(_xp_panel)
func _build_level_banner() -> void:
_level_banner = Label.new()
_level_banner.anchor_left = 0.5
_level_banner.anchor_right = 0.5
_level_banner.anchor_top = 0.25
_level_banner.anchor_bottom = 0.25
_level_banner.offset_left = -200.0
_level_banner.offset_right = 200.0
_level_banner.offset_top = -28.0
_level_banner.offset_bottom = 28.0
_level_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_level_banner.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_level_banner.add_theme_font_size_override("font_size", 40)
_level_banner.add_theme_color_override("font_color", Color(1.0, 0.95, 0.45))
_level_banner.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
_level_banner.add_theme_constant_override("outline_size", 6)
_level_banner.modulate.a = 0.0
add_child(_level_banner)
func _on_xp_changed(current_xp: int, xp_for_next: int, level: int) -> void:
if _xp_bar == null or _xp_label == null:
return
_xp_bar.max_value = float(xp_for_next)
_xp_bar.value = float(current_xp)
_xp_label.text = "Niv. %d%d / %d" % [level, current_xp, xp_for_next]
func _on_level_up(new_level: int) -> void:
if _level_banner == null:
return
_level_banner.text = "⬆ NIVEAU %d" % new_level
_level_banner.modulate.a = 1.0
_level_banner_timer = 2.2
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 _style_bar(bar: ProgressBar, color: Color) -> void: func _style_bar(bar: ProgressBar, color: Color) -> void:
var style := StyleBoxFlat.new() var style := StyleBoxFlat.new()
style.bg_color = color style.bg_color = color
@@ -107,7 +208,14 @@ func _on_stats_changed(oxygen: float, hp: float, hunger: float) -> void:
_hunger_bar.value = hunger _hunger_bar.value = hunger
func _process(_delta: float) -> void: func _process(delta: float) -> void:
if _level_banner_timer > 0.0:
_level_banner_timer -= delta
if _level_banner != null:
var a: float = clampf(_level_banner_timer / 0.6, 0.0, 1.0)
_level_banner.modulate.a = a
_level_banner.scale = Vector2.ONE * (1.0 + (1.0 - a) * 0.2)
if not is_instance_valid(_dolphin): if not is_instance_valid(_dolphin):
return return
@@ -115,6 +223,11 @@ func _process(_delta: float) -> void:
if _depth_label != null: if _depth_label != null:
_depth_label.text = "Prof. %d m" % depth_m _depth_label.text = "Prof. %d m" % depth_m
if depth_m > 0:
var pp: Node = get_node_or_null("/root/PlayerProgress")
if pp != null and pp.has_method("note_depth"):
pp.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)

View File

@@ -52,4 +52,12 @@ static func craft(inventory: Inventory, recipe_index: int) -> bool:
var leftover: int = inventory.add_item(recipe["output"]["item_id"], recipe["output"]["count"]) var leftover: int = inventory.add_item(recipe["output"]["item_id"], recipe["output"]["count"])
if leftover > 0: if leftover > 0:
push_warning("CraftingRecipes: craft overflow — %d items lost" % leftover) push_warning("CraftingRecipes: craft overflow — %d items lost" % leftover)
_award_craft_xp(recipe)
return true return true
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)

View File

@@ -0,0 +1,67 @@
extends Node
signal xp_gained(amount: int, source: String, world_position: Vector3)
signal xp_changed(current_xp: int, xp_for_next: int, level: int)
signal level_up(new_level: int)
const XP_BREAK_DEFAULT: int = 2
const XP_BREAK_BY_BLOCK: Dictionary = {
2: 1, # sand
3: 2, # rock
4: 5, # coral rouge
5: 5, # coral bleu
6: 1, # kelp
7: 8, # epave
8: 3, # glace
}
const XP_PEARL: int = 25
const XP_CRAFT: int = 10
const XP_ECHOLOCATE: int = 3
const XP_KILL_HOSTILE: int = 15
const XP_DEPTH_MILESTONE: int = 20
var current_xp: int = 0
var level: int = 1
var _reached_depths: Array[int] = []
func _ready() -> void:
emit_state()
func xp_for_level(lv: int) -> int:
return 50 + (lv - 1) * 40 + (lv - 1) * (lv - 1) * 10
func xp_for_next() -> int:
return xp_for_level(level)
func award(amount: int, source: String = "", world_pos: Vector3 = Vector3.ZERO) -> void:
if amount <= 0:
return
current_xp += amount
xp_gained.emit(amount, source, world_pos)
while current_xp >= xp_for_next():
current_xp -= xp_for_next()
level += 1
level_up.emit(level)
emit_state()
func award_block_break(block_id: int, world_pos: Vector3) -> void:
var gain: int = XP_BREAK_BY_BLOCK.get(block_id, XP_BREAK_DEFAULT)
award(gain, "bloc", world_pos)
func emit_state() -> void:
xp_changed.emit(current_xp, xp_for_next(), level)
func note_depth(depth_m: int) -> void:
var milestones: Array[int] = [10, 25, 50, 75, 100]
for m: int in milestones:
if depth_m >= m and not _reached_depths.has(m):
_reached_depths.append(m)
award(XP_DEPTH_MILESTONE, "profondeur %dm" % m, Vector3.ZERO)

View File

@@ -0,0 +1,42 @@
extends Node3D
var _label: Label3D = null
var _elapsed: float = 0.0
var _lifetime: float = 1.4
var _drift: float = 1.6
static func spawn(parent: Node, text: String, world_pos: Vector3, color: Color = Color(1.0, 0.9, 0.2)) -> void:
var popup := Node3D.new()
popup.set_script(load("res://scripts/progression/XpPopup.gd"))
parent.add_child(popup)
popup.global_position = world_pos
popup.configure(text, color)
func configure(text: String, color: Color) -> void:
_label = Label3D.new()
_label.text = text
_label.font_size = 54
_label.outline_size = 6
_label.modulate = color
_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_label.no_depth_test = true
_label.render_priority = 4
_label.pixel_size = 0.008
add_child(_label)
func _process(delta: float) -> void:
_elapsed += delta
var t: float = _elapsed / _lifetime
position.y += _drift * delta
if _label != null:
var alpha: float = 1.0 - clampf(t, 0.0, 1.0)
var c: Color = _label.modulate
c.a = alpha
_label.modulate = c
var scale_factor: float = 1.0 + (1.0 - alpha) * 0.4
_label.scale = Vector3.ONE * scale_factor
if _elapsed >= _lifetime:
queue_free()

View File

@@ -112,5 +112,13 @@ func _on_pearl_collected(item_id: int) -> void:
return return
main.inventory.add_item(item_id, 1) main.inventory.add_item(item_id, 1)
var am: Node = get_node_or_null("/root/AudioManager") var am: Node = get_node_or_null("/root/AudioManager")
var player_pos: Vector3 = Vector3.ZERO
if is_instance_valid(_player_node):
player_pos = _player_node.global_position
if am != null and am.has_method("play_bubble_sfx") and is_instance_valid(_player_node): if am != null and am.has_method("play_bubble_sfx") and is_instance_valid(_player_node):
am.call("play_bubble_sfx", _player_node.global_position) am.call("play_bubble_sfx", player_pos)
var pp: Node = get_node_or_null("/root/PlayerProgress")
if pp != null:
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))