Explorar el Código

Add score, stars, juice, music and a new home screen; auto-save on win

- Auto-save the level on every win (the 12-hour decay stays); the Save
  power-up is retired.
- Water arrives at level 15 as the first special segment; the others
  keep a 50-level rhythm (ice 50 through magnet 300).
- As segments run out, the core pulls nearby balls in so the end of a
  level doesn't drag.
- Score with a combo multiplier for breaks in a row without touching
  the paddle (up to x5), floating points, and 1-3 stars per level by
  time against a par. Best stars and best score are saved.
- Juice: coloured debris on breaks and screen shake on breaks,
  explosions, lost balls and the core hit. Bigger power-up tokens.
- Home screen: decorative turning rings around the core, level, total
  stars, best score, Continue, next unlock and a music toggle.
- Music: two loops synthesised by tools/make_music.py (menu and game),
  crossfading through a global player; the setting is saved.
- Fix: collecting triple balls (or paddle size / big ball) changed
  physics bodies inside a contact callback, which Godot refuses
  ("Can't change this state while flushing queries"). Collected powers
  are now applied after the physics step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gabriel Capella hace 5 días
padre
commit
70c31513cb

+ 1 - 0
.gitignore

@@ -1,2 +1,3 @@
 .godot/
 build/
+__pycache__/

+ 25 - 11
README.md

@@ -27,7 +27,7 @@ Alguns detalhes que devem ser mencionados sobre a implementação:
 
 - Os power ups colidem com a bola, podendo ser jogados para fora do mapa
   (isso foi proposital para aumentar a dificuldade)
-- O nível salvo pelo jogo reduz em 1 para cada 12 horas sem jogar
+- O nível é salvo automaticamente a cada vitória e reduz em 1 para cada 12 horas sem jogar
   (queremos que o jogador jogue sempre que possível)
 - O jogo não tem pausa (propositalmente, também), pois o objetivo
   é conquistar o máximo de níveis possíveis numa jogatina
@@ -38,7 +38,6 @@ Novos power-ups aparecem conforme os níveis avançam:
 
 | Ficha | Power-up | A partir do nível | Efeito |
 | --- | --- | --- | --- |
-| S | Salvar | 0 | Salva o nível atual |
 | 3 | Bolas triplas | 0 | Até 3 bolas em jogo (cai quando a bola fica muito tempo sem tocar em nada) |
 | P | Tamanho da pá | 0 | 50% de chance de dobrar ou reduzir a pá pela metade por 10 s |
 | X | Super-bola | 0 | Quebra um bloco indestrutível; só cai se ainda houver um |
@@ -70,18 +69,33 @@ As fases são geradas aleatoriamente e são infinitas. A dificuldade só aumenta
 com o nível: mais anéis (até 14), anéis girando mais rápido, bola mais rápida,
 mais blocos indestrutíveis e mais power-ups negativos.
 
-A cada 50 níveis aparece um novo tipo de bloco, e a fração de blocos especiais
-cresce de 8% no nível 50 até 50%:
+O primeiro bloco especial (água) aparece no nível 15; depois disso, um novo tipo
+a cada 50 níveis. A fração de blocos especiais cresce de 6% no nível 15 até 50%:
 
 | Nível | Bloco | Comportamento |
 | --- | --- | --- |
-| 50 | Água | 3 toques; esvazia a cada toque e segura a bola por um instante |
-| 100 | Gelo | 2 toques; racha no primeiro e acelera a bola |
-| 150 | TNT | Explode ao quebrar, levando os vizinhos (em cadeia) |
-| 200 | Borracha | 2 toques; rebate a bola mais rápido |
-| 250 | Fantasma | Some e reaparece; a bola atravessa quando ele some |
-| 300 | Musgo | Cresce de novo uma vez, 8 s depois de quebrado |
-| 350 | Ímã | 2 toques; puxa a bola quando ela passa perto |
+| 15 | Água | 3 toques; esvazia a cada toque e segura a bola por um instante |
+| 50 | Gelo | 2 toques; racha no primeiro e acelera a bola |
+| 100 | TNT | Explode ao quebrar, levando os vizinhos (em cadeia) |
+| 150 | Borracha | 2 toques; rebate a bola mais rápido |
+| 200 | Fantasma | Some e reaparece; a bola atravessa quando ele some |
+| 250 | Musgo | Cresce de novo uma vez, 8 s depois de quebrado |
+| 300 | Ímã | 2 toques; puxa a bola quando ela passa perto |
+
+Conforme os blocos acabam, o núcleo passa a puxar a bola, para que o fim de
+uma fase não se arraste.
+
+### Pontuação e estrelas
+Cada bloco quebrado vale 10 pontos, multiplicados pelo combo: a cada 3 blocos
+seguidos sem a bola tocar na pá, o multiplicador sobe (até ×5). Ao vencer, a
+fase dá de 1 a 3 estrelas conforme o tempo (3 estrelas dentro do tempo-alvo,
+2 dentro do dobro). A melhor pontuação e as estrelas ficam salvas.
+
+A pá direciona a bola: no centro ela sobe reta, nas pontas sai inclinada até 60°.
+
+### Música
+As duas músicas (menu e jogo) são sintetizadas por `tools/make_music.py`
+(precisa de numpy e ffmpeg) e salvas em `assets/music/`.
 
 ## Build
 O build roda dentro do Docker, com o Godot em modo headless. Antes de

BIN
assets/music/game.ogg


+ 19 - 0
assets/music/game.ogg.import

@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://b4w3v2s3p5gfj"
+path="res://.godot/imported/game.ogg-4d3702d32079a1e915d917aa12e9eb79.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/music/game.ogg"
+dest_files=["res://.godot/imported/game.ogg-4d3702d32079a1e915d917aa12e9eb79.oggvorbisstr"]
+
+[params]
+
+loop=true
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4

BIN
assets/music/menu.ogg


+ 19 - 0
assets/music/menu.ogg.import

@@ -0,0 +1,19 @@
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://bmht38mf1o1by"
+path="res://.godot/imported/menu.ogg-fa26fee69557877850e6d4be35b4e577.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/music/menu.ogg"
+dest_files=["res://.godot/imported/menu.ogg-fa26fee69557877850e6d4be35b4e577.oggvorbisstr"]
+
+[params]
+
+loop=true
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4

+ 9 - 10
manual.md

@@ -4,16 +4,15 @@ O jogo tem por objetivo avançar pelas fases, quebrando os blocos e
 atingindo o núcleo vermelho no topo da tela. Para tanto, serão usadas
 uma bolinha, que colidirá com os blocos, e uma pá, cuja movimentação
 será controlada com o toque do usuário (a pá se move para onde o
-usuário tocou pela última vez). Além disso, o usuário poderá contar com
-os seguintes power-ups:
-- Aumento/diminuição da pá (power-up azul): tem 50% de chance de aumentar
-  ou de diminuir a pá (diminuir pela metade e aumentar para o dobro do tamanho).
-  A pá volta ao seu tamanho normal depois de um tempo.
-- Bolas múltiplas (power-up rosa): aumenta o número de bolas no jogo para 3.
-- Salvamento do nível (power-up verde): salva o nível atual do jogo
-- Super-bola (power-up): a bola pode destruir tanto os blocos normais
-  quanto o bloco indestrutível. Ela volta ao normal depois de destruir
-  um bloco indestrutível.
+usuário tocou pela última vez). O ponto da pá onde a bola bate define o
+ângulo da volta: no centro ela sobe reta, nas pontas sai inclinada.
+
+Power-ups caem como fichas com uma letra; pegue com a pá para usar. A lista
+completa, com o nível em que cada um aparece, está no README. Novos tipos de
+bloco também aparecem com o tempo (água, gelo, TNT, ...).
+
+Quebrar blocos seguidos, sem a bola voltar à pá, aumenta o combo e a
+pontuação. Vencer rápido dá até 3 estrelas. O nível é salvo a cada vitória.
 
 Ao atingir o núcleo com a bolinha, o usuário passará para a pŕoxima fase,
 mas caso a única bolinha em jogo colida com a parte inferior da tela, o

