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") const TITLES = { "en": preload("res://ui/achievements/achievements.en.png"), "ru": preload("res://ui/achievements/achievements.ru.png"), } 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 title: TextureRect = stage.get_node_or_null("Achievements/Header/Title") if title != null: title.texture = TITLES[locale] 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))