Quellcode durchsuchen

Add 10 powers, 7 segment kinds and a level progression that only gets harder

Powers: the super ball now only drops while an indestructible segment
is left. Ten new powers unlock between levels 2 and 40: slow-mo, catch,
shield, big ball, fireball, rush (a power-down), laser, freeze, bomb
ball and homing. Power-ups fall as lettered tokens and are picked by
weight from those unlocked; power-downs get likelier with the level.

Segments: a new kind every 50 levels, from 50 to 350: water (drains
over 3 hits and holds the ball briefly), ice, TNT (chain explosions),
rubber, ghost (phases in and out), moss (grows back once) and magnet.
Their share of each ring grows from 8% at level 50 to 50%.

Progression: the old generator cycled, dropping back to one ring at
level 120. Rings, ring speed, launch and top ball speed, indestructible
and special-segment share and power-down weight now only rise with the
level. The outer ring stays 6 ball diameters above the paddle, with
the core moving up to make room, so bounces off it stay catchable.

Paddle aiming: the ball leaves the paddle at an angle set by where it
hit (up to 60 degrees at the tips). This also fixes balls trapped
bouncing straight up and down between the paddle and the top wall.

Also rewords the level-decay message, which said "regrow" when the
saved level actually drops, and updates the README.

Balanced with bot simulations on a 540x1200 (Pixel 20:9) playfield:
win rate per attempt falls from 100% to 17% by level 400, about 17
hours of play to reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gabriel Capella vor 5 Tagen
Ursprung
Commit
be34c4e06a
13 geänderte Dateien mit 812 neuen und 145 gelöschten Zeilen
  1. 35 18
      README.md
  2. 0 4
      scenes/ball.tscn
  3. 7 0
      scenes/blast.tscn
  4. 1 1
      scenes/power_up.tscn
  5. 155 11
      scripts/ball.gd
  6. 24 0
      scripts/blast.gd
  7. 1 0
      scripts/blast.gd.uid
  8. 177 18
      scripts/block.gd
  9. 82 30
      scripts/blocks.gd
  10. 123 40
      scripts/game.gd
  11. 81 8
      scripts/paddle.gd
  12. 81 6
      scripts/params.gd
  13. 45 9
      scripts/power_up.gd

+ 35 - 18
README.md

@@ -33,14 +33,25 @@ Alguns detalhes que devem ser mencionados sobre a implementação:
   é conquistar o máximo de níveis possíveis numa jogatina
 
 ### 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.
+Os power-ups caem como fichas com uma letra; pegue com a pá para usar.
+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 |
+| Z | Câmera lenta | 2 | Bolas a 60% da velocidade por 8 s |
+| C | Pegar | 4 | A pá segura a bola por 12 s; solte o dedo para lançar |
+| U | Escudo | 6 | Uma barreira no fundo salva uma bola |
+| O | Bola grande | 8 | Bola 60% maior por 10 s |
+| F | Bola de fogo | 12 | Atravessa e quebra blocos por 5 s |
+| ! | Pressa (negativo) | 15 | Bolas 35% mais rápidas por 6 s |
+| L | Laser | 20 | A pá atira dois lasers por 6 s |
+| * | Congelar | 25 | Os anéis param de girar por 8 s |
+| B | Bola bomba | 30 | O próximo bloco atingido explode |
+| H | Teleguiada | 40 | A bola curva em direção ao núcleo por 6 s |
 
 ### Desenvolvimento
 O jogo foi desenvolvido originalmente com o framework cocos2d-x, em C++,
@@ -55,16 +66,22 @@ efeitos e sons). Para editar, abra o `project.godot` no editor Godot.
 - `assets/`: fonte e sons
 
 ### Fases Aleatórias
-Para deixar o jogo mais legal criamos fases aletórias. São infinitas fases.
-
-Comforme o nível são geradas são geradas fases na seguinte sequência: 1 fase
-de uma camada, 2 de duas camada, 3 de 3 camadas, até 8 fases de 8. Quando a
-oitava fase de 8 camadas é completa, o tamanho da camada é dividido por 1.5
-e o ciclo se repete, mas agora com no máximo 12 camadas. 
-
-Fases com o a mesma quantidade de camadas são destintas pois a probabilidade
-de blocos indestrutíveis aumenta conforme o jogador chega mais perto da
-última.
+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%:
+
+| 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 |
 
 ## Build
 O build roda dentro do Docker, com o Godot em modo headless. Antes de

+ 0 - 4
scenes/ball.tscn

@@ -2,9 +2,6 @@
 
 [ext_resource type="Script" path="res://scripts/ball.gd" id="1_ball"]
 
-[sub_resource type="CircleShape2D" id="CircleShape2D_ball"]
-radius = 15.0
-
 [node name="Ball" type="CharacterBody2D"]
 collision_layer = 8
 collision_mask = 7
@@ -12,4 +9,3 @@ motion_mode = 1
 script = ExtResource("1_ball")
 
 [node name="CollisionShape2D" type="CollisionShape2D" parent="."]
-shape = SubResource("CircleShape2D_ball")

+ 7 - 0
scenes/blast.tscn

@@ -0,0 +1,7 @@
+[gd_scene format=3]
+
+[ext_resource type="Script" path="res://scripts/blast.gd" id="1_blast"]
+
+[node name="Blast" type="Node2D"]
+z_index = 1
+script = ExtResource("1_blast")

+ 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 = 5.0
+radius = 9.0
 
 [node name="PowerUp" type="RigidBody2D"]
 collision_layer = 16

+ 155 - 11
scripts/ball.gd

@@ -1,47 +1,175 @@
 class_name Ball
 extends CharacterBody2D
 ## A ball that bounces off everything, speeds up slightly on each bounce and falls under
-## light gravity.
+## light gravity. Power-ups change it for a while (see the timed effects below).
 
 ## Emitted for every contact, so the game can react to what the ball touched.
 signal hit(ball: Ball, other: Node)
 
+const BIG_SCALE := 1.6
+const HOMING_TURN := 1.5 # radians per second towards the target
+
 ## A superball destroys one indestructible block, then turns back into a normal ball.
 var superball := false:
 	set(value):
 		superball = value
 		queue_redraw()
