extends Node2D ## One level: the core and its rings, the paddle, the balls and the falling power-ups. 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 const DEBRIS_PIECES := 10 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 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 @onready var balls: Node2D = $Balls @onready var power_ups: Node2D = $PowerUps @onready var alerts: Control = $HUD/Alerts @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: screen = get_viewport_rect().size level = Progress.current_level last_touch = _now() _started = _now() $HUD/Level.text = "Level %d" % level paddle.position = Vector2(screen.x / 2.0, screen.y - Params.PADDLE_ALTITUDE - Params.PADDLE_HEIGHT / 2.0) # 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) 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() _add_and_throw_ball() func _on_ball_hit(ball: Ball, other: Node) -> void: last_touch = _now() if over: return if other is Block: var block := other as Block 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: combo = 0 _update_score() 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"): if shield > 0: shield -= 1 _alert(Params.MSG_SHIELD_USED) $Sounds/Metal.play() else: _lose_ball(ball) func _on_power_up_collected(kind: PowerUp.Kind) -> void: if over: return var live := _live_balls() match kind: PowerUp.Kind.TRIPLE_BALLS: _alert(Params.MSG_TRIPLE_BALLS) for i in 3 - live.size(): _add_and_throw_ball() PowerUp.Kind.PADDLE: if randf() < 0.5: _alert(Params.MSG_DOUBLE_PADDLE) paddle.double_size() else: _alert(Params.MSG_HALF_PADDLE) paddle.half_size() PowerUp.Kind.SUPERBALL: 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() ## 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() 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 Progress.save_level(level) end_button.text = Params.MSG_NEXT_LEVEL end_menu.show() ## Restarts at the current level (the next one after a win). func _restart() -> void: Progress.current_level = level get_tree().reload_current_scene() 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: if paddle.is_queued_for_deletion(): 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(Params.launch_speed(level)) func _live_balls() -> Array: return balls.get_children().filter(func(ball: Node) -> bool: return not ball.is_queued_for_deletion()) # Power-up drops. Each one rolls its chance, then reschedules itself. func _drop_power_up() -> void: if over: return 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) # 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: if over: return var idle := _now() - last_touch if randf() < idle * Params.DROP_TRIPLE_BALL: last_touch = _now() if _live_balls().size() < 3: _spawn_power_up(PowerUp.Kind.TRIPLE_BALLS) _schedule(_drop_triple_balls) func _schedule(callback: Callable) -> void: var interval := Params.POWER_UP_INTERVAL var delay := interval + randf_range(-1.0, 1.0) * interval / 4.0 get_tree().create_timer(delay).timeout.connect(callback) 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) # 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) func _alert(text: String) -> void: var label := Label.new() label.text = text 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) var tween := label.create_tween() tween.tween_property(label, "modulate:a", 0.0, 3.0) 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 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