Prechádzať zdrojové kódy

Port RedCore to Godot 4.7

Adds a Godot 4.7 version of the game under godot/, ported from the
cocos2d-x sources: same level generator, ball and power-up behaviour,
saved-level decay, colours, sounds and font.

Includes a Dockerfile that exports a Linux binary with headless Godot
after a smoke test that runs both scenes and fails on script errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gabriel Capella 5 dní pred
rodič
commit
a6af2c7f16

+ 2 - 0
godot/.dockerignore

@@ -0,0 +1,2 @@
+.godot/
+build/

+ 2 - 0
godot/.gitignore

@@ -0,0 +1,2 @@
+.godot/
+build/

+ 39 - 0
godot/Dockerfile

@@ -0,0 +1,39 @@
+# Builds RedCore for Linux with headless Godot, after a smoke test that runs both scenes
+# headless and fails on script errors. Copy the binary into ./build with:
+#   docker build --target test -t redcore-build .
+#   id=$(docker create redcore-build) && docker cp $id:/project/build/RedCore.x86_64 build/ && docker rm $id
+# With BuildKit, `docker build --output build .` does the same in one step.
+
+FROM debian:bookworm-slim AS godot
+ARG GODOT_VERSION=4.7.2
+ARG GODOT_URL=https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates wget unzip libfontconfig1 \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /tmp/godot
+RUN wget -q ${GODOT_URL}/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip \
+ && unzip -q Godot_*.zip \
+ && mv Godot_v${GODOT_VERSION}-stable_linux.x86_64 /usr/local/bin/godot \
+ && rm -rf /tmp/godot/*
+# Only the Linux export templates are kept from the archive.
+RUN wget -q ${GODOT_URL}/Godot_v${GODOT_VERSION}-stable_export_templates.tpz \
+ && unzip -q Godot_*.tpz 'templates/linux_*' 'templates/version.txt' \
+ && mkdir -p /root/.local/share/godot/export_templates/${GODOT_VERSION}.stable \
+ && mv templates/* /root/.local/share/godot/export_templates/${GODOT_VERSION}.stable/ \
+ && rm -rf /tmp/godot/*
+
+FROM godot AS build
+WORKDIR /project
+COPY . .
+RUN godot --headless --import \
+ && mkdir -p build \
+ && godot --headless --export-release "Linux" build/RedCore.x86_64
+
+FROM build AS test
+RUN for scene in title game; do \
+      timeout 60 godot --headless --path . res://scenes/$scene.tscn --quit-after 1200 2>&1 | tee -a /tmp/run.log; \
+    done \
+ && ! grep -E "SCRIPT ERROR|^ERROR" /tmp/run.log
+
+FROM scratch
+COPY --from=build /project/build/ /

BIN
godot/assets/fonts/abel.ttf


BIN
godot/assets/sounds/bomb.wav


BIN
godot/assets/sounds/metal.wav


BIN
godot/assets/sounds/pipe.wav


BIN
godot/assets/sounds/win.wav


+ 18 - 0
godot/export_presets.cfg

@@ -0,0 +1,18 @@
+[preset.0]
+
+name="Linux"
+platform="Linux"
+runnable=true
+dedicated_server=false
+custom_features=""
+export_filter="all_resources"
+include_filter=""
+exclude_filter="Dockerfile, build/*"
+export_path="build/RedCore.x86_64"
+
+[preset.0.options]
+
+binary_format/embed_pck=true
+binary_format/architecture="x86_64"
+texture_format/s3tc_bptc=true
+texture_format/etc2_astc=false

+ 43 - 0
godot/project.godot

@@ -0,0 +1,43 @@
+; Engine configuration file.
+
+config_version=5
+
+[application]
+
+config/name="RedCore"
+run/main_scene="res://scenes/title.tscn"
+config/features=PackedStringArray("4.7")
+
+[autoload]
+
+Progress="*res://scripts/progress.gd"
+
+[display]
+
+window/size/viewport_width=540
+window/size/viewport_height=960
+window/stretch/mode="canvas_items"
+window/stretch/aspect="expand"
+window/handheld/orientation=1
+
+[gui]
+
+theme/custom_font="res://assets/fonts/abel.ttf"
+
+[input_devices]
+
+pointing/emulate_touch_from_mouse=true
+
+[layer_names]
+
+2d_physics/layer_1="walls"
+2d_physics/layer_2="paddle"
+2d_physics/layer_3="blocks"
+2d_physics/layer_4="balls"
+2d_physics/layer_5="power_ups"
+
+[rendering]
+
+renderer/rendering_method="gl_compatibility"
+renderer/rendering_method.mobile="gl_compatibility"
+environment/defaults/default_clear_color=Color(0.152941, 0.156863, 0.133333, 1)

+ 15 - 0
godot/scenes/ball.tscn

@@ -0,0 +1,15 @@
+[gd_scene format=3]
+
+[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
+motion_mode = 1
+script = ExtResource("1_ball")
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+shape = SubResource("CircleShape2D_ball")

+ 87 - 0
godot/scenes/game.tscn

@@ -0,0 +1,87 @@
+[gd_scene format=3]
+
+[ext_resource type="Script" path="res://scripts/game.gd" id="1_game"]
+[ext_resource type="Script" path="res://scripts/walls.gd" id="2_walls"]
+[ext_resource type="Script" path="res://scripts/blocks.gd" id="3_blocks"]
+[ext_resource type="PackedScene" path="res://scenes/paddle.tscn" id="4_paddle"]
+[ext_resource type="AudioStream" path="res://assets/sounds/pipe.wav" id="5_pipe"]
+[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"]
+
+[node name="Game" type="Node2D"]
+script = ExtResource("1_game")
+
+[node name="Walls" type="StaticBody2D" parent="."]
+script = ExtResource("2_walls")
+
+[node name="Blocks" type="Node2D" parent="."]
+script = ExtResource("3_blocks")
+
+[node name="PowerUps" type="Node2D" parent="."]
+
+[node name="Paddle" parent="." instance=ExtResource("4_paddle")]
+
+[node name="Balls" type="Node2D" parent="."]
+
+[node name="Sounds" type="Node" parent="."]
+
+[node name="Pipe" type="AudioStreamPlayer" parent="Sounds"]
+stream = ExtResource("5_pipe")
+max_polyphony = 4
+
+[node name="Metal" type="AudioStreamPlayer" parent="Sounds"]
+stream = ExtResource("6_metal")
+max_polyphony = 4
+
+[node name="Bomb" type="AudioStreamPlayer" parent="Sounds"]
+stream = ExtResource("7_bomb")
+max_polyphony = 4
+
+[node name="Win" type="AudioStreamPlayer" parent="Sounds"]
+stream = ExtResource("8_win")
+
+[node name="HUD" type="CanvasLayer" parent="."]
+
+[node name="Level" type="Label" parent="HUD"]
+anchors_preset = 2
+anchor_top = 1.0
+anchor_bottom = 1.0
+offset_left = 10.0
+offset_top = -50.0
+offset_right = 300.0
+offset_bottom = -10.0
+grow_vertical = 0
+theme_override_font_sizes/font_size = 30
+text = "Level 0"
+vertical_alignment = 2
+
+[node name="Alerts" type="Control" parent="HUD"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+
+[node name="EndMenu" type="VBoxContainer" parent="HUD"]
+visible = false
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/separation = 10
+
+[node name="Message" type="Label" parent="HUD/EndMenu"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 40
+horizontal_alignment = 1
+
+[node name="Button" type="Button" parent="HUD/EndMenu"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 32
+flat = true

+ 14 - 0
godot/scenes/paddle.tscn

@@ -0,0 +1,14 @@
+[gd_scene format=3]
+
+[ext_resource type="Script" path="res://scripts/paddle.gd" id="1_paddle"]
+
+[node name="Paddle" type="AnimatableBody2D"]
+collision_layer = 2
+collision_mask = 0
+sync_to_physics = false
+script = ExtResource("1_paddle")
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+
+[node name="NormalTimer" type="Timer" parent="."]
+one_shot = true

+ 25 - 0
godot/scenes/power_up.tscn

@@ -0,0 +1,25 @@
+[gd_scene format=3]
+
+[ext_resource type="Script" path="res://scripts/power_up.gd" id="1_power_up"]
+
+[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_bouncy"]
+friction = 0.0
+bounce = 1.0
+
+[sub_resource type="CircleShape2D" id="CircleShape2D_power_up"]
+radius = 5.0
+
+[node name="PowerUp" type="RigidBody2D"]
+collision_layer = 16
+collision_mask = 3
+physics_material_override = SubResource("PhysicsMaterial_bouncy")
+gravity_scale = 0.1
+lock_rotation = true
+continuous_cd = 2
+contact_monitor = true
+max_contacts_reported = 4
+linear_damp_mode = 1
+script = ExtResource("1_power_up")
+
+[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+shape = SubResource("CircleShape2D_power_up")

+ 61 - 0
godot/scenes/title.tscn

@@ -0,0 +1,61 @@
+[gd_scene format=3]
+
+[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"]
+
+[node name="Title" type="Node2D"]
+script = ExtResource("1_title")
+
+[node name="Walls" type="StaticBody2D" parent="."]
+script = ExtResource("2_walls")
+
+[node name="Core" type="StaticBody2D" parent="."]
+script = ExtResource("3_core")
+radius = 200.0
+metadata/bounce = 0.9
+
+[node name="Balls" type="Node2D" parent="."]
+
+[node name="UI" type="CanvasLayer" parent="."]
+
+[node name="Title" type="Label" parent="UI"]
+anchor_top = 0.25
+anchor_right = 1.0
+anchor_bottom = 0.25
+offset_top = -70.0
+offset_bottom = 70.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_font_sizes/font_size = 100
+text = "RedCore"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="Start" type="Button" parent="UI"]
+anchor_left = 0.5
+anchor_top = 0.75
+anchor_right = 0.5
+anchor_bottom = 0.75
+offset_left = -100.0
+offset_top = -30.0
+offset_right = 100.0
+offset_bottom = 30.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_font_sizes/font_size = 40
+text = "Start"
+flat = true
+
+[node name="LevelInfo" type="Label" parent="UI"]
+anchor_top = 0.75
+anchor_right = 1.0
+anchor_bottom = 0.75
+offset_left = 16.0
+offset_top = 40.0
+offset_right = -16.0
+offset_bottom = 100.0
+grow_horizontal = 2
+theme_override_font_sizes/font_size = 20
+horizontal_alignment = 1
+autowrap_mode = 3

+ 52 - 0
godot/scripts/ball.gd

@@ -0,0 +1,52 @@
+class_name Ball
+extends CharacterBody2D
+## A ball that bounces off everything and speeds up slightly on each bounce.
+
+## Emitted for every contact, so the game can react to what the ball touched.
+signal hit(ball: Ball, other: Node)
+
+## A superball destroys one indestructible block, then turns back into a normal ball.
+var superball := false:
+	set(value):
+		superball = value
+		queue_redraw()
+
+
+func throw() -> void:
+	var angle := deg_to_rad(randf_range(-Params.THROW_ANGLE, Params.THROW_ANGLE))
+	velocity = Vector2(sin(angle), -cos(angle)) * Params.INITIAL_SPEED
+
+
+func _physics_process(delta: float) -> void:
+	var motion := velocity * delta
+	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)
+		hit.emit(self, other)
+		if is_queued_for_deletion():
+			return
+	_kick_power_ups()
+
+
+## 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:
+			continue
+		var normal := offset / dist
+		var approach := (power_up.linear_velocity - velocity).dot(normal)
+		if approach < 0.0:
+			power_up.linear_velocity -= 2.0 * approach * normal
+			hit.emit(self, power_up)
+
+
+func _draw() -> void:
+	draw_circle(Vector2.ZERO, Params.BALL_RADIUS, Params.COLOR_ORANGE if superball else Params.COLOR_GREY)

+ 51 - 0
godot/scripts/block.gd

@@ -0,0 +1,51 @@
+class_name Block
+extends AnimatableBody2D
+## One arc-shaped segment of a ring around the core.
+
+var indestructible := false
+var color := Params.COLOR_BLUE
+var _polygon := PackedVector2Array()
+
+
+func _init(inner: float, outer: float, begin: float, end: float, is_indestructible: bool) -> void:
+	sync_to_physics = false
+	collision_layer = Params.LAYER_BLOCKS
+	collision_mask = 0
+	indestructible = is_indestructible
+
+	# Leave a ~1px gap between neighbouring segments.
+	begin += 1.0 / outer
+	end -= 1.0 / outer
+	var steps := maxi(2, int((end - begin) * sqrt(outer) / 1.5))
+	var outer_arc := _arc(outer, begin, end, steps)
+	# The drawn shape stops 2px short of the inner radius to leave a gap between rings.
+	_polygon = outer_arc + _arc(inner + 2.0, end, begin, steps)
+
+	var collision := CollisionPolygon2D.new()
+	collision.polygon = outer_arc + _arc(inner, end, begin, steps)
+	add_child(collision)
+
+	if indestructible:
+		color = Params.COLOR_ORANGE
+	else:
+		color = Color8(
+			int(102 * (randf() / 5.0 + 0.5)),
+			int(217 * (randf() / 5.0 + 0.5)),
+			int(239 * (randf() / 5.0 + 0.5)))
+
+
+func destroy() -> void:
+	collision_layer = 0
+	queue_free()
+
+
+func _draw() -> void:
+	draw_colored_polygon(_polygon, color)
+
+
+static func _arc(radius: float, from: float, to: float, steps: int) -> PackedVector2Array:
+	var points := PackedVector2Array()
+	for i in steps + 1:
+		var angle := lerpf(from, to, float(i) / steps)
+		points.append(Vector2(sin(angle), cos(angle)) * radius)
+	return points

+ 68 - 0
godot/scripts/blocks.gd

@@ -0,0 +1,68 @@
+class_name Blocks
+extends Node2D
+## The core plus rings of rotating blocks around it, generated from the level number.
+
+const SEGMENTS_PER_RING := 10
+const MAX_RING_SPEED := 30.0 # degrees per second
+
+var core: Core
+var _rings: Array[Node2D] = []
+var _speeds: Array[float] = []
+
+
+func build(level: int) -> void:
+	var layout := _layout(level)
+	for index in range(layout.rings - 1, -1, -1):
+		_add_ring(layout.size, index, layout.indestructible_chance)
+	core = Core.new()
+	add_child(core)
+
+
+func _physics_process(delta: float) -> void:
+	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.
+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:
+	var inner := index * size + Params.CORE_RADIUS
+	var outer := (index + 1) * size + Params.CORE_RADIUS
+
+	# Evenly spaced segment boundaries, each jittered so segments have uneven lengths.
+	var step := TAU / SEGMENTS_PER_RING
+	var bounds: Array[float] = []
+	for i in SEGMENTS_PER_RING:
+		bounds.append(step * i + randf_range(-1.0, 1.0) * 0.45 * step)
+	bounds.append(bounds[0] + TAU)
+
+	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))
+	add_child(ring)
+	_rings.append(ring)
+	_speeds.append(randf_range(-MAX_RING_SPEED, MAX_RING_SPEED))

+ 27 - 0
godot/scripts/core.gd

@@ -0,0 +1,27 @@
+class_name Core
+extends StaticBody2D
+## The red core. Hitting it in the game scene wins the level.
+
+@export var radius := Params.CORE_RADIUS
+
+
+func _ready() -> void:
+	collision_layer = Params.LAYER_BLOCKS
+	collision_mask = 0
+	var shape := CircleShape2D.new()
+	shape.radius = radius
+	var collision := CollisionShape2D.new()
+	collision.shape = shape
+	add_child(collision)
+
+
+## Grows to fill the screen; returns when the animation ends.
+func explode() -> void:
+	collision_layer = 0
+	var tween := create_tween()
+	tween.tween_property(self, "scale", scale * 20.0, 1.0)
+	await tween.finished
+
+
+func _draw() -> void:
+	draw_circle(Vector2.ZERO, radius, Params.COLOR_RED)

+ 226 - 0
godot/scripts/game.gd

@@ -0,0 +1,226 @@
+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")
+
+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
+
+@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
+
+
+func _ready() -> void:
+	screen = get_viewport_rect().size
+	level = Progress.current_level
+	last_touch = _now()
+	$HUD/Level.text = "Level %d" % level
+
+	blocks.position = screen / 2.0
+	blocks.build(level)
+	paddle.position = Vector2(screen.x / 2.0, screen.y - Params.PADDLE_ALTITUDE - Params.PADDLE_HEIGHT / 2.0)
+	end_button.pressed.connect(_restart)
+
+	get_tree().create_timer(10.0).timeout.connect(_drop_level_save)
+	_drop_triple_balls()
+	_drop_paddle()
+	_drop_superball()
+	_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
+		if not block.indestructible:
+			$Sounds/Pipe.play()
+			block.destroy()
+		else:
+			$Sounds/Metal.play()
+			if ball.superball:
+				block.destroy()
+				ball.superball = false
+	elif other is Core:
+		_win()
+	elif other.is_in_group("bottom"):
+		_lose_ball(ball)
+
+
+func _on_power_up_collected(power_up: PowerUp) -> void:
+	if over:
+		return
+	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():
+				_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:
+			_alert(Params.MSG_SUPER_BALL)
+			var live := _live_balls()
+			if not live.is_empty():
+				live[0].superball = true
+
+
+func _lose_ball(ball: Ball) -> void:
+	$Sounds/Bomb.play()
+	_burst(ball.global_position)
+	ball.queue_free()
+	if _live_balls().is_empty():
+		over = true
+		_clear_play_field()
+		end_message.text = Params.MSG_OVER
+		end_message.show()
+		end_button.text = Params.MSG_RESTART
+		end_menu.show()
+
+
+func _win() -> void:
+	over = true
+	$Sounds/Win.play()
+	_clear_play_field()
+	await blocks.core.explode()
+	level += 1
+	end_message.hide()
+	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()
+
+
+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.hit.connect(_on_ball_hit)
+	balls.add_child(ball)
+	ball.throw()
+
+
+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_level_save() -> void:
+	if over:
+		return
+	if randf() < Params.DROP_LEVEL_SAVE:
+		_spawn_power_up(PowerUp.Kind.SAVE)
+	_schedule(_drop_level_save)
+
+
+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 _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
+	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)
+	power_up.collected.connect(_on_power_up_collected)
+	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.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)
+
+
+## A short orange flash where a ball fell out.
+func _burst(at: Vector2) -> void:
+	var particles := CPUParticles2D.new()
+	particles.position = at
+	particles.one_shot = true
+	particles.amount = 80
+	particles.lifetime = 1.0
+	particles.explosiveness = 0.9
+	particles.spread = 180.0
+	particles.gravity = Vector2.ZERO
+	particles.initial_velocity_min = 40.0
+	particles.initial_velocity_max = 160.0
+	particles.scale_amount_min = 3.0
+	particles.scale_amount_max = 6.0
+	var ramp := Gradient.new()
+	ramp.set_color(0, Color(1.0, 0.85, 0.3))
+	ramp.set_color(1, Color(1.0, 0.3, 0.0, 0.0))
+	particles.color_ramp = ramp
+	particles.finished.connect(particles.queue_free)
+	add_child(particles)
+	particles.emitting = true
+
+
+func _now() -> float:
+	return Time.get_ticks_msec() / 1000.0

+ 70 - 0
godot/scripts/paddle.gd

@@ -0,0 +1,70 @@
+class_name Paddle
+extends AnimatableBody2D
+## Follows the player's finger (or mouse) along the bottom of the screen.
+
+enum Width { NORMAL, HALF, DOUBLE }
+
+const SCALE := { Width.NORMAL: 1.0, Width.HALF: 0.5, Width.DOUBLE: 2.0 }
+
+var width := Width.NORMAL
+var _screen_width := 0.0
+var _shape := RectangleShape2D.new()
+
+@onready var _timer: Timer = $NormalTimer
+
+
+func _ready() -> void:
+	_screen_width = get_viewport_rect().size.x
+	$CollisionShape2D.shape = _shape
+	_timer.timeout.connect(normal_size)
+	normal_size()
+
+
+func _unhandled_input(event: InputEvent) -> void:
+	if event is InputEventScreenTouch and event.is_pressed():
+		_move_to((event as InputEventScreenTouch).position.x)
+	elif event is InputEventScreenDrag:
+		_move_to((event as InputEventScreenDrag).position.x)
+
+
+## Doubles the paddle for a while, or cancels a halving.
+func double_size() -> void:
+	if width == Width.HALF:
+		normal_size()
+		return
+	_set_width(Width.DOUBLE)
+	_timer.start(Params.POWER_UP_PADDLE_SECONDS)
+
+
+## Halves the paddle for a while, or cancels a doubling.
+func half_size() -> void:
+	if width == Width.DOUBLE:
+		normal_size()
+		return
+	_set_width(Width.HALF)
+	_timer.start(Params.POWER_UP_PADDLE_SECONDS)
+
+
+func normal_size() -> void:
+	_timer.stop()
+	_set_width(Width.NORMAL)
+
+
+func _set_width(value: Width) -> void:
+	width = value
+	_shape.size = Vector2((Params.PADDLE_WIDTH + Params.PADDLE_HEIGHT) * SCALE[width], Params.PADDLE_HEIGHT)
+	_move_to(position.x)
+	queue_redraw()
+
+
+func _move_to(x: float) -> void:
+	var half: float = Params.PADDLE_WIDTH / 2.0 * SCALE[width]
+	position.x = clampf(x, half, _screen_width - half)
+
+
+func _draw() -> void:
+	var half: float = Params.PADDLE_WIDTH / 2.0 * SCALE[width]
+	var cap := Params.PADDLE_HEIGHT / 2.0
+	draw_line(Vector2(-half, 0), Vector2(half, 0), Params.COLOR_GREY, Params.PADDLE_HEIGHT)
+	draw_circle(Vector2(-half, 0), cap, Params.COLOR_GREY)
+	draw_circle(Vector2(half, 0), cap, Params.COLOR_GREY)

+ 53 - 0
godot/scripts/params.gd

@@ -0,0 +1,53 @@
+class_name Params
+## Game tuning values, ported from the cocos2d-x params.h.
+
+# Ball
+const BALL_RADIUS := 15.0
+const THROW_ANGLE := 20.0 # degrees either side of straight up
+const INITIAL_SPEED := 500.0
+const BALL_BOUNCE := 1.005 # every bounce speeds the ball up a little
+
+# Core
+const CORE_RADIUS := 50.0
+
+# Paddle
+const PADDLE_WIDTH := 120.0
+const PADDLE_HEIGHT := 10.0
+const PADDLE_ALTITUDE := 90.0 # distance from the bottom of the screen
+
+# Colors
+const COLOR_PINK := Color("#f92672")
+const COLOR_BLUE := Color("#66d9ef")
+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)
+
+# Physics layers (bit values)
+const LAYER_WALLS := 1
+const LAYER_PADDLE := 2
+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
+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 POWER_UP_INTERVAL := 2.5
+const POWER_UP_PADDLE_SECONDS := 10.0
+
+# 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. In %.1f hour(s) you will regrow a level."
+const MSG_NEXT_LEVEL := "Next Level"
+const MSG_RESTART := "Restart"
+const MSG_SUPER_BALL := "Super Ball!"

+ 34 - 0
godot/scripts/power_up.gd

@@ -0,0 +1,34 @@
+class_name PowerUp
+extends RigidBody2D
+## A small dot 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 }
+
+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,
+}
+
+var kind := Kind.SAVE
+
+
+func _ready() -> void:
+	add_to_group("power_ups")
+	body_entered.connect(_on_body_entered)
+
+
+func _on_body_entered(body: Node) -> void:
+	if body is Paddle:
+		collected.emit(self)
+		queue_free()
+	elif body.is_in_group("bottom"):
+		queue_free()
+
+
+func _draw() -> void:
+	draw_circle(Vector2.ZERO, RADIUS, COLORS[kind])

+ 33 - 0
godot/scripts/progress.gd

@@ -0,0 +1,33 @@
+extends Node
+## Saved level, which drops by one for every Params.LOSE_HOURS the player stays away.
+
+const PATH := "user://progress.cfg"
+
+## Level the next game scene starts at.
+var current_level := 0
+
+
+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)
+
+
+## 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 now := Time.get_unix_time_from_system()
+	var period := 3600.0 * Params.LOSE_HOURS
+	var remaining := period + last - now
+	while remaining < 0.0 and level > 0:
+		last += period
+		level -= 1
+		remaining = period + last - now
+	config.set_value("progress", "level", level)
+	config.set_value("progress", "time", last)
+	config.save(PATH)
+	return [level, remaining / 3600.0]

+ 27 - 0
godot/scripts/title.gd

@@ -0,0 +1,27 @@
+extends Node2D
+## Title screen: a big core with balls bouncing around it, and the saved level.
+
+const BALL_SCENE := preload("res://scenes/ball.tscn")
+
+var _level := 0
+
+
+func _ready() -> void:
+	var screen := get_viewport_rect().size
+	$Core.position = Vector2(screen.x / 2.0, screen.y * 0.25)
+	for i in 10:
+		var ball: Ball = BALL_SCENE.instantiate()
+		ball.position = Vector2(screen.x / 2.0, screen.y - Params.BALL_RADIUS * 2.0)
+		$Balls.add_child(ball)
+		ball.throw()
+
+	var progress := Progress.load_level()
+	_level = progress[0]
+	if _level != 0:
+		$UI/LevelInfo.text = Params.MSG_LEVEL_BACK % [_level, progress[1]]
+	$UI/Start.pressed.connect(_on_start_pressed)
+
+
+func _on_start_pressed() -> void:
+	Progress.current_level = _level
+	get_tree().change_scene_to_file("res://scenes/game.tscn")

+ 30 - 0
godot/scripts/walls.gd

@@ -0,0 +1,30 @@
+class_name Walls
+extends StaticBody2D
+## Screen edges. The bottom edge is its own body in the "bottom" group, so the game can tell
+## when a ball or power-up falls out.
+
+
+func _ready() -> void:
+	collision_layer = Params.LAYER_WALLS
+	collision_mask = 0
+	var size := get_viewport_rect().size
+	_add_boundary(self, Vector2.RIGHT, 0.0)
+	_add_boundary(self, Vector2.LEFT, -size.x)
+	_add_boundary(self, Vector2.DOWN, 0.0)
+
+	var bottom := StaticBody2D.new()
+	bottom.collision_layer = Params.LAYER_WALLS
+	bottom.collision_mask = 0
+	bottom.add_to_group("bottom")
+	add_child(bottom)
+	_add_boundary(bottom, Vector2.UP, -size.y)
+
+
+## Adds a half-plane; [param normal] points into the open (playable) side.
+func _add_boundary(body: StaticBody2D, normal: Vector2, distance: float) -> void:
+	var shape := WorldBoundaryShape2D.new()
+	shape.normal = normal
+	shape.distance = distance
+	var collision := CollisionShape2D.new()
+	collision.shape = shape
+	body.add_child(collision)