+## The next block hit explodes.
+var bomb := false:
+	set(value):
+		bomb = value
+		queue_redraw()
+## Fire burns through breakable blocks without bouncing.
+var fire: bool:
+	get:
+		return _fire_time > 0.0
+## What homing steers towards (the core).
+var target: Node2D
+var radius := Params.BALL_RADIUS
+## Bounces and speed-ups stop here; the game raises it with the level.
+var max_speed := Params.max_ball_speed(0)
+
+var _fire_time := 0.0
+var _big_time := 0.0
+var _homing_time := 0.0
+var _time_scale := 1.0
+var _time_scale_left := 0.0
+var _stuck_to: Node2D
+var _stuck_offset := Vector2.ZERO
+var _stuck_time := 0.0
+var _stuck_slowdown := 1.0
+var _shape := CircleShape2D.new()
 
 
-func throw() -> void:
+func _ready() -> void:
+	add_to_group("balls")
+	_shape.radius = radius
+	$CollisionShape2D.shape = _shape
+
+
+func throw(speed := Params.INITIAL_SPEED) -> void:
 	var angle := deg_to_rad(randf_range(-Params.THROW_ANGLE, Params.THROW_ANGLE))
-	velocity = Vector2(sin(angle), -cos(angle)) * Params.INITIAL_SPEED
+	velocity = Vector2(sin(angle), -cos(angle)) * speed
+
+
+func set_fire(seconds: float) -> void:
+	_fire_time = maxf(_fire_time, seconds)
+	queue_redraw()
+
+
+func set_big(seconds: float) -> void:
+	_big_time = maxf(_big_time, seconds)
+	_set_radius(Params.BALL_RADIUS * BIG_SCALE)
+
+
+func set_homing(seconds: float) -> void:
+	_homing_time = maxf(_homing_time, seconds)
+	queue_redraw()
+
+
+## Runs the ball faster (> 1) or slower (< 1) for a while; the path stays the same.
+func set_time_scale(value: float, seconds: float) -> void:
+	_time_scale = value
+	_time_scale_left = seconds
+	queue_redraw()
+
+
+## Holds the ball on [param node] (a paddle or a water segment), moving with it, for
+## [param seconds]; then it leaves with its speed times [param slowdown].
+func stick_to(node: Node2D, seconds: float, slowdown := 1.0) -> void:
+	_stuck_to = node
+	_stuck_offset = node.to_local(global_position)
+	_stuck_time = seconds
+	_stuck_slowdown = slowdown
+
+
+func is_stuck_to(node: Node2D) -> bool:
+	return _stuck_to == node
+
+
+## Sends the ball up off the paddle at an angle set by where it hit: straight up from the
+## centre, up to PADDLE_MAX_ANGLE from vertical at the tips. Keeps its speed.
+func aim_from(paddle: Paddle, speed := velocity.length()) -> void:
+	var side := clampf((global_position.x - paddle.global_position.x) / paddle.half_width(), -1.0, 1.0)
+	var angle := deg_to_rad(side * Params.PADDLE_MAX_ANGLE)
+	velocity = Vector2(sin(angle), -cos(angle)) * speed
+
+
+## Lets go of whatever holds it. Off the paddle, the angle depends on where it sat.
+func release() -> void:
+	if _stuck_to == null:
+		return
+	if _stuck_to is Paddle and is_instance_valid(_stuck_to):
+		aim_from(_stuck_to as Paddle, maxf(velocity.length(), Params.INITIAL_SPEED))
+	velocity *= _stuck_slowdown
+	_stuck_to = null
 
 
 func _physics_process(delta: float) -> void:
-	velocity.y += Params.BALL_GRAVITY * delta
-	var motion := velocity * delta
+	_tick_effects(delta)
+	if _stuck_to != null:
+		if not is_instance_valid(_stuck_to) or _stuck_to.is_queued_for_deletion():
+			_stuck_to = null
+		else:
+			global_position = _stuck_to.to_global(_stuck_offset)
+			_stuck_time -= delta
+			if _stuck_time <= 0.0:
+				release()
+			return
+
+	var dt := delta * _time_scale
+	velocity.y += Params.BALL_GRAVITY * dt
+	if _homing_time > 0.0 and is_instance_valid(target):
+		var turn := velocity.angle_to(target.global_position - global_position)
+		velocity = velocity.rotated(clampf(turn, -HOMING_TURN * dt, HOMING_TURN * dt))
+	var motion := velocity * dt
 	for i in 4:
 		var collision := move_and_collide(motion)
 		if collision == null:
 			break
 		var other := collision.get_collider() as Node
 		var normal := collision.get_normal()
-		var bounce: float = other.get_meta("bounce", 1.0)
-		velocity = velocity.bounce(normal) * Params.BALL_BOUNCE * bounce
-		motion = collision.get_remainder().bounce(normal)
+		# Fire burns through breakable blocks: no bounce, keep going.
+		var burn := fire and other is Block and (other as Block).is_breakable()
+		if not burn:
+			var bounce: float = other.get_meta("bounce", 1.0)
+			velocity = velocity.bounce(normal) * Params.BALL_BOUNCE * bounce
+			motion = collision.get_remainder().bounce(normal)
+		else:
+			motion = collision.get_remainder()
 		hit.emit(self, other)
-		if is_queued_for_deletion():
+		velocity = velocity.limit_length(max_speed)
+		if is_queued_for_deletion() or _stuck_to != null:
 			return
 	_kick_power_ups()
 
 
+func _tick_effects(delta: float) -> void:
+	var was := [_fire_time > 0.0, _big_time > 0.0, _homing_time > 0.0, _time_scale_left > 0.0]
+	_fire_time = maxf(_fire_time - delta, 0.0)
+	_big_time = maxf(_big_time - delta, 0.0)
+	_homing_time = maxf(_homing_time - delta, 0.0)
+	_time_scale_left = maxf(_time_scale_left - delta, 0.0)
+	if was[1] and _big_time == 0.0:
+		_set_radius(Params.BALL_RADIUS)
+	if was[3] and _time_scale_left == 0.0:
+		_time_scale = 1.0
+	if was != [_fire_time > 0.0, _big_time > 0.0, _homing_time > 0.0, _time_scale_left > 0.0]:
+		queue_redraw()
+
+
+func _set_radius(value: float) -> void:
+	radius = value
+	_shape.radius = value
+	queue_redraw()
+
+
 ## Power-ups are 1/1000 of a ball's mass: they get knocked away and the ball doesn't notice.
 func _kick_power_ups() -> void:
 	for power_up: PowerUp in get_tree().get_nodes_in_group("power_ups"):
 		var offset := power_up.global_position - global_position
 		var dist := offset.length()
-		if dist == 0.0 or dist > Params.BALL_RADIUS + PowerUp.RADIUS:
+		if dist == 0.0 or dist > radius + PowerUp.RADIUS:
 			continue
 		var normal := offset / dist
 		var approach := (power_up.linear_velocity - velocity).dot(normal)
@@ -51,4 +179,20 @@ func _kick_power_ups() -> void:
 
 
 func _draw() -> void:
