475 lines
14 KiB
GDScript
475 lines
14 KiB
GDScript
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
|
||
)
|