diff --git a/simulation_rules.md b/simulation_rules.md index bf77554..1031dcb 100644 --- a/simulation_rules.md +++ b/simulation_rules.md @@ -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`. diff --git a/stage/stage.gd b/stage/stage.gd index d6baaf4..0b479fa 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -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) diff --git a/tests/test_achievements.gd b/tests/test_achievements.gd new file mode 100644 index 0000000..0e8a4d7 --- /dev/null +++ b/tests/test_achievements.gd @@ -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) diff --git a/tests/test_achievements.gd.uid b/tests/test_achievements.gd.uid new file mode 100644 index 0000000..dd5fb05 --- /dev/null +++ b/tests/test_achievements.gd.uid @@ -0,0 +1 @@ +uid://uslcyxm8vpcn diff --git a/ui/achievements/achievement_controller.gd b/ui/achievements/achievement_controller.gd new file mode 100644 index 0000000..3484a2d --- /dev/null +++ b/ui/achievements/achievement_controller.gd @@ -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)) diff --git a/ui/achievements/achievement_controller.gd.uid b/ui/achievements/achievement_controller.gd.uid new file mode 100644 index 0000000..7db1c75 --- /dev/null +++ b/ui/achievements/achievement_controller.gd.uid @@ -0,0 +1 @@ +uid://c1x220s0rloxc diff --git a/ui/achievements/achievement_item.tscn b/ui/achievements/achievement_item.tscn index cf66b6f..45520d2 100644 --- a/ui/achievements/achievement_item.tscn +++ b/ui/achievements/achievement_item.tscn @@ -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 diff --git a/world/world.tscn b/world/world.tscn index db5cabd..388dbfa 100644 --- a/world/world.tscn +++ b/world/world.tscn @@ -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