-	draw_circle(Vector2.ZERO, Params.BALL_RADIUS, Params.COLOR_ORANGE if superball else Params.COLOR_GREY, true, -1.0, true)
+	var fill := Params.COLOR_GREY
+	if superball:
+		fill = Params.COLOR_ORANGE
+	elif fire:
+		fill = Params.COLOR_FIRE
+	elif bomb:
+		fill = Params.COLOR_BOMB
+	draw_circle(Vector2.ZERO, radius, fill, true, -1.0, true)
+	if bomb:
+		draw_circle(Vector2.ZERO, radius - 1.5, Params.COLOR_ORANGE, false, 3.0, true)
+	# Thin rings show timed effects: homing in red, slow-mo in blue, rush in pink.
+	var ring := radius + 4.0
+	if _homing_time > 0.0:
+		draw_circle(Vector2.ZERO, ring, Params.COLOR_RED, false, 2.0, true)
+		ring += 4.0
+	if _time_scale_left > 0.0:
+		draw_circle(Vector2.ZERO, ring, Params.COLOR_BLUE if _time_scale < 1.0 else Params.COLOR_PINK, false, 2.0, true)

+ 24 - 0
scripts/blast.gd

@@ -0,0 +1,24 @@
+extends Node2D
+## An expanding ring that fades out, drawn where something exploded.
+
+const SECONDS := 0.3
+
+var radius := 80.0
+var _progress := 0.0:
+	set(value):
+		_progress = value
+		queue_redraw()
+
+
+func _ready() -> void:
+	var tween := create_tween()
+	tween.tween_property(self, "_progress", 1.0, SECONDS).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
+	tween.tween_callback(queue_free)
+
+
+func _draw() -> void:
+	var color := Params.COLOR_ORANGE
+	color.a = 1.0 - _progress
+	draw_circle(Vector2.ZERO, radius * _progress, color, false, 6.0 * (1.0 - _progress) + 1.0, true)
+	color.a *= 0.25
+	draw_circle(Vector2.ZERO, radius * _progress, color, true, -1.0, true)

+ 1 - 0
scripts/blast.gd.uid

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

+ 177 - 18
scripts/block.gd

@@ -1,14 +1,56 @@
 class_name Block
 extends AnimatableBody2D
-## One arc-shaped segment of a ring around the core.
+## 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 a TNT segment breaks; the rings turn it into an explosion.
+signal detonated(block: Block)
+
+enum Kind { NORMAL, INDESTRUCTIBLE, WATER, ICE, TNT, RUBBER, GHOST, MOSS, MAGNET }
+
+## What [method strike] did, so the game can pick a sound.
+enum Hit { BROKE, DAMAGED, BLOCKED }
 
 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
 
-var indestructible := false
+## Per kind: first level it can appear, hits to break (0 = unbreakable) and flat colour.
+## 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") },
+}
+
+const WATER_STICK_SECONDS := 0.35
+const WATER_SLOWDOWN := 0.85
+const ICE_SPEEDUP := 1.08
+const RUBBER_SPEEDUP := 1.15
+const GHOST_SOLID_SECONDS := 2.0
+const GHOST_PHASED_SECONDS := 1.2
+const MOSS_REGROW_SECONDS := 8.0 # moss grows back once, this long after breaking
+const MAGNET_RADIUS := 150.0
+const MAGNET_PULL := 700.0 # px/s² at the segment, fading to 0 at MAGNET_RADIUS
+
+var kind := Kind.NORMAL
+var hp := 1
 var color := Params.COLOR_BLUE
+var _inner := 0.0
+var _outer := 0.0
+var _begin := 0.0
+var _end := 0.0
 var _polygon := PackedVector2Array()
+var _collision := CollisionPolygon2D.new()
+var _cracks := PackedVector2Array()
+var _ghost_clock := 0.0
+var _regrown := false
 ## 0 to 1: how far the tile is washed towards white while breaking.
 var _flash := 0.0:
 	set(value):
@@ -16,49 +58,166 @@ var _flash := 0.0:
 		queue_redraw()
 
 
-func _init(inner: float, outer: float, begin: float, end: float, is_indestructible: bool) -> void:
+func _init(inner: float, outer: float, begin: float, end: float, segment_kind: Kind) -> void:
 	sync_to_physics = false
 	collision_layer = Params.LAYER_BLOCKS
 	collision_mask = 0
-	indestructible = is_indestructible
+	_inner = inner
+	_outer = outer
+	_begin = begin
+	_end = end
+	add_child(_collision)
+	set_kind(segment_kind)
+	_ghost_clock = randf() * (GHOST_SOLID_SECONDS + GHOST_PHASED_SECONDS)
 
-	# Collision uses the drawn outline, so the ball bounces exactly where it touches.
-	_polygon = _round_corners(_sector(inner + GAP / 2.0, outer - GAP / 2.0, begin, end, GAP / 2.0))
-	# Very short segments can leave a sliver too thin to split into convex pieces; skip its
-	# collision rather than let Godot fail on it.
-	if not Geometry2D.decompose_polygon_in_convex(_polygon).is_empty():
-		var collision := CollisionPolygon2D.new()
-		collision.polygon = _polygon
-		add_child(collision)
 
-	if indestructible:
-		color = Params.COLOR_ORANGE
-	else:
+func set_kind(value: Kind) -> void:
+	kind = value
+	hp = KINDS[kind].hp
+	if kind == Kind.NORMAL:
 		color = Color8(
 			int(102 * (randf() / 5.0 + 0.5)),
 			int(217 * (randf() / 5.0 + 0.5)),
 			int(239 * (randf() / 5.0 + 0.5)))
+	else:
+		color = KINDS[kind].color
+	_rebuild_shape()
+	set_physics_process(kind == Kind.GHOST or kind == Kind.MAGNET)
+
+
+func is_breakable() -> bool:
+	return hp > 0
+
 
+## Centre of the segment in global coordinates.
+func center() -> Vector2:
+	var middle := (_begin + _end) / 2.0
+	return to_global(Vector2(sin(middle), cos(middle)) * (_inner + _outer) / 2.0)
+
+
+## A ball hit the segment. Applies the segment's effect on the ball and takes a hit.
+func strike(ball: Ball) -> Hit:
+	if not is_breakable():
+		if ball.superball:
+			ball.superball = false
+			destroy()
+			return Hit.BROKE
+		return Hit.BLOCKED
+	if ball.fire:
+		# Fire burns straight through whatever the segment is.
+		hp = 1
+		return damage()
+	match kind:
+		Kind.ICE:
+			ball.velocity *= ICE_SPEEDUP
+		Kind.RUBBER:
+			ball.velocity *= RUBBER_SPEEDUP
+	var result := damage()
+	if kind == Kind.WATER and result == Hit.DAMAGED:
+		ball.stick_to(self, WATER_STICK_SECONDS, WATER_SLOWDOWN)
+	return result
 
