Files

475 lines
14 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"),
}
# 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 ball_sounds := {} # color_name -> AudioStreamWAV (tono pentatonico)
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
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 _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:
# 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)
# 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()
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_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
)