commit 7a2a00aa2122047c89bd2d5eb726d2dab5be05b1 Author: enne2 Date: Mon Aug 10 18:38:45 2026 +0200 JuggleBoard game v0.1: Godot 4.7 arcade, combo decay, config menu, circus BGM diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3269c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Godot editor cache +.godot/ + +# Build output (web export) +build/ + +# Os temporanei +*.tmp +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..63ed3bd --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# JuggleBoard Game + +Un gioco arcade **reaction & chaining** per dispositivi mobili (orizzontale), realizzato in **Godot 4.7** (GDScript), ispirato al prodotto riabilitativo "Juggle Board". + +## Gameplay +- **5 corsie orizzontali**, ognuna con una **pallina colorata** a riposo sul lato destro. +- Un **bersaglio in alto** indica quale pallina toccare. +- Toccando la pallina giusta: scivola a sinistra e poi torna a destra, con un **suono di conferma**. +- Un nuovo bersaglio appare **solo se c'è almeno una pallina a riposo**. +- Devi concatenare i lanci **prima che scada il timeout**: più concateni, più punti (combo). +- **Difficoltà crescente**: il tempo parte molto disteso (~4,5s) e **si riduce** con il tempo trascorso e il punteggio. +- **Tap sbagliato** → suono di errore e reset combo (nessun game over). +- **Combo con decadimento**: il combo cresce con i tap rapidi ma **si riduce pian piano** in proporzione al tempo che aspetti prima di cliccare la pallina bersaglio (rate configurabile `COMBO_DECAY`). +- **Timeout scaduto** → suono dedicato, reset combo, nuovo bersaglio (nessun game over). +- I suoni (lancio, errore, timeout) sono generati proceduralmente in 8-bit. + +## Requisiti +- Godot 4.x (sviluppato e testato con 4.7.1, renderer GL Compatibility). + +## Eseguire +```bash +# dal percorso del progetto +godot --path . +``` +Oppure apri il progetto con l'editor Godot (`godot -e .`) e premi **Play** (F5). + +## Controlli +- **Desktop**: click del mouse sulla pallina. +- **Mobile**: tocco sullo schermo. + +## Struttura +``` +project.godot # configurazione (viewport 1280x720, landscape, GL Compatibility) +icon.svg +scenes/ + main.tscn # scena principale (radice Node2D + script) +scripts/ + main.gd # logica di gioco, HUD, punteggio, difficoltà + ball.gd # pallina: Area2D, scivolata sinistra↔destra, input + target_dot.gd # indicatore del colore bersaglio +``` + +## Personalizzare la difficoltà +In `scripts/main.gd` (sezione "Tuning difficoltà"): +- `START_TIME` → tempo iniziale tra un ordine e l'altro (s). Più alto = partenza più distesa. +- `MIN_TIME` → tempo minimo (velocità massima) raggiunto a difficoltà piena. +- `TIME_RAMP` → secondi di gioco per raggiungere la difficoltà piena. +- `SCORE_RAMP` → punteggio per raggiungere la difficoltà piena. + +La difficoltà cresce col massimo tra il fattore-tempo e il fattore-punteggio. + +## Esportazione mobile +Installa dal Project Manager il template **Android** (o iOS su macOS), poi +*Project → Export* con preset `Android` / `Web`. diff --git a/audio/circus.ogg b/audio/circus.ogg new file mode 100644 index 0000000..e245373 Binary files /dev/null and b/audio/circus.ogg differ diff --git a/audio/circus.ogg.import b/audio/circus.ogg.import new file mode 100644 index 0000000..8551015 --- /dev/null +++ b/audio/circus.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://b2gfccy54hwyd" +path="res://.godot/imported/circus.ogg-5b91588fe544e7aa78021ac22d7fcfc9.oggvorbisstr" + +[deps] + +source_file="res://audio/circus.ogg" +dest_files=["res://.godot/imported/circus.ogg-5b91588fe544e7aa78021ac22d7fcfc9.oggvorbisstr"] + +[params] + +loop=true +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..4794574 --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,42 @@ +[preset.0] + +name="Web" +platform="Web" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/web/index.html" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false +html/export_icon=true +html/custom_html_shell="" +html/head_include="" +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +html/experimental_virtual_keyboard=false +progressive_web_app/enabled=false +progressive_web_app/offline_page="" +progressive_web_app/display=1 +progressive_web_app/orientation=0 +progressive_web_app/icon_144x144="" +progressive_web_app/icon_180x180="" +progressive_web_app/icon_512x512="" +progressive_web_app/background_color=Color(0, 0, 0, 1) diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..22d5adf --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..a907c99 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d1bxk6k1q1xn8" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.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 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..6312e86 --- /dev/null +++ b/project.godot @@ -0,0 +1,32 @@ +; Engine configuration file. +config_version=5 + +[application] + +config/name="JuggleBoard" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") +config/icon="res://icon.svg" + +[autoload] + +Settings="*res://scripts/settings.gd" + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 +window/size/resizable=false +window/stretch/mode="canvas_items" +window/stretch/aspect="keep" +window/handheld/orientation=4 + +[input_devices] + +pointing/emulate_touch_from_mouse=true +pointing/emulate_mouse_from_touch=true + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..4cc05c7 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://juggleboard_main"] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") diff --git a/scripts/ball.gd b/scripts/ball.gd new file mode 100644 index 0000000..ea1a57b --- /dev/null +++ b/scripts/ball.gd @@ -0,0 +1,74 @@ +extends Area2D + +## Una singola pallina nella sua corsia. Partendo da destra, se lanciata +## scivola verso il bordo sinistro e poi torna alla posizione di partenza. +## Il rilevamento del tocco/click è gestito centralmente da main.gd tramite +## distanza: qui NON ci sono handler di input dedicati. + +signal returned # emesso quando la pallina torna a casa + + +var color_name := "" +var ball_color := Color.WHITE +var radius := 40.0 +var home_pos := Vector2.ZERO +var left_pos := Vector2.ZERO +var in_motion := false +var _tween: Tween + + +func _draw() -> void: + # ombra + draw_circle(Vector2(0, 5), radius, Color(0, 0, 0, 0.3)) + # corpo + draw_circle(Vector2.ZERO, radius, ball_color) + # bordo + draw_arc(Vector2.ZERO, radius, 0.0, TAU, 48, Color(0, 0, 0, 0.45), 3.0) + # riflesso + draw_circle(Vector2(-radius * 0.28, -radius * 0.32), radius * 0.18, Color(1, 1, 1, 0.55)) + + +func setup(cn: String, c: Color, home: Vector2, left: Vector2, r: float) -> void: + color_name = cn + ball_color = c + radius = r + home_pos = home + left_pos = left + position = home + var shape := CollisionShape2D.new() + var cs := CircleShape2D.new() + cs.radius = r * 1.35 # area di tocco ampliata (ma l'input è via distanza in main) + shape.shape = cs + add_child(shape) + queue_redraw() + + +func is_home() -> bool: + return not in_motion + + +func reset() -> void: + if _tween and _tween.is_valid(): + _tween.kill() + in_motion = false + position = home_pos + + +## Lancia la pallina verso sinistra e la fa tornare a casa. `mt` è il tempo +## totale (andata + ritorno) in secondi. +func launch(mt: float) -> void: + in_motion = true + if _tween and _tween.is_valid(): + _tween.kill() + var half := maxf(mt * 0.5, 0.01) + _tween = create_tween() + _tween.tween_property(self, "position", left_pos, half) \ + .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT) + _tween.tween_property(self, "position", home_pos, half) \ + .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT) + _tween.finished.connect(_on_motion_done) + + +func _on_motion_done() -> void: + in_motion = false + returned.emit() diff --git a/scripts/ball.gd.uid b/scripts/ball.gd.uid new file mode 100644 index 0000000..daf5a80 --- /dev/null +++ b/scripts/ball.gd.uid @@ -0,0 +1 @@ +uid://d3kyc2ecf0aco diff --git a/scripts/circus_music.gd b/scripts/circus_music.gd new file mode 100644 index 0000000..44f8599 --- /dev/null +++ b/scripts/circus_music.gd @@ -0,0 +1,138 @@ +extends RefCounted +## Genera proceduralmente un loop musicale da circo (valzer calliope) in WAV. +## Nessun file audio esterno: tutto sintetizzato a runtime, con loop seamless. + +const SR := 22050 + + +static func generate() -> AudioStreamWAV: + var bpm := 132.0 + var beat := 60.0 / bpm + var bars := 8 + var total_beats := bars * 3 + var total_seconds := total_beats * beat + var n := int(SR * total_seconds) + var mix := PackedFloat32Array() + mix.resize(n) + + # Melodia (calliope) — 8 battute in 3/4 + var melody := [ + [72, 1.0], [76, 1.0], [79, 1.0], + [72, 1.0], [76, 1.0], [79, 1.0], + [69, 1.0], [72, 1.0], [76, 1.0], + [69, 1.0], [72, 1.0], [76, 1.0], + [65, 1.0], [69, 1.0], [72, 1.0], + [65, 1.0], [69, 1.0], [72, 1.0], + [67, 1.0], [71, 1.0], [74, 1.0], + [72, 2.0], [0, 1.0], + ] + # Accompagnamento oom-pah-pah: [basso, [accordo]] + var chords := [ + [48, [60, 64, 67]], + [48, [60, 64, 67]], + [45, [57, 60, 64]], + [45, [57, 60, 64]], + [41, [53, 57, 60]], + [41, [53, 57, 60]], + [43, [55, 59, 62]], + [48, [60, 64, 67]], + ] + + # Melodia + var t := 0.0 + for note in melody: + var midi: int = note[0] + var beats: float = note[1] + var dur := beats * beat + if midi > 0: + _add_note(mix, t, dur, _freq(midi), 0.5, "calliope") + t += dur + + # Accompagnamento + t = 0.0 + for bar in bars: + var bass: int = chords[bar][0] + var chord: Array = chords[bar][1] + _add_note(mix, t, beat * 0.9, _freq(bass), 0.42, "bass") + for b in [1, 2]: + var ct: float = t + b * beat + for m in chord: + _add_note(mix, ct, beat * 0.8, _freq(int(m)), 0.16, "chord") + t += 3.0 * beat + + # Fade in/out (30ms) per loop seamless senza click + var fade := int(SR * 0.03) + for i in fade: + var g := float(i) / fade + mix[i] *= g + mix[n - 1 - i] *= g + + # Normalizzazione + var peak := 0.0 + for i in n: + var a := absf(mix[i]) + if a > peak: + peak = a + var norm := 1.0 + if peak > 0.0: + norm = 0.9 / peak + + var data := PackedByteArray() + data.resize(n) + for i in n: + var v := int(clampf(mix[i] * norm * 127.0, -128.0, 127.0)) + data[i] = v & 0xFF + + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_8_BITS + stream.mix_rate = SR + stream.data = data + stream.loop_mode = AudioStreamWAV.LOOP_FORWARD + stream.loop_begin = 0 + stream.loop_end = n + return stream + + +static func _freq(midi: int) -> float: + return 440.0 * pow(2.0, (midi - 69) / 12.0) + + +static func _square(t: float, f: float) -> float: + return 1.0 if sin(t * f * TAU) > 0.0 else -1.0 + + +static func _saw(t: float, f: float) -> float: + return fmod(t * f, 1.0) * 2.0 - 1.0 + + +static func _envelope(t: float, dur: float) -> float: + var a := 0.02 + var r := 0.06 + var e := 1.0 + if t < a: + e = t / a + var rem := dur - t + if rem < r: + e = minf(e, rem / r) + return e + + +static func _add_note(mix: PackedFloat32Array, start: float, dur: float, freq: float, amp: float, kind: String) -> void: + var start_i := int(start * SR) + var cnt := int(dur * SR) + for i in cnt: + var idx := start_i + i + if idx < 0 or idx >= mix.size(): + break + var t := float(i) / SR + var env := _envelope(t, dur) + var s := 0.0 + if kind == "calliope": + var vib := 1.0 + 0.006 * sin(t * 2.0 * PI * 5.0) + var f := freq * vib + s = 0.5 * _square(t, f) + 0.3 * _saw(t, f * 1.004) + 0.2 * _square(t, f * 0.996) + elif kind == "bass": + s = 0.6 * _saw(t, freq) + 0.4 * _square(t, freq) + elif kind == "chord": + s = 0.5 * _square(t, freq) + 0.5 * _saw(t, freq) + mix[idx] += s * env * amp diff --git a/scripts/circus_music.gd.uid b/scripts/circus_music.gd.uid new file mode 100644 index 0000000..cb05ebf --- /dev/null +++ b/scripts/circus_music.gd.uid @@ -0,0 +1 @@ +uid://xlg6u5nv5rue diff --git a/scripts/main.gd b/scripts/main.gd new file mode 100644 index 0000000..b64c53f --- /dev/null +++ b/scripts/main.gd @@ -0,0 +1,456 @@ +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 := 1130.0 +const LEFT_X := 300.0 +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("e74c3c"), + "blu": Color("3498db"), + "verde": Color("2ecc71"), + "giallo": Color("f1c40f"), + "viola": Color("9b59b6"), +} + +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 bgm: AudioStreamPlayer +var options_menu: CanvasLayer + + +func _ready() -> void: + _build_lanes() + _build_balls() + _build_ui() + _init_audio() + _setup_bgm() + options_menu = load("res://scripts/options_menu.gd").new() + add_child(options_menu) + _new_game() + + +func _process(delta: float) -> void: + elapsed += delta + if target_color == "": + return # in attesa che una pallina torni a casa: nessun countdown + # 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 _open_options() -> void: + if options_menu: + options_menu.show_menu() + + +func _toggle_options() -> void: + if options_menu: + if options_menu.visible: + options_menu.hide_menu() + else: + options_menu.show_menu() + + +# ---------------------------------------------------------------- costruzione + +func _build_lanes() -> void: + for i in 5: + var y := LANE_TOP + i * LANE_GAP + var line := Line2D.new() + line.add_point(Vector2(90, y)) + line.add_point(Vector2(VIEW.x - 90, y)) + line.width = 2 + line.default_color = Color(1, 1, 1, 0.12) + add_child(line) + var lbl := Label.new() + lbl.text = COLORS.keys()[i].to_upper() + lbl.position = Vector2(100, y - 44) + lbl.add_theme_font_size_override("font_size", 16) + lbl.add_theme_color_override("font_color", Color(1, 1, 1, 0.35)) + add_child(lbl) + + +func _build_balls() -> void: + var names := COLORS.keys() + for i in 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 bar := ColorRect.new() + bar.color = Color(0.08, 0.09, 0.12, 0.95) + bar.position = Vector2.ZERO + bar.size = Vector2(VIEW.x, 140) + layer.add_child(bar) + + var edge := ColorRect.new() + edge.color = Color(1, 1, 1, 0.15) + edge.position = Vector2(0, 140) + edge.size = Vector2(VIEW.x, 3) + layer.add_child(edge) + + var tlabel := Label.new() + tlabel.text = "BERSAGLIO" + tlabel.position = Vector2(46, 26) + tlabel.add_theme_font_size_override("font_size", 22) + tlabel.add_theme_color_override("font_color", Color(1, 1, 1, 0.8)) + layer.add_child(tlabel) + + target_dot = load("res://scripts/target_dot.gd").new() + target_dot.position = Vector2(128, 84) + layer.add_child(target_dot) + + help_label = Label.new() + help_label.text = "Tocca la pallina corrispondente al bersaglio per lanciarla!" + help_label.position = Vector2(210, 96) + help_label.add_theme_font_size_override("font_size", 18) + help_label.add_theme_color_override("font_color", Color(1, 1, 1, 0.6)) + layer.add_child(help_label) + + score_label = Label.new() + score_label.position = Vector2(VIEW.x - 320, 22) + score_label.add_theme_font_size_override("font_size", 34) + layer.add_child(score_label) + + combo_label = Label.new() + combo_label.position = Vector2(VIEW.x - 320, 68) + combo_label.add_theme_font_size_override("font_size", 20) + combo_label.add_theme_color_override("font_color", Color(1, 1, 1, 0.65)) + layer.add_child(combo_label) + + # pulsante Opzioni + var opts_btn := Button.new() + opts_btn.text = "⚙ Opzioni (Esc)" + opts_btn.position = Vector2(VIEW.x / 2 - 100, 26) + opts_btn.pressed.connect(_open_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(1.0, 0.9, 0.4)) + 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: + sound_launch = _create_tone(587.33, 0.12, "sine") # Re5 + sound_error = _create_tone(164.81, 0.18, "square") # Mi3 basso + sound_timeout = _create_tone(220.0, 0.22, "saw") # La3 + sound_milestone = _create_arpeggio([523.25, 659.25, 783.99, 1046.5], 0.12) # Do5 Mi5 Sol5 Do6 + + +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_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 + 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 - (float(i) / n) + var s := sin(t * f * TAU) * env + var val := int(clampf(s * 0.35 * 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, wave_type: String = "sine") -> AudioStreamWAV: + var sample_rate := 22050 + var num_samples := int(sample_rate * duration) + var data := PackedByteArray() + data.resize(num_samples) + for i in num_samples: + var t := float(i) / sample_rate + var env := 1.0 - (float(i) / num_samples) + var s := 0.0 + if wave_type == "sine": + s = sin(t * freq * TAU) + elif wave_type == "square": + s = 1.0 if sin(t * freq * TAU) > 0.0 else -1.0 + elif wave_type == "saw": + s = fmod(t * freq, 1.0) * 2.0 - 1.0 + var val := int(clampf(s * env * 0.35 * 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 lancio, punti, nuovo bersaglio, reset timer + combo += 1.0 + score += _points_for_combo() + _play_sound(sound_launch) + var mt := _current_time() + clicked.launch(mt) + window_time = mt + window_timer = 0.0 + _pick_target() + _update_hud() + _check_milestone() + else: + # tasto sbagliato: suono errore, reset combo, nessun game over + _play_sound(sound_error) + combo = 0.0 + _update_hud() + + +func _on_timeout() -> void: + # tempo scaduto: suono timeout, reset combo, nuovo bersaglio (se possibile), nessun game over + _play_sound(sound_timeout) + 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: + # fattori lineari di tempo e punteggio + var tf := clampf(elapsed / Settings.time_ramp, 0.0, 1.0) + var sf := clampf(score / Settings.score_ramp, 0.0, 1.0) + # curva dolce: smoothstep rende l'accelerazione quasi impercettibile all'inizio + return _smoothstep(maxf(tf, 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 un tap corretto, in base alla curva selezionata nelle Opzioni. + match Settings.score_curve: + 1: # scaglioni: +0.5x ogni 5 tap di combo + var mult := 1.0 + floori(combo / 5.0) * 0.5 + return int(Settings.points_per_combo * mult) + 2: # radice quadrata + return int(Settings.points_per_combo * sqrt(combo)) + 3: # additiva: base + combo + return Settings.points_per_combo + int(combo) + _: # 0 = quadratica (attuale): base × combo + return Settings.points_per_combo * int(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_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 + ) diff --git a/scripts/main.gd.uid b/scripts/main.gd.uid new file mode 100644 index 0000000..f86bf5b --- /dev/null +++ b/scripts/main.gd.uid @@ -0,0 +1 @@ +uid://4vue3sdlahr5 diff --git a/scripts/options_menu.gd b/scripts/options_menu.gd new file mode 100644 index 0000000..450b2d1 --- /dev/null +++ b/scripts/options_menu.gd @@ -0,0 +1,171 @@ +extends CanvasLayer +## Menù di configurazione in-game: slider/spinbox per calibrare velocità e +## moltiplicatori. "Salva e riavvia" persiste le impostazioni e ricarica la +## scena applicando i nuovi valori. + +var rows := {} # key -> {slider, spin} +var curve_option: OptionButton + + +func _ready() -> void: + # Critico: il menù deve ricevere input anche quando il gioco è in pausa, + # altrimenti slider/spinbox/pulsanti risultano congelati e inutilizzabili. + process_mode = Node.PROCESS_MODE_ALWAYS + visible = false + _build() + + +func _build() -> void: + # sfondo scuro che blocca i click sottostanti + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.65) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(dim) + + # pannello centrato + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(center) + + var panel := PanelContainer.new() + center.add_child(panel) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 24) + margin.add_theme_constant_override("margin_right", 24) + margin.add_theme_constant_override("margin_top", 16) + margin.add_theme_constant_override("margin_bottom", 16) + panel.add_child(margin) + + var vb := VBoxContainer.new() + vb.add_theme_constant_override("separation", 8) + margin.add_child(vb) + + var title := Label.new() + title.text = "CONFIGURAZIONE" + title.add_theme_font_size_override("font_size", 30) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vb.add_child(title) + + var entries := [ + ["start_time", "Tempo iniziale (s)", 1.0, 10.0, 0.1], + ["min_time", "Tempo minimo (s)", 0.3, 3.0, 0.1], + ["time_ramp", "Rampa tempo (s)", 30.0, 600.0, 10.0], + ["score_ramp", "Rampa punteggio", 100.0, 10000.0, 100.0], + ["points_per_combo", "Punti per combo", 1.0, 50.0, 1.0], + ["combo_decay_threshold", "Soglia decay combo", 0.0, 30.0, 1.0], + ["combo_decay_rate", "Rate decay combo", 0.0, 0.5, 0.01], + ["difficulty_decay_rate", "Rate decay difficoltà", 0.0, 2.0, 0.05], + ] + for e in entries: + rows[e[0]] = _build_row(vb, e[0], e[1], e[2], e[3], e[4]) + + # selettore curva punteggio + var ccurve := HBoxContainer.new() + ccurve.add_theme_constant_override("separation", 6) + vb.add_child(ccurve) + var clbl := Label.new() + clbl.text = "Curva punteggio" + clbl.custom_minimum_size.x = 230 + ccurve.add_child(clbl) + curve_option = OptionButton.new() + curve_option.custom_minimum_size.x = 360 + curve_option.add_item("Quadratica (attuale)") + curve_option.add_item("A scaglioni (ogni 5 tap)") + curve_option.add_item("Radice quadrata") + curve_option.add_item("Additiva (base + combo)") + curve_option.select(Settings.score_curve) + curve_option.item_selected.connect(func(idx: int): Settings.score_curve = idx) + ccurve.add_child(curve_option) + + # pulsanti + var hb := HBoxContainer.new() + hb.add_theme_constant_override("separation", 8) + vb.add_child(hb) + var save := Button.new() + save.text = "Salva e riavvia" + save.pressed.connect(_on_save) + var reset := Button.new() + reset.text = "Ripristina default" + reset.pressed.connect(_on_reset) + var cancel := Button.new() + cancel.text = "Annulla" + cancel.pressed.connect(_on_cancel) + hb.add_child(save) + hb.add_child(reset) + hb.add_child(cancel) + + +func _build_row(parent: Node, key: String, label_text: String, mn: float, mx: float, step: float) -> Dictionary: + var hb := HBoxContainer.new() + hb.add_theme_constant_override("separation", 6) + parent.add_child(hb) + + var lbl := Label.new() + lbl.text = label_text + lbl.custom_minimum_size.x = 230 + hb.add_child(lbl) + + var slider := HSlider.new() + slider.min_value = mn + slider.max_value = mx + slider.step = step + slider.custom_minimum_size.x = 260 + slider.custom_minimum_size.y = 40 + slider.add_theme_constant_override("grabber_radius", 14) # più facile da toccare + slider.value = Settings.get(key) + hb.add_child(slider) + + var spin := SpinBox.new() + spin.min_value = mn + spin.max_value = mx + spin.step = step + spin.value = Settings.get(key) + spin.custom_minimum_size.x = 90 + hb.add_child(spin) + + slider.value_changed.connect(func(v: float): + spin.value = v + Settings.set(key, v)) + spin.value_changed.connect(func(v: float): + slider.value = v + Settings.set(key, v)) + + return {"slider": slider, "spin": spin} + + +func show_menu() -> void: + # aggiorna i valori correnti ogni volta che si apre + for key in rows: + var val: float = Settings.get(key) + rows[key].slider.value = val + rows[key].spin.value = val + if curve_option: + curve_option.select(Settings.score_curve) + visible = true + get_tree().paused = true + + +func hide_menu() -> void: + visible = false + get_tree().paused = false + + +func _on_save() -> void: + Settings.save_settings() + hide_menu() + get_tree().call_deferred("reload_current_scene") + + +func _on_reset() -> void: + Settings.reset_defaults() + for key in rows: + var val: float = Settings.get(key) + rows[key].slider.value = val + rows[key].spin.value = val + if curve_option: + curve_option.select(Settings.score_curve) + + +func _on_cancel() -> void: + hide_menu() diff --git a/scripts/options_menu.gd.uid b/scripts/options_menu.gd.uid new file mode 100644 index 0000000..9bcf012 --- /dev/null +++ b/scripts/options_menu.gd.uid @@ -0,0 +1 @@ +uid://dnr1wdhqehwnu diff --git a/scripts/settings.gd b/scripts/settings.gd new file mode 100644 index 0000000..4dae506 --- /dev/null +++ b/scripts/settings.gd @@ -0,0 +1,62 @@ +extends Node +## Singleton Settings: valori di calibrazione del gameplay, caricati/salvati +## in user://settings.cfg. Modificati dal menù di configurazione e applicati +## al riavvio della scena. + +const PATH := "user://settings.cfg" + +var start_time := 4.5 # tempo iniziale (s) tra un ordine e l'altro +var min_time := 0.8 # tempo minimo (velocità massima) +var time_ramp := 180.0 # secondi di gioco per difficoltà piena +var score_ramp := 2000.0 # punteggio per difficoltà piena +var points_per_combo := 10 # punti base per ogni livello di combo +var combo_decay_threshold := 5.0 # combo sopra cui inizia il decay +var combo_decay_rate := 0.06 # combo persi per unità sopra la soglia +var difficulty_decay_rate := 0.3 # decay aggiuntivo legato alla difficoltà +# Curva del punteggio: 0=quadratica, 1=scaglioni, 2=radice, 3=additiva +var score_curve := 0 + + +func _ready() -> void: + load_settings() + + +func load_settings() -> void: + var cfg := ConfigFile.new() + if cfg.load(PATH) != OK: + return # usa i default + start_time = cfg.get_value("gameplay", "start_time", start_time) + min_time = cfg.get_value("gameplay", "min_time", min_time) + time_ramp = cfg.get_value("gameplay", "time_ramp", time_ramp) + score_ramp = cfg.get_value("gameplay", "score_ramp", score_ramp) + points_per_combo = cfg.get_value("gameplay", "points_per_combo", points_per_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) + score_curve = cfg.get_value("gameplay", "score_curve", score_curve) + + +func save_settings() -> void: + var cfg := ConfigFile.new() + cfg.set_value("gameplay", "start_time", start_time) + cfg.set_value("gameplay", "min_time", min_time) + cfg.set_value("gameplay", "time_ramp", time_ramp) + cfg.set_value("gameplay", "score_ramp", score_ramp) + cfg.set_value("gameplay", "points_per_combo", points_per_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("gameplay", "score_curve", score_curve) + cfg.save(PATH) + + +func reset_defaults() -> void: + start_time = 4.5 + min_time = 0.8 + time_ramp = 180.0 + score_ramp = 2000.0 + points_per_combo = 10 + combo_decay_threshold = 5.0 + combo_decay_rate = 0.06 + difficulty_decay_rate = 0.3 + score_curve = 0 diff --git a/scripts/settings.gd.uid b/scripts/settings.gd.uid new file mode 100644 index 0000000..8e0cd8d --- /dev/null +++ b/scripts/settings.gd.uid @@ -0,0 +1 @@ +uid://tvu3ae4txaud diff --git a/scripts/target_dot.gd b/scripts/target_dot.gd new file mode 100644 index 0000000..f421938 --- /dev/null +++ b/scripts/target_dot.gd @@ -0,0 +1,16 @@ +extends Node2D + +## Pallina di colore che mostra il bersaglio attuale nella barra in alto. + +var dot_color := Color.WHITE + + +func _draw() -> void: + draw_circle(Vector2(0, 4), 36.0, Color(0, 0, 0, 0.3)) + draw_circle(Vector2.ZERO, 36.0, dot_color) + draw_arc(Vector2.ZERO, 36.0, 0.0, TAU, 40, Color(0, 0, 0, 0.4), 3.0) + + +func set_color(c: Color) -> void: + dot_color = c + queue_redraw() diff --git a/scripts/target_dot.gd.uid b/scripts/target_dot.gd.uid new file mode 100644 index 0000000..7e4f2c8 --- /dev/null +++ b/scripts/target_dot.gd.uid @@ -0,0 +1 @@ +uid://b1xr5s67yiov