-## Flashes and fades out, then frees itself. Stops colliding right away.
+
+## Takes one hit from anything (ball, laser, explosion). Unbreakable segments ignore it.
+func damage() -> Hit:
+	if not is_breakable() or collision_layer == 0:
+		return Hit.BLOCKED
+	hp -= 1
+	if hp > 0:
+		_rebuild_shape()
+		return Hit.DAMAGED
+	destroy()
+	return Hit.BROKE
+
+
+## Flashes and fades out. Stops colliding right away. Moss comes back once, after a while.
 func destroy() -> void:
 	collision_layer = 0
+	if kind == Kind.TNT:
+		detonated.emit(self)
 	var tween := create_tween()
 	tween.tween_property(self, "_flash", 0.8, BREAK_SECONDS / 3.0)
 	tween.tween_property(self, "modulate:a", 0.0, BREAK_SECONDS)
 	tween.parallel().tween_property(self, "scale", Vector2.ONE * 1.04, BREAK_SECONDS)
-	tween.tween_callback(queue_free)
+	if kind == Kind.MOSS and not _regrown:
+		tween.tween_interval(MOSS_REGROW_SECONDS)
+		tween.tween_callback(_regrow)
+	else:
+		tween.tween_callback(queue_free)
+
+
+func _regrow() -> void:
+	_regrown = true
+	hp = KINDS[kind].hp
+	_flash = 0.0
+	scale = Vector2.ONE * 0.6
+	var tween := create_tween().set_parallel()
+	tween.tween_property(self, "modulate:a", 1.0, 0.4)
+	tween.tween_property(self, "scale", Vector2.ONE, 0.4)
+	tween.chain().tween_callback(set.bind("collision_layer", Params.LAYER_BLOCKS))
+
+
+func _physics_process(delta: float) -> void:
+	if kind == Kind.GHOST and hp > 0:
+		_ghost_clock = fmod(_ghost_clock + delta, GHOST_SOLID_SECONDS + GHOST_PHASED_SECONDS)
+		var solid := _ghost_clock < GHOST_SOLID_SECONDS
+		collision_layer = Params.LAYER_BLOCKS if solid else 0
+		modulate.a = 1.0 if solid else 0.2
+	elif kind == Kind.MAGNET and collision_layer != 0:
+		var here := center()
+		for ball: Ball in get_tree().get_nodes_in_group("balls"):
+			var offset := here - ball.global_position
+			var dist := offset.length()
+			if dist > 0.0 and dist < MAGNET_RADIUS:
+				ball.velocity += offset / dist * MAGNET_PULL * (1.0 - dist / MAGNET_RADIUS) * delta
+
+
+## Recomputes the drawn and collision outline. Water drains from the outside in as it is hit;
+## ice gets cracks; other multi-hit segments darken.
+func _rebuild_shape() -> void:
+	var outer := _outer
+	var max_hp: int = KINDS[kind].hp
+	if kind == Kind.WATER and hp < max_hp:
+		outer = _inner + (_outer - _inner) * float(hp) / max_hp
+	_polygon = _round_corners(_sector(_inner + GAP / 2.0, outer - GAP / 2.0, _begin, _end, GAP / 2.0))
+	# Very short segments can leave a sliver too thin to split into convex pieces; skip its
+	# collision rather than let Godot fail on it.
+	var valid := not Geometry2D.decompose_polygon_in_convex(_polygon).is_empty()
+	_collision.set_deferred("polygon", _polygon if valid else PackedVector2Array())
+	_collision.set_deferred("disabled", not valid)
+	_cracks = PackedVector2Array()
+	if kind == Kind.ICE and hp < max_hp:
+		_cracks = _crack_lines()
+	queue_redraw()
+
+
+func _crack_lines() -> PackedVector2Array:
+	var lines := PackedVector2Array()
+	var middle := (_begin + _end) / 2.0
+	var radius := (_inner + _outer) / 2.0
+	var start := Vector2(sin(middle), cos(middle)) * radius
+	for i in 3:
+		var angle := middle + randf_range(-0.6, 0.6) * (_end - _begin) / 2.0
+		var r := lerpf(_inner + GAP, _outer - GAP, randf())
+		lines.append(start)
+		lines.append(Vector2(sin(angle), cos(angle)) * r)
+	return lines
 
 
 func _draw() -> void:
 	if _polygon.size() < 3:
 		return # segment too short to show at all
-	var fill := color.lerp(Color.WHITE, _flash)
+	var fill := color
+	if hp > 0 and hp < KINDS[kind].hp and kind != Kind.WATER and kind != Kind.ICE:
+		fill = fill.darkened(0.25)
+	fill = fill.lerp(Color.WHITE, _flash)
 	draw_colored_polygon(_polygon, fill)
 	# Polygons have no antialiasing of their own; a smooth outline softens the edge.
 	var outline := _polygon.duplicate()
 	outline.append(_polygon[0])
 	draw_polyline(outline, fill, 1.0, true)
+	if not _cracks.is_empty():
+		draw_multiline(_cracks, Color(0.2, 0.35, 0.45, 0.9), 1.5, true)
 
 
 ## Ring segment between two radii. [param gap] trims each side by that many pixels,

+ 82 - 30
scripts/blocks.gd

@@ -2,12 +2,16 @@ class_name Blocks
 extends Node2D
 ## The core plus rings of rotating blocks around it, generated from the level number.
 
+const BLAST_SCENE := preload("res://scenes/blast.tscn")
+
 const SEGMENTS_PER_RING := 10
-const MAX_RING_SPEED := 30.0 # degrees per second
+const TNT_RADIUS := 80.0
+const TNT_CHAIN_DELAY := 0.12 # seconds between one TNT going off and the next
 
 var core: Core
 var _rings: Array[Node2D] = []
 var _speeds: Array[float] = []
+var _frozen_time := 0.0
 
 
 ## Builds the level's rings. Rings are made thinner when needed so they all fit within
@@ -16,42 +20,88 @@ func build(level: int, max_radius: float) -> void:
 	var layout := _layout(level)
 	var size := minf(layout.size, (max_radius - Params.CORE_RADIUS) / layout.rings)
 	for index in range(layout.rings - 1, -1, -1):
-		_add_ring(size, index, layout.indestructible_chance)
+		_add_ring(level, size, index, layout.indestructible_chance)
 	core = Core.new()
 	add_child(core)
 
 