+ 1 - 0
project.godot

@@ -11,6 +11,7 @@ config/features=PackedStringArray("4.7")
 [autoload]
 
 Progress="*res://scripts/progress.gd"
+Music="*res://scripts/music.gd"
 
 [display]
 

+ 61 - 2
scenes/game.tscn

@@ -8,10 +8,26 @@
 [ext_resource type="AudioStream" path="res://assets/sounds/metal.wav" id="6_metal"]
 [ext_resource type="AudioStream" path="res://assets/sounds/bomb.wav" id="7_bomb"]
 [ext_resource type="AudioStream" path="res://assets/sounds/win.wav" id="8_win"]
+[ext_resource type="Script" path="res://scripts/stars.gd" id="9_stars"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pill"]
+content_margin_left = 36.0
+content_margin_top = 10.0
+content_margin_right = 36.0
+content_margin_bottom = 12.0
+bg_color = Color(0.992157, 0.592157, 0.121569, 1)
+corner_radius_top_left = 30
+corner_radius_top_right = 30
+corner_radius_bottom_right = 30
+corner_radius_bottom_left = 30
+anti_aliasing_size = 1.5
 
 [node name="Game" type="Node2D"]
 script = ExtResource("1_game")
 
+[node name="Camera" type="Camera2D" parent="."]
+anchor_mode = 0
+
 [node name="Walls" type="StaticBody2D" parent="."]
 script = ExtResource("2_walls")
 
@@ -56,6 +72,32 @@ theme_override_font_sizes/font_size = 30
 text = "Level 0"
 vertical_alignment = 2
 
+[node name="Score" type="Label" parent="HUD"]
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -260.0
+offset_top = 10.0
+offset_right = -14.0
+offset_bottom = 50.0
+grow_horizontal = 0
+theme_override_font_sizes/font_size = 32
+text = "0"
+horizontal_alignment = 2
+
+[node name="Combo" type="Label" parent="HUD"]
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -260.0
+offset_top = 48.0
+offset_right = -14.0
+offset_bottom = 78.0
+grow_horizontal = 0
+theme_override_colors/font_color = Color(0.992157, 0.592157, 0.121569, 1)
+theme_override_font_sizes/font_size = 22
+horizontal_alignment = 2
+
 [node name="Alerts" type="Control" parent="HUD"]
 layout_mode = 3
 anchors_preset = 15
@@ -74,14 +116,31 @@ anchor_right = 0.5
 anchor_bottom = 0.5
 grow_horizontal = 2
 grow_vertical = 2
-theme_override_constants/separation = 10
+theme_override_constants/separation = 16
 
 [node name="Message" type="Label" parent="HUD/EndMenu"]
 layout_mode = 2
 theme_override_font_sizes/font_size = 40
 horizontal_alignment = 1
 
+[node name="Stars" type="Control" parent="HUD/EndMenu"]
+layout_mode = 2
+script = ExtResource("9_stars")
+star_size = 48.0
+
+[node name="Score" type="Label" parent="HUD/EndMenu"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 26
+horizontal_alignment = 1
+
 [node name="Button" type="Button" parent="HUD/EndMenu"]
 layout_mode = 2
+size_flags_horizontal = 4
+theme_override_colors/font_color = Color(0.152941, 0.156863, 0.133333, 1)
+theme_override_colors/font_hover_color = Color(0.152941, 0.156863, 0.133333, 1)
+theme_override_colors/font_pressed_color = Color(0.152941, 0.156863, 0.133333, 1)
 theme_override_font_sizes/font_size = 32
-flat = true
+theme_override_styles/normal = SubResource("StyleBoxFlat_pill")
+theme_override_styles/hover = SubResource("StyleBoxFlat_pill")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_pill")
+theme_override_styles/focus = SubResource("StyleBoxFlat_pill")

+ 1 - 1
scenes/power_up.tscn

@@ -7,7 +7,7 @@ friction = 0.0
 bounce = 1.0
 
 [sub_resource type="CircleShape2D" id="CircleShape2D_power_up"]
-radius = 9.0
+radius = 12.0
 
 [node name="PowerUp" type="RigidBody2D"]
 collision_layer = 16

+ 107 - 20
scenes/title.tscn

@@ -3,6 +3,19 @@
 [ext_resource type="Script" path="res://scripts/title.gd" id="1_title"]
 [ext_resource type="Script" path="res://scripts/walls.gd" id="2_walls"]
 [ext_resource type="Script" path="res://scripts/core.gd" id="3_core"]
+[ext_resource type="Script" path="res://scripts/stars.gd" id="4_stars"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pill"]
+content_margin_left = 48.0
+content_margin_top = 12.0
+content_margin_right = 48.0
+content_margin_bottom = 14.0
+bg_color = Color(0.992157, 0.592157, 0.121569, 1)
+corner_radius_top_left = 34
+corner_radius_top_right = 34
+corner_radius_bottom_right = 34
+corner_radius_bottom_left = 34
+anti_aliasing_size = 1.5
 
 [node name="Title" type="Node2D"]
 script = ExtResource("1_title")
@@ -12,7 +25,7 @@ script = ExtResource("2_walls")
 
 [node name="Core" type="StaticBody2D" parent="."]
 script = ExtResource("3_core")
-radius = 200.0
+radius = 180.0
 metadata/bounce = 0.9
 
 [node name="Balls" type="Node2D" parent="."]
@@ -20,42 +33,116 @@ metadata/bounce = 0.9
 [node name="UI" type="CanvasLayer" parent="."]
 
 [node name="Title" type="Label" parent="UI"]
-anchor_top = 0.25
+anchor_top = 0.28
 anchor_right = 1.0
-anchor_bottom = 0.25
+anchor_bottom = 0.28
 offset_top = -70.0
 offset_bottom = 70.0
 grow_horizontal = 2
 grow_vertical = 2
-theme_override_font_sizes/font_size = 100
+theme_override_font_sizes/font_size = 96
 text = "RedCore"
 horizontal_alignment = 1
 vertical_alignment = 1
 
-[node name="Start" type="Button" parent="UI"]
+[node name="MusicToggle" type="Button" parent="UI"]
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -150.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = 48.0
+grow_horizontal = 0
+theme_override_colors/font_color = Color(1, 1, 1, 0.75)
+theme_override_font_sizes/font_size = 20
+text = "Music: on"
+flat = true
+alignment = 2
+
+[node name="Info" type="VBoxContainer" parent="UI"]
+anchor_top = 0.56
+anchor_right = 1.0
+anchor_bottom = 0.56
+offset_top = -60.0
+offset_bottom = 60.0
+grow_horizontal = 2
+grow_vertical = 2
+alignment = 1
+
+[node name="Level" type="Label" parent="UI/Info"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 40
+text = "Level 0"
+horizontal_alignment = 1
+
+[node name="Totals" type="HBoxContainer" parent="UI/Info"]
+layout_mode = 2
+alignment = 1
+
+[node name="Star" type="Control" parent="UI/Info/Totals"]
+layout_mode = 2
+script = ExtResource("4_stars")
+count = 1
+filled = 1
+star_size = 26.0
+
+[node name="Count" type="Label" parent="UI/Info/Totals"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 26
+text = "0"
+
+[node name="Best" type="Label" parent="UI/Info"]
+layout_mode = 2
+theme_override_colors/font_color = Color(1, 1, 1, 0.6)
+theme_override_font_sizes/font_size = 20
+horizontal_alignment = 1
+
+[node name="Play" type="Button" parent="UI"]
 anchor_left = 0.5
-anchor_top = 0.75
+anchor_top = 0.7
 anchor_right = 0.5
-anchor_bottom = 0.75
-offset_left = -100.0
-offset_top = -30.0
-offset_right = 100.0
-offset_bottom = 30.0
+anchor_bottom = 0.7
+offset_left = -130.0
+offset_top = -34.0
+offset_right = 130.0
+offset_bottom = 34.0
 grow_horizontal = 2
 grow_vertical = 2
-theme_override_font_sizes/font_size = 40
-text = "Start"
-flat = true
+theme_override_colors/font_color = Color(0.152941, 0.156863, 0.133333, 1)
+theme_override_colors/font_hover_color = Color(0.152941, 0.156863, 0.133333, 1)
+theme_override_colors/font_pressed_color = Color(0.152941, 0.156863, 0.133333, 1)
+theme_override_font_sizes/font_size = 36
+theme_override_styles/normal = SubResource("StyleBoxFlat_pill")
+theme_override_styles/hover = SubResource("StyleBoxFlat_pill")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_pill")
+theme_override_styles/focus = SubResource("StyleBoxFlat_pill")
+text = "Play"
 
-[node name="LevelInfo" type="Label" parent="UI"]
-anchor_top = 0.75
+[node name="Next" type="Label" parent="UI"]
+anchor_top = 0.79
 anchor_right = 1.0
-anchor_bottom = 0.75
+anchor_bottom = 0.79
 offset_left = 16.0
-offset_top = 40.0
+offset_top = -16.0
 offset_right = -16.0
-offset_bottom = 100.0
+offset_bottom = 16.0
 grow_horizontal = 2
-theme_override_font_sizes/font_size = 20
+grow_vertical = 2
+theme_override_colors/font_color = Color(0.4, 0.85098, 0.937255, 1)
+theme_override_font_sizes/font_size = 22
+horizontal_alignment = 1
+
+[node name="Decay" type="Label" parent="UI"]
+anchor_top = 0.87
+anchor_right = 1.0
+anchor_bottom = 0.87
+offset_left = 24.0
+offset_top = -24.0
+offset_right = -24.0
+offset_bottom = 30.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_colors/font_color = Color(1, 1, 1, 0.55)
+theme_override_font_sizes/font_size = 17
 horizontal_alignment = 1
 autowrap_mode = 3

+ 14 - 10
scripts/block.gd

@@ -3,6 +3,8 @@ extends AnimatableBody2D
 ## One arc-shaped segment of a ring around the core. Its [enum Kind] decides how it looks,
 ## how many hits it takes and what it does to the ball.
 
+## Emitted when the segment breaks, whatever broke it.
+signal broken(block: Block)
 ## Emitted when a TNT segment breaks; the rings turn it into an explosion.
 signal detonated(block: Block)
 
@@ -15,18 +17,19 @@ const GAP := 4.0 # px between neighbouring tiles
 const CORNER := 4.0 # corner rounding radius in px
 const BREAK_SECONDS := 0.15 # fade-out time, after a short flash
 
-## Per kind: first level it can appear, hits to break (0 = unbreakable) and flat colour.
+## Per kind: name, first level it can appear, hits to break (0 = unbreakable) and flat
+## colour. Water comes early as the first taste; the rest arrive every 50 levels.
 ## Normal segments get a random blue-green colour instead.
 const KINDS := {
-	Kind.NORMAL: { unlock = 0, hp = 1, color = Params.COLOR_BLUE },
-	Kind.INDESTRUCTIBLE: { unlock = 0, hp = 0, color = Params.COLOR_ORANGE },
-	Kind.WATER: { unlock = 50, hp = 3, color = Color("#3a7bff") },
-	Kind.ICE: { unlock = 100, hp = 2, color = Color("#d6f3ff") },
-	Kind.TNT: { unlock = 150, hp = 1, color = Params.COLOR_PINK },
-	Kind.RUBBER: { unlock = 200, hp = 2, color = Color("#ae81ff") },
-	Kind.GHOST: { unlock = 250, hp = 1, color = Color("#c8c3d8") },
-	Kind.MOSS: { unlock = 300, hp = 1, color = Params.COLOR_GREEN },
-	Kind.MAGNET: { unlock = 350, hp = 2, color = Color("#9aa4ad") },
+	Kind.NORMAL: { name = "Normal", unlock = 0, hp = 1, color = Params.COLOR_BLUE },
+	Kind.INDESTRUCTIBLE: { name = "Indestructible", unlock = 0, hp = 0, color = Params.COLOR_ORANGE },
+	Kind.WATER: { name = "Water", unlock = Params.FIRST_SPECIAL_LEVEL, hp = 3, color = Color("#3a7bff") },
+	Kind.ICE: { name = "Ice", unlock = 50, hp = 2, color = Color("#d6f3ff") },
+	Kind.TNT: { name = "TNT", unlock = 100, hp = 1, color = Params.COLOR_PINK },
+	Kind.RUBBER: { name = "Rubber", unlock = 150, hp = 2, color = Color("#ae81ff") },
+	Kind.GHOST: { name = "Ghost", unlock = 200, hp = 1, color = Color("#c8c3d8") },
+	Kind.MOSS: { name = "Moss", unlock = 250, hp = 1, color = Params.COLOR_GREEN },
+	Kind.MAGNET: { name = "Magnet", unlock = 300, hp = 2, color = Color("#9aa4ad") },
 }
 
 const WATER_STICK_SECONDS := 0.35
@@ -133,6 +136,7 @@ func damage() -> Hit:
 ## Flashes and fades out. Stops colliding right away. Moss comes back once, after a while.
 func destroy() -> void:
 	collision_layer = 0
+	broken.emit(self)
 	if kind == Kind.TNT:
 		detonated.emit(self)
 	var tween := create_tween()

+ 35 - 0
scripts/blocks.gd

@@ -2,6 +2,11 @@ class_name Blocks
 extends Node2D
 ## The core plus rings of rotating blocks around it, generated from the level number.
 
+## A segment broke, by any means.
+signal segment_broken(block: Block)
+## Something exploded (TNT or a bomb ball) at this global position.
+signal exploded(at: Vector2)
+
 const BLAST_SCENE := preload("res://scenes/blast.tscn")
 
 const SEGMENTS_PER_RING := 10
@@ -12,6 +17,9 @@ var core: Core
 var _rings: Array[Node2D] = []
 var _speeds: Array[float] = []
 var _frozen_time := 0.0
+var _outer_radius := 0.0
+var _breakable_total := 0
+var _breakable_left := 0
 
 
 ## Builds the level's rings. Rings are made thinner when needed so they all fit within
@@ -21,6 +29,8 @@ func build(level: int, max_radius: float) -> void:
 	var size := minf(layout.size, (max_radius - Params.CORE_RADIUS) / layout.rings)
 	for index in range(layout.rings - 1, -1, -1):
 		_add_ring(level, size, index, layout.indestructible_chance)
+	_outer_radius = Params.CORE_RADIUS + layout.rings * size
+	_breakable_left = _breakable_total
 	core = Core.new()
 	add_child(core)
 
@@ -44,6 +54,7 @@ func explode(at: Vector2, radius: float) -> void:
 	blast.radius = radius
 	blast.position = to_local(at)
 	add_child(blast)
+	exploded.emit(at)
 	var query := PhysicsShapeQueryParameters2D.new()
 	var circle := CircleShape2D.new()
 	circle.radius = radius
@@ -56,6 +67,7 @@ func explode(at: Vector2, radius: float) -> void:
 
 
 func _physics_process(delta: float) -> void:
+	_pull_balls(delta)
 	if _frozen_time > 0.0:
 		_frozen_time -= delta
 		return
@@ -63,6 +75,26 @@ func _physics_process(delta: float) -> void:
 		_rings[i].rotation_degrees += _speeds[i] * delta
 
 
+## The fewer breakable segments are left, the harder the core pulls nearby balls in.
+func _pull_balls(delta: float) -> void:
+	if _breakable_total == 0 or not is_instance_valid(core) or core.collision_layer == 0:
+		return
+	var strength := Params.CORE_PULL * clampf(1.0 - float(_breakable_left) / _breakable_total, 0.0, 1.0)
+	if strength <= 0.0:
+		return
+	var reach := _outer_radius + Params.CORE_PULL_REACH
+	for ball: Ball in get_tree().get_nodes_in_group("balls"):
+		var offset := global_position - ball.global_position
+		var dist := offset.length()
+		if dist > 0.0 and dist < reach:
+			ball.velocity += offset / dist * strength * delta
+
+
+func _on_broken(block: Block) -> void:
+	_breakable_left -= 1
+	segment_broken.emit(block)
+
+
 func _on_detonated(block: Block) -> void:
 	var at := block.center()
 	get_tree().create_timer(TNT_CHAIN_DELAY, false, true).timeout.connect(explode.bind(at, TNT_RADIUS))
@@ -116,6 +148,9 @@ func _add_ring(level: int, size: float, index: int, indestructible_chance: float
 	for i in SEGMENTS_PER_RING:
 		var block := Block.new(inner, outer, bounds[i], bounds[i + 1], _segment_kind(level, indestructible_chance))
 		block.detonated.connect(_on_detonated)
+		block.broken.connect(_on_broken)
+		if block.is_breakable():
+			_breakable_total += 1
 		ring.add_child(block)
 	add_child(ring)
 	_rings.append(ring)

+ 109 - 9
scripts/game.gd

@@ -6,6 +6,7 @@ const POWER_UP_SCENE := preload("res://scenes/power_up.tscn")
 const SUN_BURST_SCENE := preload("res://scenes/sun_burst.tscn")
 
 const SHIELD_HEIGHT := 6.0 # px above the bottom edge
+const DEBRIS_PIECES := 10
 
 var level := 0
 ## Set once the level is won or lost; stops power-up drops and collision handling.
@@ -19,6 +20,11 @@ var shield := 0:
 		shield = value
 		queue_redraw()
 var _started := 0.0
+var score := 0
+## Segments broken in a row since a ball last touched the paddle.
+var combo := 0
+## Screen shake amount, 0 to 1; decays over time.
+var _trauma := 0.0
 
 @onready var blocks: Blocks = $Blocks
 @onready var paddle: Paddle = $Paddle
@@ -28,6 +34,11 @@ var _started := 0.0
 @onready var end_menu: Control = $HUD/EndMenu
 @onready var end_message: Label = $HUD/EndMenu/Message
 @onready var end_button: Button = $HUD/EndMenu/Button
+@onready var end_stars: Stars = $HUD/EndMenu/Stars
+@onready var end_score: Label = $HUD/EndMenu/Score
+@onready var score_label: Label = $HUD/Score
+@onready var combo_label: Label = $HUD/Combo
+@onready var camera: Camera2D = $Camera
 
 
 func _ready() -> void:
@@ -46,7 +57,11 @@ func _ready() -> void:
 	blocks.position = Vector2(screen.x / 2.0, core_y)
 	blocks.build(level, lowest_ring_edge - core_y)
 	paddle.released.connect(_release_caught_balls)
+	blocks.segment_broken.connect(_on_segment_broken)
+	blocks.exploded.connect(func(_at: Vector2) -> void: _shake(0.5))
 	end_button.pressed.connect(_restart)
+	_update_score()
+	Music.play("game")
 
 	_drop_power_up()
 	_drop_triple_balls()
@@ -71,6 +86,8 @@ func _on_ball_hit(ball: Ball, other: Node) -> void:
 	elif other is Core:
 		_win()
 	elif other is Paddle:
+		combo = 0
+		_update_score()
 		if paddle.catch_time > 0.0:
 			ball.stick_to(paddle, Params.CATCH_HOLD_SECONDS)
 		elif ball.velocity.y < 0.0:
@@ -84,14 +101,11 @@ func _on_ball_hit(ball: Ball, other: Node) -> void:
 			_lose_ball(ball)
 
 
-func _on_power_up_collected(power_up: PowerUp) -> void:
+func _on_power_up_collected(kind: PowerUp.Kind) -> void:
 	if over:
 		return
 	var live := _live_balls()
-	match power_up.kind:
-		PowerUp.Kind.SAVE:
-			Progress.save_level(level)
-			_alert(Params.MSG_LEVEL_SAVED)
+	match kind:
 		PowerUp.Kind.TRIPLE_BALLS:
 			_alert(Params.MSG_TRIPLE_BALLS)
 			for i in 3 - live.size():
@@ -151,15 +165,41 @@ func _release_caught_balls() -> void:
 			ball.release()
 
 
+## Score, combo and debris for every segment that breaks, however it broke.
+func _on_segment_broken(block: Block) -> void:
+	if over:
+		return
+	combo += 1
+	var points := Params.POINTS_PER_BREAK * _multiplier()
+	score += points
+	_update_score()
+	var at := block.center()
+	_debris(at, block.color)
+	_popup(at, "+%d" % points, Params.COLOR_ORANGE if _multiplier() > 1 else Color.WHITE)
+	_shake(0.12)
+
+
+func _multiplier() -> int:
+	return mini(Params.MAX_COMBO, 1 + combo / Params.BREAKS_PER_COMBO_STEP)
+
+
+func _update_score() -> void:
+	score_label.text = str(score)
+	combo_label.text = "x%d combo" % _multiplier() if _multiplier() > 1 else ""
+
+
 func _lose_ball(ball: Ball) -> void:
 	$Sounds/Bomb.play()
 	_burst(ball.global_position)
+	_shake(0.6)
 	ball.queue_free()
 	if _live_balls().is_empty():
 		over = true
 		_clear_play_field()
 		end_message.text = Params.MSG_OVER
 		end_message.show()
+		end_stars.hide()
+		end_score.text = "Score %d" % score
 		end_button.text = Params.MSG_RESTART
 		end_menu.show()
 
@@ -167,10 +207,21 @@ func _lose_ball(ball: Ball) -> void:
 func _win() -> void:
 	over = true
 	$Sounds/Win.play()
+	_shake(0.8)
+	var seconds := _now() - _started
+	var par := Params.par_seconds(level)
+	var stars := 3 if seconds <= par else (2 if seconds <= 2.0 * par else 1)
 	_clear_play_field()
 	await blocks.core.explode()
+	Progress.save_stars(level, stars)
+	var best := Progress.save_best_score(score)
+	end_message.text = Params.MSG_LEVEL_DONE % level
+	end_message.show()
+	end_stars.show()
+	end_stars.reveal(stars)
+	end_score.text = ("New best! " if best else "") + "Score %d" % score
 	level += 1
-	end_message.hide()
+	Progress.save_level(level)
 	end_button.text = Params.MSG_NEXT_LEVEL
 	end_menu.show()
 
@@ -222,8 +273,6 @@ func _pick_power_up() -> int:
 	var weights: Array[float] = []
 	for kind: int in PowerUp.KINDS:
 		var w := PowerUp.weight(kind as PowerUp.Kind, level)
-		if kind == PowerUp.Kind.SAVE and _now() - _started < Params.SAVE_DELAY:
-			w = 0.0
 		# A super ball only helps while something unbreakable is left.
 		if kind == PowerUp.Kind.SUPERBALL and not blocks.has_unbreakable():
 			w = 0.0
@@ -259,7 +308,9 @@ func _spawn_power_up(kind: PowerUp.Kind) -> void:
 	var power_up: PowerUp = POWER_UP_SCENE.instantiate()
 	power_up.kind = kind
 	power_up.position = Vector2(randf_range(PowerUp.RADIUS, screen.x - PowerUp.RADIUS), PowerUp.RADIUS + 1.0)
-	power_up.collected.connect(_on_power_up_collected)
+	# Collected inside a physics callback, where bodies can't be added or resized
+	# (triple balls, paddle size, big ball): apply it once physics is done.
+	power_up.collected.connect(_on_power_up_collected, CONNECT_DEFERRED)
 	power_ups.add_child(power_up)
 
 
@@ -278,6 +329,55 @@ func _alert(text: String) -> void:
 	tween.tween_callback(label.queue_free)
 
 
+## Chips flying off a broken segment, in its colour.
+func _debris(at: Vector2, color: Color) -> void:
+	var debris := CPUParticles2D.new()
+	debris.position = at
+	debris.one_shot = true
+	debris.amount = DEBRIS_PIECES
+	debris.lifetime = 0.6
+	debris.explosiveness = 1.0
+	debris.spread = 180.0
+	debris.gravity = Vector2(0, 600)
+	debris.initial_velocity_min = 60.0
+	debris.initial_velocity_max = 180.0
+	debris.scale_amount_min = 3.0
+	debris.scale_amount_max = 5.0
+	debris.color = color
+	var fade := Gradient.new()
+	fade.set_color(0, Color.WHITE)
+	fade.set_color(1, Color(1, 1, 1, 0))
+	debris.color_ramp = fade
+	debris.finished.connect(debris.queue_free)
+	add_child(debris)
+	debris.emitting = true
+
+
+## Points floating up from where a segment broke.
+func _popup(at: Vector2, text: String, color: Color) -> void:
+	var label := Label.new()
+	label.text = text
+	label.add_theme_font_size_override("font_size", 22)
+	label.add_theme_color_override("font_color", color)
+	label.position = at - Vector2(20, 14)
+	label.z_index = 5
+	add_child(label)
+	var tween := label.create_tween().set_parallel()
+	tween.tween_property(label, "position:y", label.position.y - 40.0, 0.7)
+	tween.tween_property(label, "modulate:a", 0.0, 0.7).set_delay(0.2)
+	tween.chain().tween_callback(label.queue_free)
+
+
+func _shake(amount: float) -> void:
+	_trauma = minf(1.0, _trauma + amount)
+
+
+func _process(delta: float) -> void:
+	_trauma = maxf(0.0, _trauma - Params.SHAKE_DECAY * delta)
+	var strength := _trauma * _trauma * Params.SHAKE_MAX
+	camera.offset = Vector2(randf_range(-1, 1), randf_range(-1, 1)) * strength
+
+
 func _burst(at: Vector2) -> void:
 	var burst: CPUParticles2D = SUN_BURST_SCENE.instantiate()
 	burst.position = at

+ 50 - 0
scripts/music.gd

@@ -0,0 +1,50 @@
+extends Node
+## Background music that carries on across scene changes. Scenes ask for a track; the
+## player crossfades to it, or keeps playing if it is already on.
+
+## Loaded on first use rather than preloaded, so this autoload never depends on the
+## music having been imported yet. Looping is set in each track's .import file.
+const TRACKS := {
+	menu = "res://assets/music/menu.ogg",
+	game = "res://assets/music/game.ogg",
+}
+const VOLUME_DB := { menu = -6.0, game = -10.0 }
+const FADE_SECONDS := 0.8
+const SILENT_DB := -40.0
+
+var _player := AudioStreamPlayer.new()
+var _track := ""
+
+
+func _ready() -> void:
+	process_mode = Node.PROCESS_MODE_ALWAYS
+	add_child(_player)
+
+
+func play(track: String) -> void:
+	if track == _track and _player.playing:
+		return
+	_track = track
+	if not Progress.music_on():
+		return
+	var tween := create_tween()
+	if _player.playing:
+		tween.tween_property(_player, "volume_db", SILENT_DB, FADE_SECONDS / 2.0)
+	tween.tween_callback(_start.bind(track))
+	tween.tween_property(_player, "volume_db", VOLUME_DB[track], FADE_SECONDS / 2.0)
+
+
+func set_enabled(on: bool) -> void:
+	Progress.set_music_on(on)
+	if on:
+		var track := _track
+		_track = ""
+		play(track if track != "" else "menu")
+	else:
+		_player.stop()
+
+
+func _start(track: String) -> void:
+	_player.stream = load(TRACKS[track])
+	_player.volume_db = SILENT_DB
+	_player.play()

+ 1 - 0
scripts/music.gd.uid

@@ -0,0 +1 @@
+uid://uacralc4lboj

+ 24 - 5
scripts/params.gd

@@ -41,7 +41,6 @@ const LAYER_POWER_UPS := 16
 # Power-ups. Every POWER_UP_INTERVAL seconds a drop is rolled (power_up_chance), and the
 # kind is picked by the weights in PowerUp.KINDS. Triple balls roll separately.
 const DROP_TRIPLE_BALL := 0.06 # multiplied by seconds since a ball last touched anything
-const SAVE_DELAY := 10.0 # seconds into a level before "save" can drop
 const POWER_UP_INTERVAL := 2.5
 const POWER_UP_PADDLE_SECONDS := 10.0
 const SLOW_SCALE := 0.6
@@ -58,17 +57,31 @@ const FREEZE_SECONDS := 8.0
 const BOMB_RADIUS := 90.0
 const HOMING_SECONDS := 6.0
 
+# Core pull: as segments run out, the core draws nearby balls in so levels don't drag.
+const CORE_PULL := 450.0 # px/s² when every breakable segment is gone
+const CORE_PULL_REACH := 120.0 # px beyond the outer ring
+
+# Score: points per break, times a combo multiplier that grows with breaks in a row
+# (without the ball touching the paddle).
+const POINTS_PER_BREAK := 10
+const BREAKS_PER_COMBO_STEP := 3
+const MAX_COMBO := 5
+
+# Feel
+const SHAKE_MAX := 14.0 # px at full trauma
+const SHAKE_DECAY := 1.6 # trauma lost per second
+
 # Hours without playing before the saved level drops by one
 const LOSE_HOURS := 12
 
 # Text
 const MSG_TRIPLE_BALLS := "Triple Balls!"
-const MSG_LEVEL_SAVED := "Level Saved!"
 const MSG_OVER := "Game Over..."
 const MSG_DOUBLE_PADDLE := "Doubled paddle!"
 const MSG_HALF_PADDLE := "Half paddle :("
 const MSG_LEVEL_BACK := "You are at level %d. Play within %.1f hour(s) or you will drop back a level."
 const MSG_NEXT_LEVEL := "Next Level"
+const MSG_LEVEL_DONE := "Level %d complete"
 const MSG_RESTART := "Restart"
 const MSG_SUPER_BALL := "Super Ball!"
 const MSG_SLOW := "Slow motion"
@@ -87,6 +100,7 @@ const MSG_HOMING := "Homing ball!"
 # Progression: how the game gets harder level by level. Every curve only goes up.
 
 const MAX_RINGS := 14
+const FIRST_SPECIAL_LEVEL := 15 # water arrives here; the rest every 50 levels after
 
 
 ## Rings around the core: 1 at level 0, 3 at level 12, 6 at 75, 10 at 243, 14 from 507.
@@ -113,11 +127,16 @@ static func launch_speed(level: int) -> float:
 	return INITIAL_SPEED + minf(150.0, level * 1.0)
 
 
-## Share of segments that are a special kind (water, ice, ...), once one has unlocked.
+## Share of segments that are a special kind (water, ice, ...), once the first has unlocked.
 static func special_segment_share(level: int) -> float:
-	if level < 50:
+	if level < FIRST_SPECIAL_LEVEL:
 		return 0.0
-	return minf(0.5, 0.08 + (level - 50) * 0.0012)
+	return minf(0.5, 0.06 + (level - FIRST_SPECIAL_LEVEL) * 0.0012)
+
+
+## Seconds to win a level within for three stars; twice that for two.
+static func par_seconds(level: int) -> float:
+	return 60.0 + minf(60.0, level * 0.8)
 
 
 ## Chance of a power-up drop every POWER_UP_INTERVAL seconds.

+ 25 - 25
scripts/power_up.gd

@@ -2,37 +2,38 @@ class_name PowerUp
 extends RigidBody2D
 ## A small token that falls from the top. Catch it with the paddle to collect it.
 
-signal collected(power_up: PowerUp)
+## Emitted from a physics callback; connect it deferred before adding or resizing bodies.
+signal collected(kind: Kind)
 
 enum Kind {
-	SAVE, TRIPLE_BALLS, PADDLE, SUPERBALL,
+	TRIPLE_BALLS, PADDLE, SUPERBALL,
 	SLOW, CATCH, SHIELD, BIG_BALL, FIREBALL, RUSH, LASER, FREEZE, BOMB, HOMING,
 }
 
-const RADIUS := 9.0
+const RADIUS := 12.0
+const GLYPH_SIZE := 18
 const FONT := preload("res://assets/fonts/abel.ttf")
 
-## Per kind: colour, the letter on the token, first level it drops at, and its weight in
-## the random pick. Triple balls are not in the pick: they drop when the ball is idle.
+## Per kind: name, colour, the letter on the token, first level it drops at, and its weight
+## in the random pick. Triple balls are not in the pick: they drop when the ball is idle.
 ## Power-downs (down = true) get likelier as the levels go up.
 const KINDS := {
-	Kind.SAVE: { color = Params.COLOR_GREEN, glyph = "S", unlock = 0, weight = 0.6 },
-	Kind.TRIPLE_BALLS: { color = Params.COLOR_PINK, glyph = "3", unlock = 0, weight = 0.0 },
-	Kind.PADDLE: { color = Params.COLOR_BLUE, glyph = "P", unlock = 0, weight = 1.0 },
-	Kind.SUPERBALL: { color = Params.COLOR_ORANGE, glyph = "X", unlock = 0, weight = 0.6 },
-	Kind.SLOW: { color = Color("#5fb3ff"), glyph = "Z", unlock = 2, weight = 0.8 },
-	Kind.CATCH: { color = Color("#8be28b"), glyph = "C", unlock = 4, weight = 0.8 },
-	Kind.SHIELD: { color = Color("#f4f4f0"), glyph = "U", unlock = 6, weight = 0.7 },
-	Kind.BIG_BALL: { color = Color("#c9c9c9"), glyph = "O", unlock = 8, weight = 0.7 },
-	Kind.FIREBALL: { color = Params.COLOR_FIRE, glyph = "F", unlock = 12, weight = 0.6 },
-	Kind.RUSH: { color = Params.COLOR_PINK, glyph = "!", unlock = 15, weight = 0.4, down = true },
-	Kind.LASER: { color = Params.COLOR_RED, glyph = "L", unlock = 20, weight = 0.6 },
-	Kind.FREEZE: { color = Color("#d6f3ff"), glyph = "*", unlock = 25, weight = 0.6 },
-	Kind.BOMB: { color = Params.COLOR_BOMB, glyph = "B", unlock = 30, weight = 0.6 },
-	Kind.HOMING: { color = Color("#ff8a8a"), glyph = "H", unlock = 40, weight = 0.5 },
+	Kind.TRIPLE_BALLS: { name = "Triple balls", color = Params.COLOR_PINK, glyph = "3", unlock = 0, weight = 0.0 },
+	Kind.PADDLE: { name = "Paddle size", color = Params.COLOR_BLUE, glyph = "P", unlock = 0, weight = 1.0 },
+	Kind.SUPERBALL: { name = "Super ball", color = Params.COLOR_ORANGE, glyph = "X", unlock = 0, weight = 0.6 },
+	Kind.SLOW: { name = "Slow-mo", color = Color("#5fb3ff"), glyph = "Z", unlock = 2, weight = 0.8 },
+	Kind.CATCH: { name = "Catch", color = Color("#8be28b"), glyph = "C", unlock = 4, weight = 0.8 },
+	Kind.SHIELD: { name = "Shield", color = Color("#f4f4f0"), glyph = "U", unlock = 6, weight = 0.7 },
+	Kind.BIG_BALL: { name = "Big ball", color = Color("#c9c9c9"), glyph = "O", unlock = 8, weight = 0.7 },
+	Kind.FIREBALL: { name = "Fireball", color = Params.COLOR_FIRE, glyph = "F", unlock = 12, weight = 0.6 },
+	Kind.RUSH: { name = "Rush", color = Params.COLOR_PINK, glyph = "!", unlock = 15, weight = 0.4, down = true },
+	Kind.LASER: { name = "Laser", color = Params.COLOR_RED, glyph = "L", unlock = 20, weight = 0.6 },
+	Kind.FREEZE: { name = "Freeze", color = Color("#d6f3ff"), glyph = "*", unlock = 25, weight = 0.6 },
+	Kind.BOMB: { name = "Bomb ball", color = Params.COLOR_BOMB, glyph = "B", unlock = 30, weight = 0.6 },
+	Kind.HOMING: { name = "Homing", color = Color("#ff8a8a"), glyph = "H", unlock = 40, weight = 0.5 },
 }
 
-var kind := Kind.SAVE
+var kind := Kind.PADDLE
 
 
 func _ready() -> void:
@@ -52,7 +53,7 @@ static func weight(kind: Kind, level: int) -> float:
 
 func _on_body_entered(body: Node) -> void:
 	if body is Paddle:
-		collected.emit(self)
+		collected.emit(kind)
 		queue_free()
 	elif body.is_in_group("bottom"):
 		queue_free()
@@ -63,8 +64,7 @@ func _draw() -> void:
 	draw_circle(Vector2.ZERO, RADIUS, color, true, -1.0, true)
 	# Dark letters on light tokens, light letters on dark ones.
 	var ink := Params.COLOR_BACK if color.get_luminance() > 0.5 else Color.WHITE
-	var size := 14
 	var text: String = KINDS[kind].glyph
-	var text_size := FONT.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
-	draw_string(FONT, Vector2(-text_size.x / 2.0, FONT.get_ascent(size) / 2.0 - 1.0), text,
-		HORIZONTAL_ALIGNMENT_LEFT, -1, size, ink)
+	var text_size := FONT.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, GLYPH_SIZE)
+	draw_string(FONT, Vector2(-text_size.x / 2.0, FONT.get_ascent(GLYPH_SIZE) / 2.0 - 1.0), text,
+		HORIZONTAL_ALIGNMENT_LEFT, -1, GLYPH_SIZE, ink)

+ 55 - 12
scripts/progress.gd

@@ -1,25 +1,29 @@
 extends Node
-## Saved level, which drops by one for every Params.LOSE_HOURS the player stays away.
+## What the player keeps between sessions: the level reached (saved on every win, and
+## dropping by one for every Params.LOSE_HOURS away), best stars per level, and settings.
 
 const PATH := "user://progress.cfg"
 
 ## Level the next game scene starts at.
 var current_level := 0
 
+var _config := ConfigFile.new()
+
+
+func _ready() -> void:
+	_config.load(PATH)
+
 
 func save_level(level: int) -> void:
-	var config := ConfigFile.new()
-	config.set_value("progress", "level", level)
-	config.set_value("progress", "time", Time.get_unix_time_from_system())
-	config.save(PATH)
+	_config.set_value("progress", "level", level)
+	_config.set_value("progress", "time", Time.get_unix_time_from_system())
+	_config.save(PATH)
 
 
 ## Applies any decay since the last save and returns [level, hours until the next decay].
 func load_level() -> Array:
-	var config := ConfigFile.new()
-	config.load(PATH)
-	var level: int = config.get_value("progress", "level", 0)
-	var last: float = config.get_value("progress", "time", 0.0)
+	var level: int = _config.get_value("progress", "level", 0)
+	var last: float = _config.get_value("progress", "time", 0.0)
 	var now := Time.get_unix_time_from_system()
 	var period := 3600.0 * Params.LOSE_HOURS
 	var remaining := period + last - now
@@ -27,7 +31,46 @@ func load_level() -> Array:
 		last += period
 		level -= 1
 		remaining = period + last - now
-	config.set_value("progress", "level", level)
-	config.set_value("progress", "time", last)
-	config.save(PATH)
+	_config.set_value("progress", "level", level)
+	_config.set_value("progress", "time", last)
+	_config.save(PATH)
 	return [level, remaining / 3600.0]
+
+
+## Keeps the best star count (1 to 3) for a level; returns true if it is a new best.
+func save_stars(level: int, stars: int) -> bool:
+	var key := str(level)
+	if stars <= _config.get_value("stars", key, 0):
+		return false
+	_config.set_value("stars", key, stars)
+	_config.save(PATH)
+	return true
+
+
+func total_stars() -> int:
+	var total := 0
+	if _config.has_section("stars"):
+		for key in _config.get_section_keys("stars"):
+			total += int(_config.get_value("stars", key))
+	return total
+
+
+func save_best_score(score: int) -> bool:
+	if score <= best_score():
+		return false
+	_config.set_value("progress", "best_score", score)
+	_config.save(PATH)
+	return true
+
+
+func best_score() -> int:
+	return _config.get_value("progress", "best_score", 0)
+
+
+func music_on() -> bool:
+	return _config.get_value("settings", "music", true)
+
+
+func set_music_on(on: bool) -> void:
+	_config.set_value("settings", "music", on)
+	_config.save(PATH)

+ 70 - 0
scripts/stars.gd

@@ -0,0 +1,70 @@
+class_name Stars
+extends Control
+## A row of stars, [member filled] of [member count] lit. Earned stars pop in one by one
+## when [method reveal] is called.
+
+@export var count := 3:
+	set(value):
+		count = value
+		_resize()
+@export var filled := 0:
+	set(value):
+		filled = value
+		queue_redraw()
+@export var star_size := 36.0:
+	set(value):
+		star_size = value
+		_resize()
+@export var spacing := 10.0
+
+## Per-star scale while popping in.
+var _pop: Array[float] = [1.0, 1.0, 1.0]
+
+
+func _ready() -> void:
+	_resize()
+
+
+## Lights the first [param stars] stars one after another with a small pop.
+func reveal(stars: int) -> void:
+	filled = stars
+	_pop.resize(count)
+	_pop.fill(0.0)
+	var tween := create_tween()
+	for i in count:
+		tween.tween_method(_set_pop.bind(i), 0.0, 1.0, 0.25).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
+
+
+func _set_pop(value: float, index: int) -> void:
+	_pop[index] = value
+	queue_redraw()
+
+
+func _resize() -> void:
+	custom_minimum_size = Vector2(count * star_size + (count - 1) * spacing, star_size)
+	_pop.resize(count)
+	for i in count:
+		if _pop[i] == 0.0:
+			_pop[i] = 1.0
+	queue_redraw()
+
+
+func _draw() -> void:
+	var start := (size.x - custom_minimum_size.x) / 2.0
+	for i in count:
+		var center := Vector2(start + star_size / 2.0 + i * (star_size + spacing), size.y / 2.0)
+		var lit := i < filled
+		var color := Params.COLOR_ORANGE if lit else Color(1, 1, 1, 0.15)
+		var points := _star_points(center, star_size / 2.0 * (_pop[i] if lit else 1.0))
+		draw_colored_polygon(points, color)
+		points.append(points[0])
+		draw_polyline(points, color, 1.0, true)
+
+
+static func _star_points(center: Vector2, radius: float) -> PackedVector2Array:
+	var points := PackedVector2Array()
+	for i in 10:
+		var r := radius if i % 2 == 0 else radius * 0.45
+		var angle := -PI / 2.0 + i * PI / 5.0
+		points.append(center + Vector2(cos(angle), sin(angle)) * r)
+	return points

+ 1 - 0
scripts/stars.gd.uid

@@ -0,0 +1 @@
+uid://by3k7hy70xm00

+ 75 - 6
scripts/title.gd

@@ -1,15 +1,27 @@
 extends Node2D
-## Title screen: a big core with balls bouncing around it, and the saved level.
+## Title screen: the big core with turning rings and bouncing balls, the player's progress,
+## what unlocks next, and a music toggle.
 
 const BALL_SCENE := preload("res://scenes/ball.tscn")
+const BALLS := 6
+const RING_SPEEDS := [12.0, -8.0] # degrees per second, one per decorative ring
+## Kinds sprinkled into the decorative rings, as a taste of what is coming.
+const SHOWCASE := [
+	Block.Kind.INDESTRUCTIBLE, Block.Kind.WATER, Block.Kind.ICE, Block.Kind.TNT,
+	Block.Kind.RUBBER, Block.Kind.MOSS,
+]
 
 var _level := 0
+var _rings: Array[Node2D] = []
+
+@onready var core: Core = $Core
 
 
 func _ready() -> void:
 	var screen := get_viewport_rect().size
-	$Core.position = Vector2(screen.x / 2.0, screen.y * 0.25)
-	for i in 10:
+	core.position = Vector2(screen.x / 2.0, screen.y * 0.28)
+	_add_rings()
+	for i in BALLS:
 		var ball: Ball = BALL_SCENE.instantiate()
 		ball.position = Vector2(screen.x / 2.0, screen.y - Params.BALL_RADIUS * 2.0)
 		$Balls.add_child(ball)
@@ -17,11 +29,68 @@ func _ready() -> void:
 
 	var progress := Progress.load_level()
 	_level = progress[0]
+	$UI/Info/Level.text = "Level %d" % _level
+	$UI/Info/Totals/Count.text = str(Progress.total_stars())
+	var best := Progress.best_score()
+	$UI/Info/Best.text = "Best score %d" % best if best > 0 else ""
+	$UI/Play.text = "Play" if _level == 0 else "Continue"
+	$UI/Next.text = _next_unlock(_level)
 	if _level != 0:
-		$UI/LevelInfo.text = Params.MSG_LEVEL_BACK % [_level, progress[1]]
-	$UI/Start.pressed.connect(_on_start_pressed)
+		$UI/Decay.text = Params.MSG_LEVEL_BACK % [_level, progress[1]]
+	$UI/Play.pressed.connect(_on_play_pressed)
+	$UI/MusicToggle.pressed.connect(_on_music_toggled)
+	_show_music_state()
+	Music.play("menu")
+
+
+func _physics_process(delta: float) -> void:
+	for i in _rings.size():
+		_rings[i].rotation_degrees += RING_SPEEDS[i] * delta
+
+
+## Two thin rings of segments around the core, mostly normal with a few special kinds.
+## They are only decoration: no collisions.
+func _add_rings() -> void:
+	var inner := core.radius + 6.0
+	for r in RING_SPEEDS.size():
+		var ring := Node2D.new()
+		ring.position = core.position
+		var segments := 12 + r * 4
+		var step := TAU / segments
+		for i in segments:
+			var kind: Block.Kind = SHOWCASE[(i + r * 3) % SHOWCASE.size()] if i % 3 == 0 else Block.Kind.NORMAL
+			var block := Block.new(inner + r * 30.0, inner + r * 30.0 + 26.0, i * step, (i + 1) * step, kind)
+			block.collision_layer = 0
+			block.set_physics_process(false)
+			ring.add_child(block)
+		add_child(ring)
+		move_child(ring, core.get_index())
+		_rings.append(ring)
+
 
+## "Next: Fireball at level 12" for the next power or segment to unlock after [param level].
+static func _next_unlock(level: int) -> String:
+	var best_level := -1
+	var best_name := ""
+	for info: Dictionary in PowerUp.KINDS.values() + Block.KINDS.values():
+		var unlock: int = info.unlock
+		if unlock > level and (best_level < 0 or unlock < best_level):
+			best_level = unlock
+			best_name = info.name
+	if best_level < 0:
+		return "Everything unlocked. How far can you go?"
+	return "Next: %s at level %d" % [best_name, best_level]
 
-func _on_start_pressed() -> void:
+
+func _on_play_pressed() -> void:
 	Progress.current_level = _level
 	get_tree().change_scene_to_file("res://scenes/game.tscn")
+
+
+func _on_music_toggled() -> void:
+	Music.set_enabled(not Progress.music_on())
+	_show_music_state()
+
+
+func _show_music_state() -> void:
+	$UI/MusicToggle.text = "Music: %s" % ("on" if Progress.music_on() else "off")

+ 182 - 0
tools/make_music.py

@@ -0,0 +1,182 @@
+"""Synthesises RedCore's two music loops and writes them as Ogg Vorbis.
+
+    python3 tools/make_music.py      # needs numpy and ffmpeg
+
+menu.ogg: calm, 90 BPM, soft pad and plucked arpeggio.
+game.ogg: driving, 120 BPM, drums, bass, arpeggio and a simple lead.
+Both use the chords Am - F - C - G and are an exact number of bars long; echo tails
+wrap around to the start, so they loop without a seam.
+"""
+import pathlib
+import subprocess
+import tempfile
+import wave
+
+import numpy as np
+
+RATE = 44100
+OUT = pathlib.Path(__file__).resolve().parent.parent / "assets" / "music"
+
+# Chord roots and their triads as MIDI notes (A minor, F, C, G).
+CHORDS = [(57, [57, 60, 64]), (53, [53, 57, 60]), (48, [48, 52, 55]), (55, [55, 59, 62])]
+
+
+def freq(midi):
+    return 440.0 * 2 ** ((midi - 69) / 12)
+
+
+def envelope(n, attack, decay, sustain, release):
+    """ADSR envelope over n samples; times in seconds, sustain as a level."""
+    a, d, r = (int(x * RATE) for x in (attack, decay, release))
+    env = np.full(n, sustain)
+    a = min(a, n)
+    env[:a] = np.linspace(0, 1, a, endpoint=False)
+    d = min(d, n - a)
+    env[a:a + d] = np.linspace(1, sustain, d, endpoint=False)
+    r = min(r, n)
+    env[n - r:] *= np.linspace(1, 0, r)
+    return env
+
+
+def tone(midi, seconds, harmonics=(1.0,), detune=0.0):
+    """Additive oscillator: a weighted sum of harmonics, optionally two detuned voices."""
+    t = np.arange(int(seconds * RATE)) / RATE
+    f = freq(midi)
+    out = np.zeros_like(t)
+    for k, weight in enumerate(harmonics, start=1):
+        out += weight * np.sin(2 * np.pi * f * k * t)
+        if detune:
+            out += weight * np.sin(2 * np.pi * f * k * (1 + detune) * t)
+    return out / (sum(harmonics) * (2 if detune else 1))
+
+
+def add(track, sound, at):
+    """Mixes sound into track at sample offset at, wrapping past the end (seamless loop)."""
+    n = len(track)
+    start = int(at) % n
+    end = start + len(sound)
+    if end <= n:
+        track[start:end] += sound
+    else:
+        track[start:] += sound[:n - start]
+        track[:end - n] += sound[n - start:]
+
+
+def echo(track, delay, feedback, mix):
+    """Circular feedback delay, so the tail lands back at the loop start."""
+    out = track.copy()
+    shift = int(delay * RATE)
+    tap = track.copy()
+    for _ in range(6):
+        tap = np.roll(tap, shift) * feedback
+        out += tap * mix
+    return out
+
+
+def lowpass(signal, cutoff):
+    """One-pole low-pass filter, run twice for a gentler 12 dB/octave slope."""
+    alpha = 1 - np.exp(-2 * np.pi * cutoff / RATE)
+    out = signal
+    for _ in range(2):
+        y = np.empty_like(out)
+        acc = 0.0
+        for i, x in enumerate(out):
+            acc += alpha * (x - acc)
+            y[i] = acc
+        out = y
+    return out
+
+
+def kick(seconds=0.35):
+    t = np.arange(int(seconds * RATE)) / RATE
+    pitch = 50 + 90 * np.exp(-t * 30)
+    fade = np.minimum(1.0, (seconds - t) / 0.05)  # no click where it ends
+    return np.sin(2 * np.pi * np.cumsum(pitch) / RATE) * np.exp(-t * 9) * fade
+
+
+def noise_hit(seconds, decay, low, high, rng):
+    """A decaying noise burst band-limited to [low, high] Hz."""
+    n = int(seconds * RATE)
+    hit = rng.uniform(-1, 1, n) * np.exp(-np.arange(n) / RATE * decay)
+    band = lowpass(hit, high)
+    return band - lowpass(band, low)
+
+
+def menu_track():
+    bpm, bars = 90, 8
+    beat = 60 / bpm
+    track = np.zeros(int(bars * 4 * beat * RATE))
+    for bar in range(bars):
+        root, triad = CHORDS[(bar // 2) % 4]
+        start = bar * 4 * beat * RATE
+        if bar % 2 == 0:  # a pad chord held for two bars
+            for note in triad:
+                pad = tone(note, 8 * beat, (1.0, 0.3, 0.1), detune=0.004)
+                add(track, 0.10 * pad * envelope(len(pad), 1.2, 0.5, 0.8, 1.5), start)
+            bass = tone(root - 12, 8 * beat, (1.0, 0.2))
+            add(track, 0.16 * bass * envelope(len(bass), 0.3, 1.0, 0.6, 1.0), start)
+        arp = triad + [triad[1] + 12, triad[2] + 12, triad[1] + 12]
+        for i in range(8):  # plucked eighth notes
+            note = arp[i % len(arp)] + 12
+            pluck = tone(note, 1.2, (1.0, 0.5, 0.25, 0.1))
+            add(track, 0.07 * pluck * envelope(len(pluck), 0.005, 0.4, 0.0, 0.3), start + i * beat / 2 * RATE)
+    return echo(track, beat * 0.75, 0.45, 0.35)
+
+
+def game_track():
+    bpm, bars = 120, 16
+    beat = 60 / bpm
+    rng = np.random.default_rng(7)
+    track = np.zeros(int(bars * 4 * beat * RATE))
+    drums = np.zeros_like(track)
+    hat = noise_hit(0.06, 70, 5000, 9000, rng)
+    snare = noise_hit(0.22, 20, 900, 5000, rng)
+    lead_notes = [76, 74, 72, 74, 76, 79, 76, 74]  # one phrase, transposed per chord
+    for bar in range(bars):
+        root, triad = CHORDS[bar % 4]
+        start = bar * 4 * beat * RATE
+        for b in range(4):
+            add(drums, 0.40 * kick(), start + b * beat * RATE)
+            if b in (1, 3):
+                add(drums, 0.35 * snare, start + b * beat * RATE)
+        for e in range(8):
+            add(drums, (0.12 if e % 2 else 0.07) * hat, start + e * beat / 2 * RATE)
+            bass = tone(root - 12, beat / 2, (1.0, 0.6, 0.3, 0.15))
+            add(track, 0.30 * bass * envelope(len(bass), 0.005, 0.1, 0.6, 0.05), start + e * beat / 2 * RATE)
+        for note in triad:  # a quiet pad for body
+            pad = tone(note, 4 * beat, (1.0, 0.3, 0.1), detune=0.004)
+            add(track, 0.05 * pad * envelope(len(pad), 0.3, 0.5, 0.7, 0.4), start)
+        arp = triad + [triad[1] + 12]
+        for s in range(16):
+            note = arp[s % len(arp)] + 12
+            pluck = tone(note, beat / 2, (1.0, 0.0, 0.33, 0.0, 0.2))  # square-ish
+            add(track, 0.08 * pluck * envelope(len(pluck), 0.003, 0.08, 0.0, 0.05), start + s * beat / 4 * RATE)
+        if bar >= 8:  # the lead joins for the second half
+            shift = root - 57
+            for i, note in enumerate(lead_notes):
+                lead = tone(note + shift, beat / 2, (1.0, 0.4, 0.2), detune=0.003)
+                add(track, 0.12 * lead * envelope(len(lead), 0.01, 0.1, 0.7, 0.08), start + i * beat / 2 * RATE)
+    return echo(track, beat * 0.75, 0.3, 0.25) + 0.8 * drums
+
+
+def write_ogg(signal, name):
+    # Gentle stereo: the same mix with a tiny delay on the right channel.
+    right = np.roll(signal, int(0.012 * RATE))
+    stereo = np.stack([signal, right], axis=1)
+    stereo /= np.max(np.abs(stereo)) / 0.89  # about -1 dBFS
+    pcm = (stereo * 32767).astype(np.int16)
+    OUT.mkdir(parents=True, exist_ok=True)
+    with tempfile.NamedTemporaryFile(suffix=".wav") as tmp:
+        with wave.open(tmp.name, "wb") as w:
+            w.setnchannels(2)
+            w.setsampwidth(2)
+            w.setframerate(RATE)
+            w.writeframes(pcm.tobytes())
+        subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", tmp.name,
+                        "-c:a", "libvorbis", "-q:a", "4", str(OUT / name)], check=True)
+    print(f"{name}: {len(signal) / RATE:.1f} s")
+
+
+if __name__ == "__main__":
+    write_ogg(menu_track(), "menu.ogg")
+    write_ogg(game_track(), "game.ogg")