extends Node2D ## JuggleBoard Game — reaction & chaining arcade. ## ## Layout: 5 corsie orizzontali, 5 palline colorate a riposo a destra. ## Un bersaglio in alto indica quale pallina premere. Toccata, la pallina ## scivola a sinistra e poi torna a destra. Prima che scada il timeout devi ## premere il nuovo bersaglio per concatenare. Più concateni, più punti fai ## e più la difficoltà (velocità) cresce con il tempo trascorso e il punteggio. ## ## Un nuovo bersaglio appare SOLO se c'è almeno una pallina a riposo. const VIEW := Vector2(1280, 720) const LANE_TOP := 210.0 const LANE_GAP := 98.0 const BALL_RADIUS := 40.0 const HOME_X := 1180.0 # alloggiamento destro (dentro i bordi) const LEFT_X := 100.0 # alloggiamento sinistro (dentro i bordi) const HIT_RADIUS := BALL_RADIUS * 1.4 # area di tocco/clic (maggiorata) # I valori di calibrazione (velocità, moltiplicatori, decay) sono gestiti dal # singleton Settings e modificabili dal menù di configurazione in-game. const COLORS := { "rosso": Color("de6b7e"), # corallo "blu": Color("1852de"), # cobalto "verde": Color("88ce96"), # menta "giallo": Color("e8b84b"), # ambra "viola": Color("9b7ede"), # lavanda } # Note pentatoniche (Do4 Re4 Mi4 Sol4 La4): qualsiasi pallina toccata # produce sempre un suono armonico e piacevole (sensory-friendly). const BALL_NOTES := { "rosso": 261.63, "blu": 293.66, "verde": 329.63, "giallo": 392.00, "viola": 440.00, } 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 elapsed := 0.0 var window_timer := 0.0 var window_time := Settings.start_time var next_milestone := 100 # UI var target_dot: Node2D var score_label: Label var combo_label: Label var help_label: Label var milestone_label: Label var flash: ColorRect # Audio var sound_launch: AudioStreamWAV var sound_error: AudioStreamWAV var sound_timeout: AudioStreamWAV var sound_milestone: AudioStreamWAV var sound_victory: AudioStreamWAV var victory_layer: CanvasLayer var victory_shown := false var ball_sounds := {} # color_name -> AudioStreamWAV (tono pentatonico) var bgm: AudioStreamPlayer var options_menu: CanvasLayer var lock_menu: CanvasLayer func _ready() -> void: add_child(load("res://scripts/board.gd").new()) _build_balls() _build_ui() _init_audio() _setup_bgm() options_menu = load("res://scripts/options_menu.gd").new() add_child(options_menu) lock_menu = load("res://scripts/lock_menu.gd").new() lock_menu.unlocked.connect(_open_options) add_child(lock_menu) victory_layer = load("res://scripts/victory_overlay.gd").new() victory_layer.reset_requested.connect(_reset_after_victory) add_child(victory_layer) _new_game() 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: 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: combo = maxf(0.0, combo - _combo_decay() * delta) window_timer += delta if window_timer >= window_time: _on_timeout() # Input globale: mouse click e touch tap, convertiti in coordinate di gioco. func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: _toggle_options() return var pos := Vector2.INF if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: pos = event.position elif event is InputEventScreenTouch and event.pressed: pos = event.position if pos == Vector2.INF: return _handle_click(pos) func _request_options() -> void: # Le Opzioni sono protette: prima la sequenza di sblocco (per adulti) if lock_menu: lock_menu.show_menu() func _open_options() -> void: if options_menu: options_menu.show_menu() func _toggle_options() -> void: if options_menu and options_menu.visible: options_menu.hide_menu() else: _request_options() # ---------------------------------------------------------------- costruzione func _build_balls() -> void: var names := COLORS.keys() for i in range(names.size()): var cn: String = names[i] var home := Vector2(HOME_X, LANE_TOP + i * LANE_GAP) var left := Vector2(LEFT_X, LANE_TOP + i * LANE_GAP) var b: Area2D = load("res://scripts/ball.gd").new() add_child(b) b.setup(cn, COLORS[cn], home, left, BALL_RADIUS) b.returned.connect(_on_ball_returned) balls[cn] = b func _build_ui() -> void: var layer := CanvasLayer.new() add_child(layer) var display_font: Font = load("res://assets/fonts/NotoSerifDisplay-CondensedExtraBold.ttf") var ink := Color("#5b2f19") var cartouche: Node2D = load("res://scripts/hud_cartouche.gd").new() layer.add_child(cartouche) var tlabel := Label.new() tlabel.text = "BERSAGLIO" tlabel.position = Vector2(94, 44) tlabel.size = Vector2(270, 54) tlabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER tlabel.add_theme_font_override("font", display_font) tlabel.add_theme_font_size_override("font_size", 34) tlabel.add_theme_color_override("font_color", ink) layer.add_child(tlabel) target_dot = load("res://scripts/target_dot.gd").new() target_dot.position = Vector2(430, 78) layer.add_child(target_dot) help_label = Label.new() help_label.text = "Tocca la pallina corrispondente al bersaglio per lanciarla!" help_label.position = Vector2(VIEW.x / 2 - 350, 154) help_label.size = Vector2(700, 30) help_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER help_label.add_theme_font_override("font", display_font) help_label.add_theme_font_size_override("font_size", 17) help_label.add_theme_color_override("font_color", ink) layer.add_child(help_label) score_label = Label.new() score_label.position = Vector2(925, 34) score_label.size = Vector2(245, 48) score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER score_label.add_theme_font_override("font", display_font) score_label.add_theme_font_size_override("font_size", 36) score_label.add_theme_color_override("font_color", ink) layer.add_child(score_label) combo_label = Label.new() combo_label.position = Vector2(925, 80) combo_label.size = Vector2(245, 34) combo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER combo_label.add_theme_font_override("font", display_font) combo_label.add_theme_font_size_override("font_size", 21) combo_label.add_theme_color_override("font_color", ink) layer.add_child(combo_label) # pulsante Opzioni var opts_btn := Button.new() opts_btn.text = "Opzioni" opts_btn.position = Vector2(580, 49) opts_btn.size = Vector2(120, 57) opts_btn.add_theme_font_override("font", display_font) opts_btn.add_theme_font_size_override("font_size", 25) opts_btn.add_theme_color_override("font_color", ink) opts_btn.add_theme_color_override("font_hover_color", Color("#7b421f")) var opt_style := StyleBoxFlat.new() opt_style.bg_color = Color("#ddb983") opt_style.border_color = Color("#9b6738") opt_style.set_border_width_all(3) opt_style.corner_radius_top_left = 9 opt_style.corner_radius_top_right = 9 opt_style.corner_radius_bottom_left = 9 opt_style.corner_radius_bottom_right = 9 opts_btn.add_theme_stylebox_override("normal", opt_style) var opt_hover := opt_style.duplicate() opt_hover.bg_color = Color("#ebcb96") opts_btn.add_theme_stylebox_override("hover", opt_hover) opts_btn.add_theme_stylebox_override("pressed", opt_style) opts_btn.pressed.connect(_request_options) layer.add_child(opts_btn) # feedback celebrativo alle milestone milestone_label = Label.new() milestone_label.position = Vector2(0, 160) milestone_label.size = Vector2(VIEW.x, 120) milestone_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER milestone_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER milestone_label.add_theme_font_size_override("font_size", 56) milestone_label.add_theme_color_override("font_color", Color(0.8, 0.55, 0.1)) milestone_label.visible = false layer.add_child(milestone_label) flash = ColorRect.new() flash.color = Color(1, 1, 0.7) flash.position = Vector2.ZERO flash.size = VIEW flash.mouse_filter = Control.MOUSE_FILTER_IGNORE flash.modulate.a = 0.0 flash.visible = false layer.add_child(flash) # ------------------------------------------------------------------- audio func _init_audio() -> void: # Suoni MORBIDI (sine, inviluppo dolce) per non sovrastimolare i bambini # neurodivergenti: niente onde quadre/sega, volumi attenuati. sound_launch = _create_tone(660.0, 0.18, 0.3) # marimba morbido sound_error = _create_tone(220.0, 0.15, 0.16) # "pop" basso e attenuato sound_timeout = _create_tone(330.0, 0.2, 0.16) # tono dolce 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) # tono pentatonico per ogni pallina colorata for cn in BALL_NOTES: ball_sounds[cn] = _create_tone(BALL_NOTES[cn], 0.2, 0.3) func _setup_bgm() -> void: # Musica di sottofondo da circo: traccia lunga generata via API Lyria, # compressa in OGG Vorbis (loop abilitato nelle impostazioni di import), # con fallback alla versione procedurale se il file non è presente. bgm = AudioStreamPlayer.new() var stream: AudioStream = load("res://audio/circus.ogg") if stream: bgm.stream = stream else: bgm.stream = load("res://scripts/circus_music.gd").generate() bgm.volume_db = -14.0 bgm.process_mode = Node.PROCESS_MODE_ALWAYS # continua anche a menù aperto add_child(bgm) bgm.play() func _create_soft_arpeggio(freqs: Array, note_dur: float = 0.12) -> AudioStreamWAV: var sample_rate := 22050 var total := 0 for f in freqs: total += int(sample_rate * note_dur) var data := PackedByteArray() data.resize(total) var idx := 0 var attack := int(sample_rate * 0.01) for f in freqs: var n := int(sample_rate * note_dur) for i in n: var t := float(i) / sample_rate var env := 1.0 if i < attack: env = float(i) / attack env *= exp(-3.0 * t / note_dur) var s := sin(t * f * TAU) * env var val := int(clampf(s * 0.3 * 127.0, -128.0, 127.0)) data[idx] = val & 0xFF idx += 1 var stream := AudioStreamWAV.new() stream.format = AudioStreamWAV.FORMAT_8_BITS stream.mix_rate = sample_rate stream.data = data return stream func _create_tone(freq: float, duration: float, amp: float = 0.3) -> AudioStreamWAV: # Onda sinusoidale pura con attacco morbido e decadimento esponenziale: # timbro dolce, senza clic né armoniche spigolose. var sample_rate := 22050 var num_samples := int(sample_rate * duration) var data := PackedByteArray() data.resize(num_samples) var attack := int(sample_rate * 0.01) for i in num_samples: var t := float(i) / sample_rate var env := 1.0 if i < attack: env = float(i) / attack env *= exp(-3.0 * t / duration) var s := sin(t * freq * TAU) var val := int(clampf(s * env * amp * 127.0, -128.0, 127.0)) data[i] = val & 0xFF var stream := AudioStreamWAV.new() stream.format = AudioStreamWAV.FORMAT_8_BITS stream.mix_rate = sample_rate stream.data = data return stream func _play_sound(stream: AudioStreamWAV) -> void: if stream == null: return var player := AudioStreamPlayer.new() player.stream = stream add_child(player) player.finished.connect(player.queue_free) player.play() # ------------------------------------------------------------------- gioco func _new_game() -> void: score = 0 combo = 0.0 elapsed = 0.0 window_timer = 0.0 window_time = Settings.start_time next_milestone = 100 for b in balls.values(): b.reset() _pick_target() help_label.visible = true _update_hud() func _handle_click(pos: Vector2) -> void: var clicked: Area2D = null var best := HIT_RADIUS for b in balls.values(): if not b.is_home(): continue var d := pos.distance_to(b.position) if d < best: best = d clicked = b if clicked == null: return # click su spazio vuoto if clicked.color_name == target_color: # corretto: suono pentatonico della pallina, punti, nuovo bersaglio combo = minf(float(Settings.max_combo), combo + 1.0) score += _points_for_combo() _play_sound(ball_sounds.get(clicked.color_name, sound_launch)) var mt := _current_time() clicked.launch(mt) window_time = mt window_timer = 0.0 _pick_target() _update_hud() _check_milestone() _check_victory() else: # tasto sbagliato: suono morbido, penalità gentile (combo -1) o reset _play_sound(sound_error) if Settings.gentle_errors: combo = maxf(0.0, combo - 1.0) else: combo = 0.0 _update_hud() func _on_timeout() -> void: # tempo scaduto: suono dolce, penalità gentile, nuovo bersaglio, nessun game over _play_sound(sound_timeout) if Settings.gentle_errors: combo = maxf(0.0, combo - 1.0) else: combo = 0.0 window_timer = 0.0 _pick_target() _update_hud() func _on_ball_returned() -> void: # quando una pallina torna a casa e non c'è bersaglio, se ne può mostrare uno if target_color == "": _pick_target() func _current_time() -> float: var d := _difficulty() return lerpf(Settings.start_time, Settings.min_time, d) func _difficulty() -> float: # La difficoltà (e quindi la velocità massima) è legata unicamente al punteggio var sf := clampf(score / Settings.score_ramp, 0.0, 1.0) return _smoothstep(sf) func _combo_decay() -> float: # Decadimento proporzionale: quasi nullo a combo basso / inizio partita, # cresce man mano che combo e difficoltà aumentano. var combo_pen := maxf(0.0, combo - Settings.combo_decay_threshold) * Settings.combo_decay_rate var diff_pen := _difficulty() * Settings.difficulty_decay_rate return combo_pen + diff_pen func _points_for_combo() -> int: # Punti per lancio = combo corrente × valore punto configurato. # Es. combo 5 × 1 = 5 pt; combo 1 × 1 = 1 pt. Il valore punto è configurabile. return int(combo) * Settings.points_per_combo func _smoothstep(x: float) -> float: return x * x * (3.0 - 2.0 * x) func _pick_target() -> void: var homes := _home_names() if homes.is_empty(): target_color = "" target_dot.visible = false return var pool := homes.duplicate() if target_color in pool and pool.size() > 1: pool.erase(target_color) target_color = pool[randi() % pool.size()] target_dot.set_color(COLORS[target_color]) target_dot.visible = true func _home_names() -> Array: var arr := [] for cn in balls.keys(): if balls[cn].is_home(): arr.append(cn) return arr func _update_hud() -> void: score_label.text = "PUNTI %d" % score combo_label.text = "Combo x%d" % int(combo) # ---------------------------------------------- feedback alle milestone func _check_victory() -> void: if not victory_shown and score >= Settings.target_score: _on_victory() func _on_victory() -> void: if victory_shown: return victory_shown = true # ferma la musica di sottofondo e riproduce l'audio di vittoria bgm.stop() _play_sound(sound_victory) # congela il gioco e mostra l'overlay di vittoria (animazione inclusa) get_tree().paused = true victory_layer.show_victory(score) func _reset_after_victory() -> void: get_tree().paused = false victory_shown = false victory_layer.hide_victory() bgm.play() _new_game() func _check_milestone() -> void: if score < next_milestone: return # supera più traguardi in una volta: celebra il più alto raggiunto var celebrated := next_milestone while next_milestone <= score: celebrated = next_milestone next_milestone *= 10 _celebrate(celebrated) func _celebrate(amount: int) -> void: _play_sound(sound_milestone) milestone_label.text = "%d PUNTI!" % amount milestone_label.visible = true milestone_label.modulate.a = 1.0 milestone_label.scale = Vector2(0.6, 0.6) flash.visible = true flash.modulate.a = 0.18 var tw := create_tween() tw.set_parallel(true) tw.tween_property(milestone_label, "scale", Vector2(1.3, 1.3), 0.4) \ .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) tw.tween_property(milestone_label, "modulate:a", 0.0, 0.8).set_delay(0.6) tw.tween_property(flash, "modulate:a", 0.0, 0.4) tw.chain().tween_callback(func(): milestone_label.visible = false flash.visible = false )