+## Stops the rings turning for a while.
+func freeze(seconds: float) -> void:
+	_frozen_time = maxf(_frozen_time, seconds)
+
+
+func has_unbreakable() -> bool:
+	for ring in _rings:
+		for block: Block in ring.get_children():
+			if not block.is_breakable() and block.collision_layer != 0:
+				return true
+	return false
+
+
+## Damages every breakable segment within [param radius] of [param at] (global position).
+func explode(at: Vector2, radius: float) -> void:
+	var blast: Node2D = BLAST_SCENE.instantiate()
+	blast.radius = radius
+	blast.position = to_local(at)
+	add_child(blast)
+	var query := PhysicsShapeQueryParameters2D.new()
+	var circle := CircleShape2D.new()
+	circle.radius = radius
+	query.shape = circle
+	query.transform = Transform2D(0.0, at)
+	query.collision_mask = Params.LAYER_BLOCKS
+	for hit in get_world_2d().direct_space_state.intersect_shape(query, 64):
+		if hit.collider is Block:
+			(hit.collider as Block).damage()
+
+
 func _physics_process(delta: float) -> void:
+	if _frozen_time > 0.0:
+		_frozen_time -= delta
+		return
 	for i in _rings.size():
 		_rings[i].rotation_degrees += _speeds[i] * delta
 
 
-## Ring count, ring thickness and indestructible-block chance for a level. Each level adds
-## a ring or raises the chance; once there are too many rings they restart thinner.
-## Ported as-is from BlocksLayer::setLevel.
+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))
+
+
+## Ring count, ring thickness and indestructible-block chance for a level. All of them only
+## ever grow with the level, so no level is easier than the one before it.
 static func _layout(level: int) -> Dictionary:
-	var count := 0
-	var delta := 15
-	var size := Params.CORE_RADIUS * 0.75
-	var rings := 1
-	var chance := 0.0
-	var i := 0
-	while count <= level:
-		var j := 0
-		while count <= level and j <= i:
-			if i + 1 > delta:
-				delta = int(delta * 1.5)
-				size /= 1.5
-				i = 0
-			rings = i + 1
-			chance = j * 0.05 / delta * 8.0
-			count += 1
-			j += 1
-		i += 1
-	return { rings = rings, size = size, indestructible_chance = chance }
-
-
-func _add_ring(size: float, index: int, indestructible_chance: float) -> void:
+	return {
+		rings = Params.ring_count(level),
+		size = Params.RING_SIZE,
+		indestructible_chance = Params.indestructible_chance(level),
+	}
+
+
+## Picks the kind of one segment. Special kinds unlock every 50 levels; their share of
+## the segments grows with the level, and the newest kind is twice as likely as the others.
+static func _segment_kind(level: int, indestructible_chance: float) -> Block.Kind:
+	if indestructible_chance >= randf():
+		return Block.Kind.INDESTRUCTIBLE
+	var unlocked: Array[Block.Kind] = []
+	for kind: Block.Kind in Block.KINDS:
+		var unlock: int = Block.KINDS[kind].unlock
+		if unlock > 0 and unlock <= level:
+			unlocked.append(kind)
+	if unlocked.is_empty() or randf() >= Params.special_segment_share(level):
+		return Block.Kind.NORMAL
+	var weights: Array[float] = []
+	for kind in unlocked:
+		weights.append(2.0 if kind == unlocked[-1] else 1.0)
+	var roll: float = randf() * weights.reduce(func(sum: float, w: float) -> float: return sum + w, 0.0)
+	for i in unlocked.size():
+		roll -= weights[i]
+		if roll <= 0.0:
+			return unlocked[i]
+	return unlocked[-1]
+
+
+func _add_ring(level: int, size: float, index: int, indestructible_chance: float) -> void:
 	var inner := index * size + Params.CORE_RADIUS
 	var outer := (index + 1) * size + Params.CORE_RADIUS
 
@@ -64,8 +114,10 @@ func _add_ring(size: float, index: int, indestructible_chance: float) -> void:
 
 	var ring := Node2D.new()
 	for i in SEGMENTS_PER_RING:
-		var indestructible := indestructible_chance >= randf()
-		ring.add_child(Block.new(inner, outer, bounds[i], bounds[i + 1], indestructible))
+		var block := Block.new(inner, outer, bounds[i], bounds[i + 1], _segment_kind(level, indestructible_chance))
+		block.detonated.connect(_on_detonated)
+		ring.add_child(block)
 	add_child(ring)
 	_rings.append(ring)
-	_speeds.append(randf_range(-MAX_RING_SPEED, MAX_RING_SPEED))
+	var max_speed := Params.ring_speed(level)
+	_speeds.append(randf_range(-max_speed, max_speed))

+ 123 - 40
scripts/game.gd

@@ -5,12 +5,20 @@ const BALL_SCENE := preload("res://scenes/ball.tscn")
 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
+
 var level := 0
 ## Set once the level is won or lost; stops power-up drops and collision handling.
 var over := false
 ## When a ball last touched anything (seconds). Long gaps make triple balls more likely.
 var last_touch := 0.0
 var screen := Vector2.ZERO
+## Balls the shield will still save.
+var shield := 0:
+	set(value):
+		shield = value
+		queue_redraw()
+var _started := 0.0
 
 @onready var blocks: Blocks = $Blocks
 @onready var paddle: Paddle = $Paddle
@@ -26,18 +34,22 @@ func _ready() -> void:
 	screen = get_viewport_rect().size
 	level = Progress.current_level
 	last_touch = _now()
+	_started = _now()
 	$HUD/Level.text = "Level %d" % level
 
-	blocks.position = screen / 2.0
 	paddle.position = Vector2(screen.x / 2.0, screen.y - Params.PADDLE_ALTITUDE - Params.PADDLE_HEIGHT / 2.0)
-	var paddle_top := paddle.position.y - Params.PADDLE_HEIGHT / 2.0
-	blocks.build(level, paddle_top - blocks.position.y - Params.PADDLE_CLEARANCE)
+	# The rings sit centred on screen; when there are many, the core moves up so they keep
+	# their thickness and still leave PADDLE_CLEARANCE above the paddle.
+	var lowest_ring_edge := paddle.position.y - Params.PADDLE_HEIGHT / 2.0 - Params.PADDLE_CLEARANCE
+	var natural_radius := Params.CORE_RADIUS + Params.ring_count(level) * Params.RING_SIZE
+	var core_y := clampf(lowest_ring_edge - natural_radius, screen.y * Params.HIGHEST_CORE, screen.y / 2.0)
+	blocks.position = Vector2(screen.x / 2.0, core_y)
+	blocks.build(level, lowest_ring_edge - core_y)
+	paddle.released.connect(_release_caught_balls)
 	end_button.pressed.connect(_restart)
 
-	get_tree().create_timer(10.0).timeout.connect(_drop_level_save)
+	_drop_power_up()
 	_drop_triple_balls()
-	_drop_paddle()
-	_drop_superball()
 	_add_and_throw_ball()
 
 
@@ -47,30 +59,42 @@ func _on_ball_hit(ball: Ball, other: Node) -> void:
 		return
 	if other is Block:
 		var block := other as Block
