feat(progression): combo de mining + multiplicateur XP

Nouvel autoload ComboTracker: chaque cassage de bloc dans les 1.8s du
précédent étend le combo (+15% par palier, cap x3.0). Le multiplicateur
s'applique au gain XP de base.

HUD: panneau "COMBO xN (+X%)" animé (pop scale) à partir de x2, avec
barre de temps restant qui se vide. Combo ≥5 au break → bonus XP final
(3 * count) avec popup.

Rétention: incite à chaîner les actions, récompense la dextérité et crée
des pics d'intensité dans la boucle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 18:14:02 +00:00
parent 27459e1eaa
commit 26f9609b53
4 changed files with 146 additions and 1 deletions

View File

@@ -247,7 +247,12 @@ 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)
var base_gain: int = pp.XP_BREAK_BY_BLOCK.get(block_id, pp.XP_BREAK_DEFAULT)
var combo: Node = get_node_or_null("/root/ComboTracker")
var mult: float = 1.0
if combo != null:
mult = combo.bump()
var gain: int = int(round(base_gain * mult))
pp.award(gain, "bloc", hit_position)
_spawn_xp_popup(gain, hit_position)
var qm: Node = get_node_or_null("/root/QuestManager")

View File

@@ -53,9 +53,14 @@ func _ready() -> void:
_on_quests_updated()
_build_toast_container()
_build_consumable_hint()
_build_combo_panel()
var am: Node = get_node_or_null("/root/AchievementManager")
if am != null:
am.achievement_unlocked.connect(_on_achievement_unlocked)
var combo: Node = get_node_or_null("/root/ComboTracker")
if combo != null:
combo.combo_changed.connect(_on_combo_changed)
combo.combo_broken.connect(_on_combo_broken)
func _build_info_panel() -> void:
@@ -242,6 +247,91 @@ func _on_quest_completed(q: Dictionary) -> void:
var _consumable_hint: Label = null
var _combo_panel: PanelContainer = null
var _combo_label: Label = null
var _combo_bar: ProgressBar = null
func _build_combo_panel() -> void:
_combo_panel = PanelContainer.new()
_combo_panel.anchor_left = 0.5
_combo_panel.anchor_right = 0.5
_combo_panel.anchor_top = 0.5
_combo_panel.anchor_bottom = 0.5
_combo_panel.offset_left = 120.0
_combo_panel.offset_right = 320.0
_combo_panel.offset_top = 30.0
_combo_panel.offset_bottom = 80.0
var style := StyleBoxFlat.new()
style.bg_color = Color(0.2, 0.08, 0.05, 0.85)
style.border_color = Color(1.0, 0.55, 0.15)
style.set_border_width_all(2)
style.set_corner_radius_all(8)
_combo_panel.add_theme_stylebox_override("panel", style)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 2)
_combo_panel.add_child(vbox)
_combo_label = Label.new()
_combo_label.text = "COMBO x1"
_combo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_combo_label.add_theme_font_size_override("font_size", 20)
_combo_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3))
_combo_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
_combo_label.add_theme_constant_override("outline_size", 4)
vbox.add_child(_combo_label)
_combo_bar = ProgressBar.new()
_combo_bar.max_value = 1.0
_combo_bar.value = 0.0
_combo_bar.custom_minimum_size = Vector2(180, 4)
_combo_bar.show_percentage = false
var fill := StyleBoxFlat.new()
fill.bg_color = Color(1.0, 0.55, 0.15)
fill.set_corner_radius_all(2)
_combo_bar.add_theme_stylebox_override("fill", fill)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.1, 0.05, 0.03, 0.8)
bg.set_corner_radius_all(2)
_combo_bar.add_theme_stylebox_override("background", bg)
vbox.add_child(_combo_bar)
_combo_panel.visible = false
add_child(_combo_panel)
func _on_combo_changed(cnt: int, mult: float) -> void:
if _combo_panel == null:
return
if cnt < 2:
_combo_panel.visible = false
return
_combo_panel.visible = true
_combo_label.text = "COMBO x%d (+%d%%)" % [cnt, int(round((mult - 1.0) * 100.0))]
_combo_panel.scale = Vector2(1.15, 1.15)
var tween := create_tween()
tween.tween_property(_combo_panel, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_SINE)
func _on_combo_broken(final_count: int) -> void:
if final_count >= 5:
var pp: Node = get_node_or_null("/root/PlayerProgress")
if pp != null:
var bonus: int = final_count * 3
pp.award(bonus, "bonus combo x%d" % final_count, Vector3.ZERO)
var main: Node = get_tree().get_first_node_in_group("main")
if main != null and is_instance_valid(_dolphin) and main.has_method("_spawn_xp_popup"):
main.call("_spawn_xp_popup", bonus, _dolphin.global_position + Vector3(0, 2.0, 0))
func _update_combo_bar() -> void:
var combo: Node = get_node_or_null("/root/ComboTracker")
if combo == null or _combo_bar == null:
return
_combo_bar.value = combo.time_remaining_ratio()
func _build_consumable_hint() -> void:
_consumable_hint = Label.new()
@@ -455,6 +545,7 @@ func _process(delta: float) -> void:
_update_toasts(delta)
_update_consumable_hint()
_update_combo_bar()
if not is_instance_valid(_dolphin):
return

View File

@@ -0,0 +1,48 @@
extends Node
signal combo_changed(count: int, multiplier: float)
signal combo_broken(final_count: int)
const WINDOW_SEC: float = 1.8
const STEP_BONUS: float = 0.15 # +15% per combo step
const MAX_MULT: float = 3.0
var count: int = 0
var multiplier: float = 1.0
var _timer: float = 0.0
var _active: bool = false
func _process(delta: float) -> void:
if not _active:
return
_timer -= delta
if _timer <= 0.0:
_break()
func bump() -> float:
# Advances combo and returns multiplier to apply to the triggering gain.
count += 1
multiplier = minf(1.0 + float(count - 1) * STEP_BONUS, MAX_MULT)
_timer = WINDOW_SEC
_active = true
combo_changed.emit(count, multiplier)
return multiplier
func _break() -> void:
var final_count: int = count
_active = false
combo_changed.emit(0, 1.0)
if final_count >= 2:
combo_broken.emit(final_count)
count = 0
multiplier = 1.0
_timer = 0.0
func time_remaining_ratio() -> float:
if not _active:
return 0.0
return clampf(_timer / WINDOW_SEC, 0.0, 1.0)