浏览代码

Add a Levels screen with best stars per level

A grid of level tiles shows the best stars for each level, highlights
the current one and lets the player replay any level up to the saved
one. The next 10 levels show locked, and a coloured dot marks levels
where a power or segment unlocks; tapping a locked tile says what
unlocks there.

The screen opens from a new Levels button on the home screen and on
the end-of-game menu (after a win or a game over). The end menu now
sits on a dark panel so it stays readable over the rings.

Replaying an old level never lowers the saved level, but a win still
resets the decay clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gabriel Capella 5 天之前
父节点
当前提交
2784f50a53
共有 9 个文件被更改,包括 295 次插入 和 14 次删除
  1. 28 5
      scenes/game.tscn
  2. 91 0
      scenes/levels.tscn
  3. 18 4
      scenes/title.tscn
  4. 10 4
      scripts/game.gd
  5. 123 0
      scripts/levels.gd
  6. 1 0
      scripts/levels.gd.uid
  7. 1 0
      scripts/params.gd
  8. 22 1
      scripts/progress.gd
  9. 1 0
      scripts/title.gd

+ 28 - 5
scenes/game.tscn

@@ -10,6 +10,18 @@
 [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_panel"]
+content_margin_left = 32.0
+content_margin_top = 24.0
+content_margin_right = 32.0
+content_margin_bottom = 20.0
+bg_color = Color(0.152941, 0.156863, 0.133333, 0.92)
+corner_radius_top_left = 22
+corner_radius_top_right = 22
+corner_radius_bottom_right = 22
+corner_radius_bottom_left = 22
+anti_aliasing_size = 1.5
+
 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pill"]
 content_margin_left = 36.0
 content_margin_top = 10.0
@@ -107,7 +119,7 @@ grow_horizontal = 2
 grow_vertical = 2
 mouse_filter = 2
 
-[node name="EndMenu" type="VBoxContainer" parent="HUD"]
+[node name="EndMenu" type="PanelContainer" parent="HUD"]
 visible = false
 anchors_preset = 8
 anchor_left = 0.5
@@ -116,24 +128,28 @@ anchor_right = 0.5
 anchor_bottom = 0.5
 grow_horizontal = 2
 grow_vertical = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_panel")
+
+[node name="Box" type="VBoxContainer" parent="HUD/EndMenu"]
+layout_mode = 2
 theme_override_constants/separation = 16
 
-[node name="Message" type="Label" parent="HUD/EndMenu"]
+[node name="Message" type="Label" parent="HUD/EndMenu/Box"]
 layout_mode = 2
 theme_override_font_sizes/font_size = 40
 horizontal_alignment = 1
 
-[node name="Stars" type="Control" parent="HUD/EndMenu"]
+[node name="Stars" type="Control" parent="HUD/EndMenu/Box"]
 layout_mode = 2
 script = ExtResource("9_stars")
 star_size = 48.0
 
-[node name="Score" type="Label" parent="HUD/EndMenu"]
+[node name="Score" type="Label" parent="HUD/EndMenu/Box"]
 layout_mode = 2
 theme_override_font_sizes/font_size = 26
 horizontal_alignment = 1
 
-[node name="Button" type="Button" parent="HUD/EndMenu"]
+[node name="Button" type="Button" parent="HUD/EndMenu/Box"]
 layout_mode = 2
 size_flags_horizontal = 4
 theme_override_colors/font_color = Color(0.152941, 0.156863, 0.133333, 1)
@@ -144,3 +160,10 @@ 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")
+
+[node name="Levels" type="Button" parent="HUD/EndMenu/Box"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme_override_font_sizes/font_size = 26
+text = "Levels"
+flat = true

+ 91 - 0
scenes/levels.tscn

@@ -0,0 +1,91 @@
+[gd_scene format=3]
+
+[ext_resource type="Script" path="res://scripts/levels.gd" id="1_levels"]
+[ext_resource type="Script" path="res://scripts/stars.gd" id="2_stars"]
+
+[node name="Levels" type="Control"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_levels")
+
+[node name="Header" type="HBoxContainer" parent="."]
+anchor_right = 1.0
+offset_left = 12.0
+offset_top = 12.0
+offset_right = -16.0
+offset_bottom = 64.0
+grow_horizontal = 2
+
+[node name="Home" type="Button" parent="Header"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 24
+text = "< Home"
+flat = true
+
+[node name="Title" type="Label" parent="Header"]
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_font_sizes/font_size = 34
+text = "Levels"
+horizontal_alignment = 1
+
+[node name="Totals" type="HBoxContainer" parent="Header"]
+layout_mode = 2
+
+[node name="Star" type="Control" parent="Header/Totals"]
+layout_mode = 2
+size_flags_vertical = 4
+script = ExtResource("2_stars")
+count = 1
+filled = 1
+star_size = 24.0
+
+[node name="Count" type="Label" parent="Header/Totals"]
+layout_mode = 2
+theme_override_font_sizes/font_size = 24
+text = "0"
+
+[node name="Scroll" type="ScrollContainer" parent="."]
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_top = 76.0
+offset_bottom = -90.0
+grow_horizontal = 2
+grow_vertical = 2
+horizontal_scroll_mode = 0
+scroll_deadzone = 12
+
+[node name="Margin" type="MarginContainer" parent="Scroll"]
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_constants/margin_left = 16
+theme_override_constants/margin_top = 8
+theme_override_constants/margin_right = 16
+theme_override_constants/margin_bottom = 16
+
+[node name="Grid" type="GridContainer" parent="Scroll/Margin"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme_override_constants/h_separation = 12
+theme_override_constants/v_separation = 12
+columns = 5
+
+[node name="Info" type="Label" parent="."]
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 16.0
+offset_top = -84.0
+offset_right = -16.0
+offset_bottom = -16.0
+grow_horizontal = 2
+grow_vertical = 0
+theme_override_colors/font_color = Color(1, 1, 1, 0.7)
+theme_override_font_sizes/font_size = 20
+horizontal_alignment = 1
+vertical_alignment = 1
+autowrap_mode = 3

+ 18 - 4
scenes/title.tscn

@@ -118,10 +118,24 @@ theme_override_styles/pressed = SubResource("StyleBoxFlat_pill")
 theme_override_styles/focus = SubResource("StyleBoxFlat_pill")
 text = "Play"
 
+[node name="Levels" type="Button" parent="UI"]
+anchor_left = 0.5
+anchor_top = 0.7
+anchor_right = 0.5
+anchor_bottom = 0.7
+offset_left = -100.0
+offset_top = 44.0
+offset_right = 100.0
+offset_bottom = 86.0
+grow_horizontal = 2
+theme_override_font_sizes/font_size = 26
+text = "Levels"
+flat = true
+
 [node name="Next" type="Label" parent="UI"]
-anchor_top = 0.79
+anchor_top = 0.82
 anchor_right = 1.0
-anchor_bottom = 0.79
+anchor_bottom = 0.82
 offset_left = 16.0
 offset_top = -16.0
 offset_right = -16.0
@@ -133,9 +147,9 @@ theme_override_font_sizes/font_size = 22
 horizontal_alignment = 1
 
 [node name="Decay" type="Label" parent="UI"]
-anchor_top = 0.87
+anchor_top = 0.9
 anchor_right = 1.0
-anchor_bottom = 0.87
+anchor_bottom = 0.9
 offset_left = 24.0
 offset_top = -24.0
 offset_right = -24.0

+ 10 - 4
scripts/game.gd

@@ -32,10 +32,11 @@ var _trauma := 0.0
 @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 end_message: Label = $HUD/EndMenu/Box/Message
+@onready var end_button: Button = $HUD/EndMenu/Box/Button
+@onready var levels_button: Button = $HUD/EndMenu/Box/Levels
+@onready var end_stars: Stars = $HUD/EndMenu/Box/Stars
+@onready var end_score: Label = $HUD/EndMenu/Box/Score
 @onready var score_label: Label = $HUD/Score
 @onready var combo_label: Label = $HUD/Combo
 @onready var camera: Camera2D = $Camera
@@ -60,6 +61,7 @@ func _ready() -> void:
 	blocks.segment_broken.connect(_on_segment_broken)
 	blocks.exploded.connect(func(_at: Vector2) -> void: _shake(0.5))
 	end_button.pressed.connect(_restart)
+	levels_button.pressed.connect(_show_levels)
 	_update_score()
 	Music.play("game")
 
@@ -226,6 +228,10 @@ func _win() -> void:
 	end_menu.show()
 
 
+func _show_levels() -> void:
+	get_tree().change_scene_to_file("res://scenes/levels.tscn")
+
+
 ## Restarts at the current level (the next one after a win).
 func _restart() -> void:
 	Progress.current_level = level

+ 123 - 0
scripts/levels.gd

@@ -0,0 +1,123 @@
+extends Control
+## Level map: a tile per level with its best stars. Levels up to the saved one can be
+## played (replays keep the best stars); a few locked ones show what is ahead, and a dot
+## marks levels where a new power or segment unlocks.
+
+const COLUMNS := 5
+const LOCKED_AHEAD := 10 # locked tiles shown past the saved level
+const TILE := Vector2(88, 96)
+
+var _saved := 0
+var _unlocks := {} # level -> Array of { name, color }
+
+@onready var grid: GridContainer = $Scroll/Margin/Grid
+@onready var scroll: ScrollContainer = $Scroll
+@onready var info: Label = $Info
+
+
+func _ready() -> void:
+	Music.play("menu")
+	_saved = Progress.load_level()[0]
+	$Header/Totals/Count.text = str(Progress.total_stars())
+	$Header/Home.pressed.connect(func() -> void: get_tree().change_scene_to_file("res://scenes/title.tscn"))
+	_collect_unlocks()
+	grid.columns = COLUMNS
+	var last := maxi(_saved, Progress.highest_won()) + LOCKED_AHEAD
+	var current: Control
+	for level in last + 1:
+		var tile := _make_tile(level)
+		grid.add_child(tile)
+		if level == _saved:
+			current = tile
+	info.text = "Tap a level to play it. Replays keep your best stars."
+	# Wait for layout, then centre the current level.
+	await get_tree().process_frame
+	await get_tree().process_frame
+	scroll.scroll_vertical = int(current.position.y + TILE.y / 2.0 - scroll.size.y / 2.0)
+
+
+func _collect_unlocks() -> void:
+	for info_dict: Dictionary in PowerUp.KINDS.values() + Block.KINDS.values():
+		var unlock: int = info_dict.unlock
+		if unlock > 0:
+			if not _unlocks.has(unlock):
+				_unlocks[unlock] = []
+			_unlocks[unlock].append(info_dict)
+
+
+func _make_tile(level: int) -> Button:
+	var playable := level <= _saved
+	var tile := Button.new()
+	tile.custom_minimum_size = TILE
+	tile.focus_mode = Control.FOCUS_NONE
+	var style := _style(level)
+	for state in ["normal", "hover", "pressed", "disabled"]:
+		tile.add_theme_stylebox_override(state, style)
+	tile.pressed.connect(_on_tile_pressed.bind(level))
+
+	var box := VBoxContainer.new()
+	box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+	box.alignment = BoxContainer.ALIGNMENT_CENTER
+	box.mouse_filter = Control.MOUSE_FILTER_IGNORE
+	tile.add_child(box)
+	var number := Label.new()
+	number.text = str(level)
+	number.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
+	number.add_theme_font_size_override("font_size", 30)
+	var ink := Params.COLOR_BACK if level == _saved else Color(1, 1, 1, 1.0 if playable else 0.3)
+	number.add_theme_color_override("font_color", ink)
+	box.add_child(number)
+	var stars := Stars.new()
+	stars.star_size = 15.0
+	stars.spacing = 3.0
+	stars.filled = Progress.stars(level)
+	stars.modulate.a = 1.0 if playable else 0.3
+	box.add_child(stars)
+	for child in box.get_children():
+		child.mouse_filter = Control.MOUSE_FILTER_IGNORE
+
+	if _unlocks.has(level):
+		var dot := UnlockDot.new()
+		dot.color = _unlocks[level][0].color
+		dot.position = Vector2(TILE.x - 16, 8)
+		tile.add_child(dot)
+	return tile
+
+
+func _style(level: int) -> StyleBoxFlat:
+	var style := StyleBoxFlat.new()
+	style.set_corner_radius_all(14)
+	style.anti_aliasing_size = 1.5
+	if level == _saved:
+		style.bg_color = Params.COLOR_ORANGE
+	elif level < _saved:
+		style.bg_color = Color("#3e3f37")
+	else:
+		style.bg_color = Color("#30312b")
+	return style
+
+
+func _on_tile_pressed(level: int) -> void:
+	if level <= _saved:
+		Progress.current_level = level
+		get_tree().change_scene_to_file("res://scenes/game.tscn")
+		return
+	var names := []
+	for unlock: Dictionary in _unlocks.get(level, []):
+		names.append(unlock.name)
+	info.text = "Level %d is locked. Reach it to play" % level
+	info.text += (": %s unlocks there." % ", ".join(names)) if not names.is_empty() else "."
+
+
+## The coloured dot on a tile where something new unlocks.
+class UnlockDot:
+	extends Control
+
+	var color := Color.WHITE
+
+	func _ready() -> void:
+		mouse_filter = Control.MOUSE_FILTER_IGNORE
+		size = Vector2(10, 10)
+
+	func _draw() -> void:
+		draw_circle(Vector2(5, 5), 5.0, color, true, -1.0, true)

+ 1 - 0
scripts/levels.gd.uid

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

+ 1 - 0
scripts/params.gd

@@ -82,6 +82,7 @@ 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_LEVELS := "Levels"
 const MSG_RESTART := "Restart"
 const MSG_SUPER_BALL := "Super Ball!"
 const MSG_SLOW := "Slow motion"

+ 22 - 1
scripts/progress.gd

@@ -14,12 +14,33 @@ func _ready() -> void:
 	_config.load(PATH)
 
 
+## Records a win that leads to [param level]. The saved level only goes up (replaying an
+## old level never lowers it), but any win restarts the decay clock.
 func save_level(level: int) -> void:
-	_config.set_value("progress", "level", level)
+	_config.set_value("progress", "level", maxi(level, saved_level()))
 	_config.set_value("progress", "time", Time.get_unix_time_from_system())
 	_config.save(PATH)
 
 
+## The level the player continues from (after any decay applied by [method load_level]).
+func saved_level() -> int:
+	return _config.get_value("progress", "level", 0)
+
+
+## Best stars earned on [param level], 0 if never won.
+func stars(level: int) -> int:
+	return _config.get_value("stars", str(level), 0)
+
+
+## Highest level the player has won, -1 if none.
+func highest_won() -> int:
+	var highest := -1
+	if _config.has_section("stars"):
+		for key in _config.get_section_keys("stars"):
+			highest = maxi(highest, int(key))
+	return highest
+
+
 ## Applies any decay since the last save and returns [level, hours until the next decay].
 func load_level() -> Array:
 	var level: int = _config.get_value("progress", "level", 0)

+ 1 - 0
scripts/title.gd

@@ -38,6 +38,7 @@ func _ready() -> void:
 	if _level != 0:
 		$UI/Decay.text = Params.MSG_LEVEL_BACK % [_level, progress[1]]
 	$UI/Play.pressed.connect(_on_play_pressed)
+	$UI/Levels.pressed.connect(func() -> void: get_tree().change_scene_to_file("res://scenes/levels.tscn"))
 	$UI/MusicToggle.pressed.connect(_on_music_toggled)
 	_show_music_state()
 	Music.play("menu")