-		if not block.indestructible:
-			$Sounds/Pipe.play()
-			block.destroy()
-		else:
-			$Sounds/Metal.play()
-			if ball.superball:
-				block.destroy()
-				ball.superball = false
+		match block.strike(ball):
+			Block.Hit.BROKE, Block.Hit.DAMAGED:
+				$Sounds/Pipe.play()
+			Block.Hit.BLOCKED:
+				$Sounds/Metal.play()
+		if ball.bomb:
+			ball.bomb = false
+			blocks.explode(ball.global_position, Params.BOMB_RADIUS)
+			$Sounds/Bomb.play()
 	elif other is Core:
 		_win()
+	elif other is Paddle:
+		if paddle.catch_time > 0.0:
+			ball.stick_to(paddle, Params.CATCH_HOLD_SECONDS)
+		elif ball.velocity.y < 0.0:
+			ball.aim_from(paddle)
 	elif other.is_in_group("bottom"):
-		_lose_ball(ball)
+		if shield > 0:
+			shield -= 1
+			_alert(Params.MSG_SHIELD_USED)
+			$Sounds/Metal.play()
+		else:
+			_lose_ball(ball)
 
 
 func _on_power_up_collected(power_up: PowerUp) -> void:
 	if over:
 		return
+	var live := _live_balls()
 	match power_up.kind:
 		PowerUp.Kind.SAVE:
 			Progress.save_level(level)
 			_alert(Params.MSG_LEVEL_SAVED)
 		PowerUp.Kind.TRIPLE_BALLS:
 			_alert(Params.MSG_TRIPLE_BALLS)
-			for i in 3 - _live_balls().size():
+			for i in 3 - live.size():
 				_add_and_throw_ball()
 		PowerUp.Kind.PADDLE:
 			if randf() < 0.5:
@@ -80,10 +104,51 @@ func _on_power_up_collected(power_up: PowerUp) -> void:
 				_alert(Params.MSG_HALF_PADDLE)
 				paddle.half_size()
 		PowerUp.Kind.SUPERBALL:
-			_alert(Params.MSG_SUPER_BALL)
-			var live := _live_balls()
-			if not live.is_empty():
+			if blocks.has_unbreakable() and not live.is_empty():
+				_alert(Params.MSG_SUPER_BALL)
 				live[0].superball = true
+		PowerUp.Kind.SLOW:
+			_alert(Params.MSG_SLOW)
+			for ball: Ball in live:
+				ball.set_time_scale(Params.SLOW_SCALE, Params.SLOW_SECONDS)
+		PowerUp.Kind.RUSH:
+			_alert(Params.MSG_RUSH)
+			for ball: Ball in live:
+				ball.set_time_scale(Params.RUSH_SCALE, Params.RUSH_SECONDS)
+		PowerUp.Kind.CATCH:
+			_alert(Params.MSG_CATCH)
+			paddle.set_catch(Params.CATCH_SECONDS)
+		PowerUp.Kind.SHIELD:
+			_alert(Params.MSG_SHIELD)
+			shield = Params.SHIELD_CHARGES
+		PowerUp.Kind.BIG_BALL:
+			_alert(Params.MSG_BIG_BALL)
+			for ball: Ball in live:
+				ball.set_big(Params.BIG_BALL_SECONDS)
+		PowerUp.Kind.FIREBALL:
+			_alert(Params.MSG_FIREBALL)
+			for ball: Ball in live:
+				ball.set_fire(Params.FIREBALL_SECONDS)
+		PowerUp.Kind.LASER:
+			_alert(Params.MSG_LASER)
+			paddle.set_laser(Params.LASER_SECONDS)
+		PowerUp.Kind.FREEZE:
+			_alert(Params.MSG_FREEZE)
+			blocks.freeze(Params.FREEZE_SECONDS)
+		PowerUp.Kind.BOMB:
+			_alert(Params.MSG_BOMB)
+			if not live.is_empty():
+				live[0].bomb = true
+		PowerUp.Kind.HOMING:
+			_alert(Params.MSG_HOMING)
+			for ball: Ball in live:
+				ball.set_homing(Params.HOMING_SECONDS)
+
+
+func _release_caught_balls() -> void:
+	for ball: Ball in _live_balls():
+		if ball.is_stuck_to(paddle):
+			ball.release()
 
 
 func _lose_ball(ball: Ball) -> void:
@@ -120,6 +185,7 @@ func _clear_play_field() -> void:
 	for node in balls.get_children() + power_ups.get_children():
 		node.queue_free()
 	paddle.queue_free()
+	shield = 0
 
 
 func _add_and_throw_ball() -> void:
@@ -127,9 +193,11 @@ func _add_and_throw_ball() -> void:
 		return
 	var ball: Ball = BALL_SCENE.instantiate()
 	ball.position = paddle.position + Vector2(0, -Params.PADDLE_HEIGHT / 2.0 - Params.BALL_RADIUS - 1.0)
+	ball.target = blocks.core
+	ball.max_speed = Params.max_ball_speed(level)
 	ball.hit.connect(_on_ball_hit)
 	balls.add_child(ball)
-	ball.throw()
+	ball.throw(Params.launch_speed(level))
 
 
 func _live_balls() -> Array:
@@ -138,12 +206,36 @@ func _live_balls() -> Array:
 
 # Power-up drops. Each one rolls its chance, then reschedules itself.
 
-func _drop_level_save() -> void:
+func _drop_power_up() -> void:
 	if over:
 		return
