diff --git a/project.godot b/project.godot index 0d8e35b..dae3933 100644 --- a/project.godot +++ b/project.godot @@ -98,6 +98,7 @@ BlockDatabase="*res://scripts/world/BlockDatabase.gd" AudioManager="*res://scripts/ambience/AudioManager.gd" ItemDatabase="*res://scripts/inventory/ItemDatabase.gd" NetworkManager="*res://scripts/net/NetworkManager.gd" +PlayerProgress="*res://scripts/progression/PlayerProgress.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 1b0da15..71500ad 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -225,6 +225,7 @@ func _on_block_break(hit_position: Vector3, _normal: Vector3) -> void: if broken_id > 0: inventory.add_item(broken_id, 1) AudioManager.play_bubble_sfx(hit_position) + _award_break_xp(broken_id, hit_position) else: _world_sync.server_break_block(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) +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: var selected: Variant = inventory.get_selected_item() if selected == null: diff --git a/scripts/dolphin/HUD.gd b/scripts/dolphin/HUD.gd index 33682a4..29b79a6 100644 --- a/scripts/dolphin/HUD.gd +++ b/scripts/dolphin/HUD.gd @@ -14,6 +14,12 @@ var _depth_label: Label = null var _biome_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_cached_name: String = "" @@ -23,6 +29,14 @@ func _ready() -> void: _style_bar(_health_bar, Color(0.91, 0.30, 0.24)) _style_bar(_hunger_bar, Color(0.95, 0.61, 0.07)) _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: @@ -72,6 +86,93 @@ func _build_info_panel() -> void: 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: var style := StyleBoxFlat.new() style.bg_color = color @@ -107,7 +208,14 @@ func _on_stats_changed(oxygen: float, hp: float, hunger: float) -> void: _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): return @@ -115,6 +223,11 @@ func _process(_delta: float) -> void: if _depth_label != null: _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: _compass_label.text = _compass_text(_dolphin.rotation.y) diff --git a/scripts/inventory/CraftingRecipes.gd b/scripts/inventory/CraftingRecipes.gd index a0f8b15..76eb35a 100644 --- a/scripts/inventory/CraftingRecipes.gd +++ b/scripts/inventory/CraftingRecipes.gd @@ -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"]) if leftover > 0: push_warning("CraftingRecipes: craft overflow — %d items lost" % leftover) + _award_craft_xp(recipe) 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) diff --git a/scripts/progression/PlayerProgress.gd b/scripts/progression/PlayerProgress.gd new file mode 100644 index 0000000..0652183 --- /dev/null +++ b/scripts/progression/PlayerProgress.gd @@ -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) diff --git a/scripts/progression/XpPopup.gd b/scripts/progression/XpPopup.gd new file mode 100644 index 0000000..d0919fe --- /dev/null +++ b/scripts/progression/XpPopup.gd @@ -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() diff --git a/scripts/world/PearlSpawner.gd b/scripts/world/PearlSpawner.gd index ccb88cf..a00b6a1 100644 --- a/scripts/world/PearlSpawner.gd +++ b/scripts/world/PearlSpawner.gd @@ -112,5 +112,13 @@ func _on_pearl_collected(item_id: int) -> void: return main.inventory.add_item(item_id, 1) 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): - 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))