Implement achievement system: add achievement controller, UI components, and tests

This commit is contained in:
Kirill
2026-10-18 20:14:27 +03:00
parent e41d652631
commit e1782c7b10
8 changed files with 358 additions and 10 deletions
+29
View File
@@ -249,3 +249,32 @@ H списывает только оставшуюся после замещен
`tests/test_extra_params.gd` проверяет загрузку точных чисел и изменение энергии,
плотности, объёма пустот, порогов индикатора и бонуса горючести.
## Достижения и уведомления
`OwniverseAchievementLoader` задаёт локализованные достижения. Строки с
`Ignore=1` исключены из проверок, списка и общего количества. Для включённых
строк поддерживаются `age::порог` и `total:ID:порог`. `total` — накопленные
поступления `stats.total_amount`, включая автоматическое производство и
эволюцию; расход и гибель объектов не уменьшают этот счётчик. Возраст берётся
из завершённого состояния симуляции. Пороги разбираются точной арифметикой.
`ui/achievements/achievement_controller.gd` проверяет условия при смене
ревизии состояния и после создания новой вселенной. Пустое OpenCondition
делает достижение видимым без уведомления; непустое открывает его с серой
иконкой `g`. Выполнение AchieveCondition выдаёт цветную иконку `c`. Если оба
условия выполнены одновременно, показывается только цветное уведомление.
Уже полученные достижения повторно не выдаются.
Состояния видимости и получения сохраняются отдельно в
`user://achievements.cfg` и сохраняются между вселенными. Очередь уведомлений
не сохраняется. Единая панель поверх индикаторов следует за выбранной
страницей: показ 3 секунды, затем затухание 0,3 секунды. Меню приостанавливает
таймер. Клик или касание закрывает текущее уведомление; очередь сохраняется. После показа индикаторы возвращаются. Тексты списка и уведомления
обновляются при смене языка; список прокручивается.
`tests/test_achievements.gd` проверяет все включённые пороги, фильтр Ignore,
открытие и получение, отсутствие повторных уведомлений, паузу в меню,
возврат индикаторов, сохранение прогресса и локализацию. Файл прогресса теста
создаётся отдельно в `/tmp`.
+6
View File
@@ -7,6 +7,7 @@ const SPEED_TEXTURE_NAME_TEMPLATE = "res://ui/topmenu/time&speed/time%s.png"
const ItemDescription = preload("res://world/owniverse/item_description.gd")
const WorldIndicators = preload("res://ui/indicators/world_indicators.gd")
var achievements: Node
var world_indicators: RefCounted
var indicator_elapsed: float = 0.0
var indicator_revision: int = -1
@@ -339,6 +340,10 @@ func _ready() -> void:
create_new_universe()
load_data()
_refresh_universe_ui()
achievements = preload("res://ui/achievements/achievement_controller.gd").new()
add_child(achievements)
achievements.initialize(self)
achievements.update(0.0)
g_counters.swipe.sec = 0.2
@@ -406,6 +411,7 @@ func _process(delta: float) -> void:
owniverse.evolute_frame(delta, get_tree())
_process_apocalypse(delta)
if achievements != null: achievements.update(delta)
clock_display_elapsed += delta
if clock_display_elapsed >= 0.1:
clock_display_elapsed = fmod(clock_display_elapsed, 0.1)
+107
View File
@@ -0,0 +1,107 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Controller = preload("res://ui/achievements/achievement_controller.gd")
var failures := 0
class MockWorld extends RefCounted:
var state := {"time": {"year": 0}, "stats": {"total_amount": {}}}
var presentation := {"revision": 0}
class MockStage extends Control:
const PAGE_WIDTH = 1080
var owniverse = MockWorld.new()
var screens: Dictionary
var stage_page_scroller: ScrollContainer
func _initialize() -> void:
call_deferred("run")
func check(value: bool, message: String) -> void:
if not value:
failures += 1
push_error(message)
func node(parent: Node, child: Node, label: String) -> Node:
child.name = label
parent.add_child(child)
return child
func run() -> void:
check(load("res://stage/stage.gd") != null, "Stage integration compiles")
var stage := MockStage.new()
root.add_child(stage)
var world = node(stage, Control.new(), "World")
stage.screens = {"World": world}
stage.stage_page_scroller = node(world, ScrollContainer.new(), "PageScroller")
var pages = node(stage.stage_page_scroller, HBoxContainer.new(), "HBoxContainer")
for names in [["ForcePage", "ForceHeader"], ["ResourcePage", "ResourceHeader"], ["StarPage", "StarHeader"], ["StructurePage", "StructureHeader"], ["LifePage", "LifeHeader"]]:
node(node(pages, VBoxContainer.new(), names[0]), HBoxContainer.new(), names[1])
var screen = node(stage, Control.new(), "Achievements")
node(node(screen, ScrollContainer.new(), "AchievementList"), VBoxContainer.new(), "AchievementItems")
var controller = Controller.new()
controller.progress_path = "/tmp/owniverse-achievements-test-%s.cfg" % OS.get_process_id()
stage.add_child(controller)
controller.initialize(stage)
check(controller.definitions.size() == 72, "All 72 enabled achievements loaded")
check(not controller.definitions.has("a95"), "Ignored achievement excluded")
controller.update(0.0)
check(controller.queue.is_empty() and controller.current.is_empty(), "Initially visible achievements do not notify")
stage.owniverse.state.stats.total_amount.H = E.of("5E4")
stage.owniverse.presentation.revision += 1
controller.update(0.0)
check(controller.progress.a13.unlocked, "Cumulative hydrogen threshold unlocks")
check(controller.progress.a14.visible and not controller.progress.a14.unlocked, "Opening threshold reveals next achievement")
controller.queue.clear()
controller._enqueue("a14", "g")
controller._enqueue("a14", "c")
check(controller.queue.size() == 1 and controller.queue[0].type == "c", "Pending reveal upgrades to completion")
controller.queue.clear()
controller.current = {}
stage.owniverse.state.time.year = E.of("1E12")
stage.owniverse.state.stats.total_amount.H = E.of("1E24")
stage.owniverse.state.stats.total_amount.Star = E.of("9E16")
stage.owniverse.presentation.revision += 1
controller.update(0.0)
check(controller.progress.a4.unlocked and controller.progress.a20.unlocked and controller.progress.a71.unlocked, "Exact large thresholds unlock")
check(controller.current.type == "c", "Immediate completion emits only completed notification")
var count: int = controller.queue.size()
controller.update(0.0)
check(controller.queue.size() == count, "Repeated update does not duplicate events")
world.hide()
var time: float = controller.elapsed
controller.update(5.0)
check(controller.elapsed == time, "Menu pauses notification")
world.show()
controller.update(3.4)
check(controller.current.is_empty() and not controller.panel.visible, "Notification expires")
for header in controller.headers: check(header.modulate.a == 1.0, "Indicators restored")
controller.update(0.0)
var remaining: int = controller.queue.size()
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT
click.pressed = true
controller._on_notification_input(click)
check(controller.current.is_empty() and not controller.panel.visible, "Click dismisses notification")
check(controller.queue.size() == remaining, "Click preserves pending notifications")
for header in controller.headers: check(header.modulate.a == 1.0, "Click restores indicators")
stage.owniverse = MockWorld.new()
controller.update(0.0)
check(controller.progress.a71.unlocked, "New universe preserves unlocked achievements")
controller.progress.clear()
controller._load_progress()
check(controller.progress.a71.unlocked, "Progress survives reload")
# Every enabled definition must be reachable through its actual compiled rule.
for id in controller.definitions:
var rule: Dictionary = controller.conditions[id].achieve
var source := {"time": {"year": 0}, "stats": {"total_amount": {}}}
if rule.type == "age": source.time.year = E.subtract(rule.amount, 1)
else: source.stats.total_amount[rule.id] = E.subtract(rule.amount, 1)
check(not controller._matches(rule, source), id + " stays locked below threshold")
if rule.type == "age": source.time.year = rule.amount
else: source.stats.total_amount[rule.id] = rule.amount
check(controller._matches(rule, source), id + " completes at exact threshold")
var global: Node = root.get_node("Global")
var previous_locale: String = global.Locale
global.Locale = "ru"
controller.update(0.0)
check(controller.status.text.begins_with("Открыто"), "Status localizes")
global.Locale = previous_locale
var path: String = controller.progress_path
stage.free()
DirAccess.remove_absolute(path)
print("Achievement checks: ", "PASS" if failures == 0 else "FAIL")
quit(1 if failures else 0)
+1
View File
@@ -0,0 +1 @@
uid://uslcyxm8vpcn
+200
View File
@@ -0,0 +1,200 @@
extends Node
## Player-wide achievement progress and the shared in-game notification queue.
const E = preload("res://world/owniverse/math/exact.gd")
const ITEM = preload("res://ui/achievements/achievement_item.tscn")
var progress_path: String = "user://achievements.cfg"
const HOLD_SECONDS = 3.0
const FADE_SECONDS = 0.3
var global: Node
var stage: Control
var definitions: Dictionary = {}
var conditions: Dictionary = {}
var progress: Dictionary = {}
var items: Dictionary = {}
var queue: Array[Dictionary] = []
var current: Dictionary = {}
var elapsed: float = 0.0
var last_revision: int = -1
var last_world: Object
var locale: String = ""
var panel: PanelContainer
var notification_item: Control
var headers: Array[Control] = []
var status: Label
var list: VBoxContainer
var storage_available: bool = true
func initialize(owner_stage: Control) -> void:
stage = owner_stage
global = get_tree().root.get_node("Global")
locale = global.Locale
for id in global.Achievements.en:
var row: Dictionary = global.Achievements.en[id]
if str(row.Ignore) == "1": continue
definitions[id] = row
conditions[id] = {
"open": _parse_condition(str(row.OpenCondition)),
"achieve": _parse_condition(str(row.AchieveCondition)),
}
_load_progress()
list = stage.get_node("Achievements/AchievementList/AchievementItems")
for child in list.get_children():
list.remove_child(child)
child.queue_free()
status = Label.new()
status.add_theme_font_size_override("font_size", 32)
list.add_child(status)
var pages: Node = stage.get_node("World/PageScroller/HBoxContainer")
for path in ["ForcePage/ForceHeader", "ResourcePage/ResourceHeader", "StarPage/StarHeader", "StructurePage/StructureHeader", "LifePage/LifeHeader"]:
headers.append(pages.get_node(path))
panel = PanelContainer.new()
panel.name = "AchievementNotification"
panel.set_as_top_level(true)
panel.mouse_filter = Control.MOUSE_FILTER_STOP
panel.gui_input.connect(_on_notification_input)
panel.z_index = 20
stage.get_node("World").add_child(panel)
notification_item = ITEM.instantiate()
panel.add_child(notification_item)
_ignore_notification_children(notification_item)
panel.hide()
_rebuild_list()
func _parse_condition(expression: String) -> Dictionary:
if expression.is_empty(): return {}
var parts := expression.split(":")
assert(parts.size() == 3 and parts[0] in ["age", "total"], "Unsupported achievement condition: " + expression)
return {"type": parts[0], "id": parts[1], "amount": E.of(parts[2])}
func _matches(condition: Dictionary, source: Dictionary) -> bool:
if condition.is_empty(): return true
var amount: Variant = source.time.year if condition.type == "age" else source.stats.total_amount.get(condition.id, 0)
return E.ge(amount, condition.amount)
func update(delta: float) -> void:
if global.Locale != locale:
locale = global.Locale
_rebuild_list()
if not current.is_empty(): _display_event(current)
var world = stage.owniverse
if world != last_world or world.presentation.revision != last_revision:
last_world = world
last_revision = world.presentation.revision
_check(world.state)
# Menu time does not consume a notification's display time.
if not stage.screens.World.visible: return
if current.is_empty() and not queue.is_empty():
current = queue.pop_front()
elapsed = 0.0
_display_event(current)
if current.is_empty(): return
elapsed += delta
var page: int = clampi(roundi(float(stage.stage_page_scroller.scroll_horizontal) / stage.PAGE_WIDTH), 0, headers.size() - 1)
for i in headers.size():
headers[i].modulate.a = 0.0 if i == page else 1.0
panel.global_position = headers[page].global_position
panel.size = Vector2(headers[page].size.x, maxf(128.0, headers[page].size.y))
panel.modulate.a = clampf(1.0 - (elapsed - HOLD_SECONDS) / FADE_SECONDS, 0.0, 1.0)
if elapsed >= HOLD_SECONDS + FADE_SECONDS:
dismiss_notification()
func _ignore_notification_children(node: Node) -> void:
if node is Control: node.mouse_filter = Control.MOUSE_FILTER_IGNORE
for child in node.get_children(): _ignore_notification_children(child)
func _on_notification_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
panel.accept_event()
dismiss_notification()
elif event is InputEventScreenTouch and event.pressed:
panel.accept_event()
dismiss_notification()
func dismiss_notification() -> void:
panel.hide()
current = {}
elapsed = 0.0
for header in headers: header.modulate.a = 1.0
func _check(source: Dictionary) -> void:
var changed := false
for id in definitions:
var saved: Dictionary = progress.get(id, {"visible": false, "unlocked": false})
if bool(saved.unlocked): continue
var rule: Dictionary = conditions[id]
if _matches(rule.achieve, source):
saved.visible = true
saved.unlocked = true
_enqueue(id, "c")
changed = true
elif not bool(saved.visible) and _matches(rule.open, source):
saved.visible = true
# An empty opening condition makes an item visible without an alert.
if not rule.open.is_empty(): _enqueue(id, "g")
changed = true
progress[id] = saved
if changed:
_save_progress()
_rebuild_list()
func _enqueue(id: String, event_type: String) -> void:
for event in queue:
if event.id == id:
event.type = event_type
return
queue.append({"id": id, "type": event_type})
func _text(id: String) -> String:
var row: Dictionary = global.Achievements[locale][id]
var prefix: String = "Ru" if locale == "ru" else "En"
var title: String = str(row[prefix + "1"])
var description: String = str(row[prefix + "2"])
return "[b]" + title + "[/b]" + ("\n" + description if not description.is_empty() else "")
func _display_event(event: Dictionary) -> void:
notification_item.open(int(definitions[event.id].Points), event.type, _text(event.id))
panel.modulate.a = 1.0
panel.show()
func _rebuild_list() -> void:
var unlocked := 0
var position := 1
for id in definitions:
var saved: Dictionary = progress.get(id, {})
if not bool(saved.get("visible", false)) and not bool(saved.get("unlocked", false)): continue
if not items.has(id):
var item: Control = ITEM.instantiate()
list.add_child(item)
items[id] = item
list.move_child(items[id], position)
position += 1
var completed: bool = bool(saved.get("unlocked", false))
if completed: unlocked += 1
items[id].open(int(definitions[id].Points), "c" if completed else "g", _text(id))
var row: Dictionary = global.get_template("Achievement-Status-Template", "Interface", "Header")
status.text = global.render_template(row, {"count": unlocked, "total": definitions.size()})
func _load_progress() -> void:
var config := ConfigFile.new()
var error := config.load(progress_path)
if error == ERR_FILE_NOT_FOUND: return
if error != OK:
storage_available = false
push_error("Cannot load achievement progress: " + error_string(error))
return
for id in definitions:
progress[id] = {"visible": bool(config.get_value(id, "visible", false)), "unlocked": bool(config.get_value(id, "unlocked", false))}
func _save_progress() -> void:
if not storage_available: return
var config := ConfigFile.new()
# Preserve entries for achievements temporarily disabled in the catalog.
config.load(progress_path)
for id in progress:
config.set_value(id, "visible", bool(progress[id].visible))
config.set_value(id, "unlocked", bool(progress[id].unlocked))
var error := config.save(progress_path + ".tmp")
if error == OK:
error = DirAccess.rename_absolute(progress_path + ".tmp", progress_path)
if error != OK: push_error("Cannot save achievement progress: " + error_string(error))
@@ -0,0 +1 @@
uid://c1x220s0rloxc
+10 -3
View File
@@ -11,15 +11,17 @@
[ext_resource type="Texture2D" uid="uid://bmp7e6lmtaex0" path="res://ui/achievements/icons/10g.png" id="9_3xyrb"]
[node name="AchievementItem" type="Control" unique_id=1273881744]
custom_minimum_size = Vector2(1080, 128)
custom_minimum_size = Vector2(0, 128)
layout_mode = 3
anchors_preset = 0
size_flags_horizontal = 3
script = ExtResource("1_w8f20")
[node name="Row" type="HBoxContainer" parent="." unique_id=2025386077]
layout_mode = 0
offset_right = 1080.0
offset_bottom = 128.0
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Icon1c" type="TextureRect" parent="Row" unique_id=2146478339]
visible = false
@@ -72,3 +74,8 @@ texture = ExtResource("9_3xyrb")
[node name="Label" type="RichTextLabel" parent="Row" unique_id=230945261]
layout_mode = 2
size_flags_horizontal = 3
bbcode_enabled = true
fit_content = true
scroll_active = false
theme_override_font_sizes/normal_font_size = 30
theme_override_font_sizes/bold_font_size = 30
+4 -7
View File
@@ -1087,17 +1087,14 @@ size_flags_vertical = 4
texture = ExtResource("61_d36xh")
stretch_mode = 3
[node name="AchievementList" type="Control" parent="Stage/Achievements" unique_id=1872434750]
[node name="AchievementList" type="ScrollContainer" parent="Stage/Achievements" unique_id=1872434750]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="AchievementItems" type="VBoxContainer" parent="Stage/Achievements/AchievementList" unique_id=1878761996]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
layout_mode = 2
size_flags_horizontal = 3
[node name="AchievementItem" parent="Stage/Achievements/AchievementList/AchievementItems" unique_id=1952626354 instance=ExtResource("64_0drd1")]
visible = false