-	if randf() < Params.DROP_LEVEL_SAVE:
-		_spawn_power_up(PowerUp.Kind.SAVE)
-	_schedule(_drop_level_save)
+	if randf() < Params.power_up_chance(level):
+		var kind := _pick_power_up()
+		if kind >= 0:
+			_spawn_power_up(kind as PowerUp.Kind)
+	_schedule(_drop_power_up)
+
+
+## Weighted pick among the power-ups that can drop right now; -1 if none can.
+func _pick_power_up() -> int:
+	var kinds: Array[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
+		if w > 0.0:
+			kinds.append(kind)
+			weights.append(w)
+	var roll: float = randf() * weights.reduce(func(sum: float, w: float) -> float: return sum + w, 0.0)
+	for i in kinds.size():
+		roll -= weights[i]
+		if roll <= 0.0:
+			return kinds[i]
+	return -1
 
 
 func _drop_triple_balls() -> void:
@@ -157,22 +249,6 @@ func _drop_triple_balls() -> void:
 	_schedule(_drop_triple_balls)
 
 
-func _drop_paddle() -> void:
-	if over:
-		return
-	if randf() < Params.DROP_PADDLE_BALL:
-		_spawn_power_up(PowerUp.Kind.PADDLE)
-	_schedule(_drop_paddle)
-
-
-func _drop_superball() -> void:
-	if over:
-		return
-	if randf() < Params.DROP_SUPER_BALL:
-		_spawn_power_up(PowerUp.Kind.SUPERBALL)
-	_schedule(_drop_superball)
-
-
 func _schedule(callback: Callable) -> void:
 	var interval := Params.POWER_UP_INTERVAL
 	var delay := interval + randf_range(-1.0, 1.0) * interval / 4.0
@@ -193,6 +269,7 @@ func _alert(text: String) -> void:
 	label.add_theme_font_size_override("font_size", 40)
 	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
 	label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
+	label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
 	label.mouse_filter = Control.MOUSE_FILTER_IGNORE
 	alerts.add_child(label)
 	label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -207,5 +284,11 @@ func _burst(at: Vector2) -> void:
 	add_child(burst)
 
 
+func _draw() -> void:
+	if shield > 0:
+		var y := screen.y - SHIELD_HEIGHT
+		draw_line(Vector2(0, y), Vector2(screen.x, y), Color("#f4f4f0"), 4.0, true)
+
+
 func _now() -> float:
 	return Time.get_ticks_msec() / 1000.0

+ 81 - 8
scripts/paddle.gd

@@ -2,11 +2,22 @@ class_name Paddle
 extends AnimatableBody2D
 ## Follows the player's finger (or mouse) along the bottom of the screen.
 
+## Emitted when the player lifts their finger; lets go of caught balls.
+signal released
+
 enum Width { NORMAL, HALF, DOUBLE }
 
 const SCALE := { Width.NORMAL: 1.0, Width.HALF: 0.5, Width.DOUBLE: 2.0 }
+const LASER_INTERVAL := 0.4 # seconds between shots
+const LASER_FLASH := 0.08 # seconds a beam stays visible
 
 var width := Width.NORMAL
+## While above 0, balls that land on the paddle are caught.
+var catch_time := 0.0
+var _laser_time := 0.0
+var _laser_cooldown := 0.0
+var _beams: Array[Vector2] = [] # local end points of the beams on screen
+var _beam_time := 0.0
 var _screen_width := 0.0
 var _shape := RectangleShape2D.new()
 
@@ -21,12 +32,20 @@ func _ready() -> void:
 
 
 func _unhandled_input(event: InputEvent) -> void:
-	if event is InputEventScreenTouch and event.is_pressed():
-		_move_to((event as InputEventScreenTouch).position.x)
+	if event is InputEventScreenTouch:
+		var touch := event as InputEventScreenTouch
+		if touch.is_pressed():
+			_move_to(touch.position.x)
+		else:
+			released.emit()
 	elif event is InputEventScreenDrag:
 		_move_to((event as InputEventScreenDrag).position.x)
 
 
+func half_width() -> float:
+	return Params.PADDLE_WIDTH / 2.0 * SCALE[width]
+
+
 ## Doubles the paddle for a while, or cancels a halving.
 func double_size() -> void:
 	if width == Width.HALF:
@@ -50,6 +69,54 @@ func normal_size() -> void:
 	_set_width(Width.NORMAL)
 
 
+func set_catch(seconds: float) -> void:
+	catch_time = maxf(catch_time, seconds)
+	queue_redraw()
+
+
+func set_laser(seconds: float) -> void:
+	_laser_time = maxf(_laser_time, seconds)
+	queue_redraw()
+
+
+func _physics_process(delta: float) -> void:
+	var was_catching := catch_time > 0.0
+	catch_time = maxf(catch_time - delta, 0.0)
+	if was_catching and catch_time == 0.0:
+		released.emit()
+		queue_redraw()
+	if _beam_time > 0.0:
+		_beam_time -= delta
+		if _beam_time <= 0.0:
+			_beams.clear()
+			queue_redraw()
+	if _laser_time > 0.0:
+		_laser_time -= delta
+		_laser_cooldown -= delta
+		if _laser_cooldown <= 0.0:
+			_laser_cooldown = LASER_INTERVAL
+			_fire_lasers()
+		if _laser_time <= 0.0:
+			queue_redraw()
+
+
+## Two beams straight up from the paddle's ends; each damages the first segment it meets.
+func _fire_lasers() -> void:
+	_beams.clear()
+	var space := get_world_2d().direct_space_state
+	for side in [-1.0, 1.0]:
+		var from := global_position + Vector2(side * (half_width() - 4.0), -Params.PADDLE_HEIGHT / 2.0)
+		var query := PhysicsRayQueryParameters2D.create(from, Vector2(from.x, 0.0), Params.LAYER_BLOCKS)
+		var result := space.intersect_ray(query)
+		var end: Vector2 = result.position if result else Vector2(from.x, 0.0)
+		if result and result.collider is Block:
+			(result.collider as Block).damage()
+		_beams.append(to_local(from))
+		_beams.append(to_local(end))
+	_beam_time = LASER_FLASH
+	queue_redraw()
+
+
 func _set_width(value: Width) -> void:
 	width = value
 	_shape.size = Vector2((Params.PADDLE_WIDTH + Params.PADDLE_HEIGHT) * SCALE[width], Params.PADDLE_HEIGHT)
@@ -58,13 +125,19 @@ func _set_width(value: Width) -> void:
 
 
 func _move_to(x: float) -> void:
-	var half: float = Params.PADDLE_WIDTH / 2.0 * SCALE[width]
-	position.x = clampf(x, half, _screen_width - half)
+	position.x = clampf(x, half_width(), _screen_width - half_width())
 
 
 func _draw() -> void:
-	var half: float = Params.PADDLE_WIDTH / 2.0 * SCALE[width]
+	var half := half_width()
 	var cap := Params.PADDLE_HEIGHT / 2.0
-	draw_line(Vector2(-half, 0), Vector2(half, 0), Params.COLOR_GREY, Params.PADDLE_HEIGHT, true)
-	draw_circle(Vector2(-half, 0), cap, Params.COLOR_GREY, true, -1.0, true)
-	draw_circle(Vector2(half, 0), cap, Params.COLOR_GREY, true, -1.0, true)
+	# A caught-ball paddle turns green.
+	var fill := Params.COLOR_GREEN if catch_time > 0.0 else Params.COLOR_GREY
+	draw_line(Vector2(-half, 0), Vector2(half, 0), fill, Params.PADDLE_HEIGHT, true)
+	draw_circle(Vector2(-half, 0), cap, fill, true, -1.0, true)
+	draw_circle(Vector2(half, 0), cap, fill, true, -1.0, true)
+	if _laser_time > 0.0:
+		for side in [-1.0, 1.0]:
+			draw_rect(Rect2(side * (half - 4.0) - 3.0, -cap - 6.0, 6.0, 6.0), Params.COLOR_RED)
+	if not _beams.is_empty():
+		draw_multiline(_beams, Params.COLOR_RED, 3.0, true)

+ 81 - 6
scripts/params.gd

@@ -15,7 +15,10 @@ const CORE_RADIUS := 50.0
 const PADDLE_WIDTH := 120.0
 const PADDLE_HEIGHT := 10.0
 const PADDLE_ALTITUDE := 90.0 # distance from the bottom of the screen
-const PADDLE_CLEARANCE := 3.5 * 2.0 * BALL_RADIUS # min space between the outer ring and the paddle
+const PADDLE_MAX_ANGLE := 60.0 # degrees from vertical when the ball leaves the paddle's tip
+const PADDLE_CLEARANCE := 6.0 * 2.0 * BALL_RADIUS # min space between the outer ring and the paddle
+const RING_SIZE := CORE_RADIUS * 0.75 # ring thickness when there is room for it
+const HIGHEST_CORE := 0.36 # the core moves up to this fraction of the screen height to make room
 
 # Colors
 const COLOR_PINK := Color("#f92672")
@@ -24,6 +27,9 @@ const COLOR_GREEN := Color("#a6e22e")
 const COLOR_ORANGE := Color("#fd971f")
 const COLOR_GREY := Color("#787878")
 const COLOR_RED := Color(0.988, 0.147, 0.030)
+const COLOR_FIRE := Color("#ff5a1f")
+const COLOR_BACK := Color("#272822")
+const COLOR_BOMB := Color("#4a4a44")
 
 # Physics layers (bit values)
 const LAYER_WALLS := 1
@@ -32,13 +38,25 @@ const LAYER_BLOCKS := 4
 const LAYER_BALLS := 8
 const LAYER_POWER_UPS := 16
 
-# Power-up drop chances, rolled every POWER_UP_INTERVAL seconds
-const DROP_LEVEL_SAVE := 0.03
+# 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 DROP_PADDLE_BALL := 0.05
-const DROP_SUPER_BALL := 0.03
+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
+const SLOW_SECONDS := 8.0
+const RUSH_SCALE := 1.35
+const RUSH_SECONDS := 6.0
+const CATCH_SECONDS := 12.0
+const CATCH_HOLD_SECONDS := 2.0 # a caught ball leaves by itself after this
+const SHIELD_CHARGES := 1
+const BIG_BALL_SECONDS := 10.0
+const FIREBALL_SECONDS := 5.0
+const LASER_SECONDS := 6.0
+const FREEZE_SECONDS := 8.0
+const BOMB_RADIUS := 90.0
+const HOMING_SECONDS := 6.0
 
 # Hours without playing before the saved level drops by one
 const LOSE_HOURS := 12
@@ -49,7 +67,64 @@ 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. In %.1f hour(s) you will regrow a level."
+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_RESTART := "Restart"
 const MSG_SUPER_BALL := "Super Ball!"
+const MSG_SLOW := "Slow motion"
+const MSG_CATCH := "Catch! Lift your finger to throw"
+const MSG_SHIELD := "Shield!"
+const MSG_SHIELD_USED := "Shield saved you!"
+const MSG_BIG_BALL := "Big ball!"
+const MSG_FIREBALL := "Fireball!"
+const MSG_RUSH := "Rush! :("
+const MSG_LASER := "Lasers!"
+const MSG_FREEZE := "Rings frozen"
+const MSG_BOMB := "Bomb ball!"
+const MSG_HOMING := "Homing ball!"
+
+
+# Progression: how the game gets harder level by level. Every curve only goes up.
+
+const MAX_RINGS := 14
+
+
+## Rings around the core: 1 at level 0, 3 at level 12, 6 at 75, 10 at 243, 14 from 507.
+static func ring_count(level: int) -> int:
+	return mini(MAX_RINGS, 1 + int(sqrt(level / 3.0)))
+
+
+## Share of segments that are indestructible.
+static func indestructible_chance(level: int) -> float:
+	return minf(0.3, level * 0.004)
+
+
+## Bounces and speed-ups stop at this speed, in px/s.
+static func max_ball_speed(level: int) -> float:
+	return 850.0 + minf(150.0, level * 0.3)
+
+## Fastest a ring can turn, in degrees per second.
+static func ring_speed(level: int) -> float:
+	return 30.0 + minf(40.0, level * 0.25)
+
+
+## Launch speed of a new ball.
+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.
+static func special_segment_share(level: int) -> float:
+	if level < 50:
+		return 0.0
+	return minf(0.5, 0.08 + (level - 50) * 0.0012)
+
+
+## Chance of a power-up drop every POWER_UP_INTERVAL seconds.
+static func power_up_chance(level: int) -> float:
+	return 0.12 + minf(0.06, level * 0.0006)
+
+
+## Multiplier on power-down weights: they get likelier as the levels go up.
+static func power_down_factor(level: int) -> float:
+	return 1.0 + minf(2.0, level / 100.0)

+ 45 - 9
scripts/power_up.gd

@@ -1,17 +1,35 @@
 class_name PowerUp
 extends RigidBody2D
-## A small dot that falls from the top. Catch it with the paddle to collect it.
+## A small token that falls from the top. Catch it with the paddle to collect it.
 
 signal collected(power_up: PowerUp)
 
-enum Kind { SAVE, TRIPLE_BALLS, PADDLE, SUPERBALL }
+enum Kind {
+	SAVE, TRIPLE_BALLS, PADDLE, SUPERBALL,
+	SLOW, CATCH, SHIELD, BIG_BALL, FIREBALL, RUSH, LASER, FREEZE, BOMB, HOMING,
+}
+
+const RADIUS := 9.0
+const FONT := preload("res://assets/fonts/abel.ttf")
 
-const RADIUS := 5.0
-const COLORS := {
-	Kind.SAVE: Params.COLOR_GREEN,
-	Kind.TRIPLE_BALLS: Params.COLOR_PINK,
-	Kind.PADDLE: Params.COLOR_BLUE,
-	Kind.SUPERBALL: Params.COLOR_ORANGE,
+## 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.
+## 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 },
 }
 
 var kind := Kind.SAVE
@@ -22,6 +40,16 @@ func _ready() -> void:
 	body_entered.connect(_on_body_entered)
 
 
+## Weight of [param kind] in the random pick at [param level]; 0 when not unlocked yet.
+static func weight(kind: Kind, level: int) -> float:
+	var info: Dictionary = KINDS[kind]
+	if level < info.unlock:
+		return 0.0
+	if info.get("down", false):
+		return info.weight * Params.power_down_factor(level)
+	return info.weight
+
+
 func _on_body_entered(body: Node) -> void:
 	if body is Paddle:
 		collected.emit(self)
@@ -31,4 +59,12 @@ func _on_body_entered(body: Node) -> void:
 
 
 func _draw() -> void:
-	draw_circle(Vector2.ZERO, RADIUS, COLORS[kind], true, -1.0, true)
+	var color: Color = KINDS[kind].color
+	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)