Files
owniverse/ui/achievements/achievement_controller.gd
T

201 lines
8.1 KiB
GDScript

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))