Implement achievement system: add achievement controller, UI components, and tests
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user