Survival mode: 4 hearts HUD, lose heart on error/timeout, game over, mode selector (Classica/Zen/Survival), hearts vectorized (codex)

This commit is contained in:
enne2
2026-08-18 21:58:35 +02:00
parent 7c5850eb1d
commit 725183290b
18 changed files with 412 additions and 24 deletions
+64
View File
@@ -0,0 +1,64 @@
extends Node2D
## Mostra i cuori della modalità Survival: 4 cuori, pieni o vuoti.
const MAX_LIVES := 4
const HEART_GAP := 44.0
const FILLED_COLOR := Color("#e0566e")
const EMPTY_COLOR := Color("#aaa49a")
const OUTLINE_COLOR := Color("#633725")
var lives := MAX_LIVES
func _ready() -> void:
queue_redraw()
func set_lives(v: int) -> void:
lives = clampi(v, 0, MAX_LIVES)
queue_redraw()
func _draw() -> void:
for i in MAX_LIVES:
var filled := i < lives
_draw_heart(Vector2(i * HEART_GAP, 0.0), FILLED_COLOR if filled else EMPTY_COLOR, filled)
func _draw_heart(origin: Vector2, color: Color, filled: bool) -> void:
var points := _heart_points(origin)
var shadow := PackedVector2Array()
for point in points:
shadow.append(point + Vector2(0.0, 3.0))
draw_colored_polygon(shadow, Color(0.18, 0.10, 0.07, 0.24))
draw_colored_polygon(points, color)
var outline := points.duplicate()
outline.append(points[0])
draw_polyline(outline, OUTLINE_COLOR, 2.5, true)
# Piccolo riflesso da pallina smaltata, coerente con gli altri elementi HUD.
var shine_alpha := 0.72 if filled else 0.38
draw_circle(origin + Vector2(10.5, 9.0), 3.4, Color(1.0, 0.94, 0.91, shine_alpha))
func _heart_points(origin: Vector2) -> PackedVector2Array:
# Quattro curve cubiche formano un cuore pieno di 36x35 px.
var points := PackedVector2Array()
var segments := [
[Vector2(18, 34), Vector2(15, 30), Vector2(2, 22), Vector2(2, 11)],
[Vector2(2, 11), Vector2(2, 3), Vector2(12, 0), Vector2(18, 7)],
[Vector2(18, 7), Vector2(24, 0), Vector2(34, 3), Vector2(34, 11)],
[Vector2(34, 11), Vector2(34, 22), Vector2(21, 30), Vector2(18, 34)],
]
for segment in segments:
for step in range(8):
var t := float(step) / 8.0
points.append(origin + _cubic(segment[0], segment[1], segment[2], segment[3], t))
points.append(origin + Vector2(18, 34))
return points
func _cubic(a: Vector2, b: Vector2, c: Vector2, d: Vector2, t: float) -> Vector2:
var u := 1.0 - t
return u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d
+1
View File
@@ -0,0 +1 @@
uid://x0ialp40fqlk
+47 -1
View File
@@ -43,6 +43,7 @@ var balls := {} # color_name -> Ball
var target_color := "" # bersaglio corrente (vuoto = nessun bersaglio)
var score := 0
var combo := 0.0 # float: decade col ritardo di reazione
var lives := 4 # cuori (modalità Survival)
var elapsed := 0.0
var window_timer := 0.0
var window_time := Settings.start_time
@@ -62,6 +63,9 @@ var sound_error: AudioStreamWAV
var sound_timeout: AudioStreamWAV
var sound_milestone: AudioStreamWAV
var sound_victory: AudioStreamWAV
var sound_defeat: AudioStreamWAV
var sound_lose_heart: AudioStreamWAV
var hearts: Node2D
var victory_layer: CanvasLayer
var victory_shown := false
var ball_sounds := {} # color_name -> AudioStreamWAV (tono pentatonico)
@@ -91,7 +95,7 @@ func _process(delta: float) -> void:
elapsed += delta
if target_color == "":
return # in attesa che una pallina torni a casa: nessun countdown
if Settings.zen_mode:
if Settings.game_mode == 1: # modalità Zen: nessun countdown né decay
return # modalità zen: nessun countdown né decay (niente pressione)
# il combo decade in proporzione al tempo che aspetto prima di cliccare
if combo > 0.0:
@@ -199,6 +203,13 @@ func _build_ui() -> void:
combo_label.add_theme_color_override("font_color", ink)
layer.add_child(combo_label)
# cuori della modalità Survival (visibili solo in quella modalità)
hearts = load("res://scripts/hearts_display.gd").new()
# Fascia libera sotto BERSAGLIO: resta lontana da target, Opzioni e combo.
hearts.position = Vector2(121, 99)
hearts.visible = false
layer.add_child(hearts)
# pulsante Opzioni
var opts_btn := Button.new()
opts_btn.text = "Opzioni"
@@ -256,6 +267,9 @@ func _init_audio() -> void:
sound_milestone = _create_soft_arpeggio([523.25, 659.25, 783.99, 1046.5], 0.12)
# audio di vittoria: arpeggio ascendente luminoso ma morbido (sensory-friendly)
sound_victory = _create_soft_arpeggio([523.25, 659.25, 783.99, 1046.5, 1318.51, 1567.98], 0.16)
# audio di sconfitta (discendente, morbido) e di cuore perso
sound_defeat = _create_soft_arpeggio([392.0, 329.63, 261.63], 0.18)
sound_lose_heart = _create_tone(330.0, 0.15, 0.2)
# tono pentatonico per ogni pallina colorata
for cn in BALL_NOTES:
ball_sounds[cn] = _create_tone(BALL_NOTES[cn], 0.2, 0.3)
@@ -348,6 +362,10 @@ func _new_game() -> void:
window_timer = 0.0
window_time = Settings.start_time
next_milestone = 100
lives = 4
if hearts:
hearts.set_lives(lives)
hearts.visible = Settings.game_mode == 2
for b in balls.values():
b.reset()
_pick_target()
@@ -389,6 +407,8 @@ func _handle_click(pos: Vector2) -> void:
else:
combo = 0.0
_update_hud()
if Settings.game_mode == 2:
_lose_life() # survival: l'errore costa un cuore
func _on_timeout() -> void:
@@ -401,6 +421,8 @@ func _on_timeout() -> void:
window_timer = 0.0
_pick_target()
_update_hud()
if Settings.game_mode == 2:
_lose_life() # survival: il timeout costa un cuore
func _on_ball_returned() -> void:
@@ -468,10 +490,34 @@ func _update_hud() -> void:
# ---------------------------------------------- feedback alle milestone
func _check_victory() -> void:
if Settings.game_mode == 2:
return # survival: endless, ignora il target punteggio
if not victory_shown and score >= Settings.target_score:
_on_victory()
func _lose_life() -> void:
if victory_shown:
return
lives -= 1
_play_sound(sound_lose_heart)
if hearts:
hearts.set_lives(maxi(lives, 0))
if lives <= 0:
_on_game_over()
func _on_game_over() -> void:
if victory_shown:
return
victory_shown = true
# ferma la musica e riproduce l'audio di sconfitta
bgm.stop()
_play_sound(sound_defeat)
get_tree().paused = true
victory_layer.show_game_over(score)
func _on_victory() -> void:
if victory_shown:
return
+32 -11
View File
@@ -4,8 +4,9 @@ extends CanvasLayer
## scena applicando i nuovi valori.
var rows := {} # key -> {slider, spin}
var zen_cb: CheckButton
var mode_option: OptionButton
var gentle_cb: CheckButton
var survival_cb: CheckButton
func _ready() -> void:
@@ -62,13 +63,33 @@ func _build() -> void:
for e in entries:
rows[e[0]] = _build_row(vb, e[0], e[1], e[2], e[3], e[4])
# opzioni accessibilità / neurodivergenza
zen_cb = CheckButton.new()
zen_cb.text = "Modalità Zen (senza tempo)"
zen_cb.button_pressed = Settings.zen_mode
zen_cb.toggled.connect(func(on: bool): Settings.zen_mode = on)
vb.add_child(zen_cb)
# modalità di gioco (mutuamente esclusive)
var mode_separator := HSeparator.new()
mode_separator.add_theme_constant_override("separation", 5)
vb.add_child(mode_separator)
var mrow := HBoxContainer.new()
mrow.custom_minimum_size.y = 44
mrow.add_theme_constant_override("separation", 12)
mrow.alignment = BoxContainer.ALIGNMENT_CENTER
vb.add_child(mrow)
var mlbl := Label.new()
mlbl.text = "Modalità di gioco:"
mlbl.custom_minimum_size.x = 230
mlbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
mlbl.add_theme_font_size_override("font_size", 18)
mrow.add_child(mlbl)
mode_option = OptionButton.new()
mode_option.custom_minimum_size.x = 300
mode_option.custom_minimum_size.y = 40
mode_option.add_theme_font_size_override("font_size", 17)
mode_option.add_item("Classica (a punteggio)")
mode_option.add_item("Zen (senza tempo)")
mode_option.add_item("Survival (a vite)")
mode_option.select(Settings.game_mode)
mode_option.item_selected.connect(func(idx: int): Settings.game_mode = idx)
mrow.add_child(mode_option)
# opzioni accessibilità / neurodivergenza
gentle_cb = CheckButton.new()
gentle_cb.text = "Errori gentili (combo -1)"
gentle_cb.button_pressed = Settings.gentle_errors
@@ -137,8 +158,8 @@ func show_menu() -> void:
var val: float = Settings.get(key)
rows[key].slider.value = val
rows[key].spin.value = val
if zen_cb:
zen_cb.button_pressed = Settings.zen_mode
if mode_option:
mode_option.select(Settings.game_mode)
if gentle_cb:
gentle_cb.button_pressed = Settings.gentle_errors
visible = true
@@ -162,8 +183,8 @@ func _on_reset() -> void:
var val: float = Settings.get(key)
rows[key].slider.value = val
rows[key].spin.value = val
if zen_cb:
zen_cb.button_pressed = Settings.zen_mode
if mode_option:
mode_option.select(Settings.game_mode)
if gentle_cb:
gentle_cb.button_pressed = Settings.gentle_errors
+6 -5
View File
@@ -4,7 +4,7 @@ extends Node
## al riavvio della scena.
const PATH := "user://settings.cfg"
const VERSION := 5 # incrementa quando cambiano i default, per resettare i file vecchi
const VERSION := 6 # incrementa quando cambiano i default, per resettare i file vecchi
var version := VERSION
var start_time := 4.5 # tempo iniziale (s) tra un ordine e l'altro
@@ -16,9 +16,10 @@ var max_combo := 5.0 # combo massimo di default (configurabile)
var combo_decay_threshold := 0.0 # combo sopra cui inizia il decay (0 = da subito)
var combo_decay_rate := 0.15 # combo persi al secondo per unità sopra la soglia
var difficulty_decay_rate := 0.3 # decay aggiuntivo legato alla difficoltà
# Modalità di gioco (mutuamente esclusive): 0=Classica (a punteggio), 1=Zen, 2=Survival
var game_mode := 0
# Accessibilità / neurodivergenza
var gentle_errors := true # errore: combo -1 invece di azzerare
var zen_mode := false # modalità senza tempo (niente countdown né decay)
func _ready() -> void:
@@ -40,12 +41,12 @@ func load_settings() -> void:
score_ramp = cfg.get_value("gameplay", "score_ramp", score_ramp)
points_per_combo = cfg.get_value("gameplay", "points_per_combo", points_per_combo)
target_score = cfg.get_value("gameplay", "target_score", target_score)
game_mode = cfg.get_value("gameplay", "game_mode", game_mode)
max_combo = cfg.get_value("gameplay", "max_combo", max_combo)
combo_decay_threshold = cfg.get_value("gameplay", "combo_decay_threshold", combo_decay_threshold)
combo_decay_rate = cfg.get_value("gameplay", "combo_decay_rate", combo_decay_rate)
difficulty_decay_rate = cfg.get_value("gameplay", "difficulty_decay_rate", difficulty_decay_rate)
gentle_errors = cfg.get_value("accessibility", "gentle_errors", gentle_errors)
zen_mode = cfg.get_value("accessibility", "zen_mode", zen_mode)
func save_settings() -> void:
@@ -56,12 +57,12 @@ func save_settings() -> void:
cfg.set_value("gameplay", "score_ramp", score_ramp)
cfg.set_value("gameplay", "points_per_combo", points_per_combo)
cfg.set_value("gameplay", "target_score", target_score)
cfg.set_value("gameplay", "game_mode", game_mode)
cfg.set_value("gameplay", "max_combo", max_combo)
cfg.set_value("gameplay", "combo_decay_threshold", combo_decay_threshold)
cfg.set_value("gameplay", "combo_decay_rate", combo_decay_rate)
cfg.set_value("gameplay", "difficulty_decay_rate", difficulty_decay_rate)
cfg.set_value("accessibility", "gentle_errors", gentle_errors)
cfg.set_value("accessibility", "zen_mode", zen_mode)
cfg.save(PATH)
@@ -71,9 +72,9 @@ func reset_defaults() -> void:
score_ramp = 8000.0
points_per_combo = 1
target_score = 1000
game_mode = 0
max_combo = 5.0
combo_decay_threshold = 0.0
combo_decay_rate = 0.15
difficulty_decay_rate = 0.3
gentle_errors = true
zen_mode = false
+22 -7
View File
@@ -11,6 +11,7 @@ const BRASS_LIGHT := Color("#f6d77a")
var title_label: Label
var score_label: Label
var subtitle_label: Label
var _card: Control
var _reset_button: Button
@@ -132,13 +133,13 @@ func _build() -> void:
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vb.add_child(title_label)
var subtitle := Label.new()
subtitle.text = "Hai raggiunto il punteggio target!"
subtitle.add_theme_font_override("font", DISPLAY_FONT)
subtitle.add_theme_font_size_override("font_size", 23)
subtitle.add_theme_color_override("font_color", INK)
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vb.add_child(subtitle)
subtitle_label = Label.new()
subtitle_label.text = "Hai raggiunto il punteggio target!"
subtitle_label.add_theme_font_override("font", DISPLAY_FONT)
subtitle_label.add_theme_font_size_override("font_size", 23)
subtitle_label.add_theme_color_override("font_color", INK)
subtitle_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vb.add_child(subtitle_label)
var score_plaque := PanelContainer.new()
score_plaque.add_theme_stylebox_override("panel", _flat_style(Color("#f7e6bc"), Color("#9d6b29"), 3, 14))
@@ -180,6 +181,20 @@ func _button_style(color: Color, border: Color, width: int) -> StyleBoxFlat:
func show_victory(score: int) -> void:
show_end("victory", score)
func show_game_over(score: int) -> void:
show_end("gameover", score)
func show_end(kind: String, score: int) -> void:
var victory := kind == "victory"
title_label.text = "VITTORIA!" if victory else "GAME OVER"
title_label.add_theme_color_override("font_color",
Color("#ffe08a") if victory else Color("#f0b4b4"))
subtitle_label.text = "Hai raggiunto il punteggio target!" if victory else \
"Hai perso tutti i cuori... Ritenta!"
score_label.text = "Punteggio: %d" % score
visible = true
_card.modulate.a = 0.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://pnxvvowelvxa"
path="res://.godot/imported/01-base.png-8f76b0be0bab160c3f472a5c3cba153e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://verification/01-base.png"
dest_files=["res://.godot/imported/01-base.png-8f76b0be0bab160c3f472a5c3cba153e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b1gnf3fktq15"
path="res://.godot/imported/02-lock-dialog.png-e9cae089c54853c79a38ae73ee57ed72.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://verification/02-lock-dialog.png"
dest_files=["res://.godot/imported/02-lock-dialog.png-e9cae089c54853c79a38ae73ee57ed72.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://do6rvjgk4sver"
path="res://.godot/imported/03-options-unlocked.png-c5d4f59fb45c67c40634d4412ef46b95.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://verification/03-options-unlocked.png"
dest_files=["res://.godot/imported/03-options-unlocked.png-c5d4f59fb45c67c40634d4412ef46b95.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cu6j5b4hod33b"
path="res://.godot/imported/04-survival-hearts.png-182ece3ef37b8bcc11c34ba3305bf365.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://verification/04-survival-hearts.png"
dest_files=["res://.godot/imported/04-survival-hearts.png-182ece3ef37b8bcc11c34ba3305bf365.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2rdadbuks4x1"
path="res://.godot/imported/05-options-mode-selector.png-ede8e28430a9cba46e2d636801e4b77e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://verification/05-options-mode-selector.png"
dest_files=["res://.godot/imported/05-options-mode-selector.png-ede8e28430a9cba46e2d636801e4b77e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cy7tdroiuiyox"
path="res://.godot/imported/check.png-986079f3804b54058bf1eacd42e341a6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://verification/check.png"
dest_files=["res://.godot/imported/check.png-986079f3804b54058bf1eacd42e341a6.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1