Improve interface accessibility and UI reliability

This commit is contained in:
Alexander Sellite 2026-07-30 00:54:53 -04:00
parent ba4476511f
commit 3734d4a168
29 changed files with 514 additions and 224 deletions

View file

@ -216,12 +216,12 @@ func show_bite() -> void:
_bite_tween.finished.connect(_on_bite_tween_finished)
func show_withdrawal_position(position: Vector3) -> void:
func show_withdrawal_position(world_position: Vector3) -> void:
if _mode != VisualMode.FISHING:
return
_kill_active_tween()
_bobber.global_position = _with_water_height(position)
_bobber.global_position = _with_water_height(world_position)
func begin_reeling() -> void:
@ -232,12 +232,12 @@ func begin_reeling() -> void:
_cast_position = _with_water_height(_bobber.global_position)
func show_reel_position(position: Vector3, _input_held: bool) -> void:
func show_reel_position(world_position: Vector3, _input_held: bool) -> void:
if _mode != VisualMode.FISHING:
return
_kill_active_tween()
_bobber.global_position = _with_water_height(position)
_bobber.global_position = _with_water_height(world_position)
func play_outcome(outcome: StringName) -> void:
@ -371,8 +371,12 @@ func _calculate_pickup_position(target: Vector3) -> Vector3:
return player_on_water + outward * pickup_distance
func _with_water_height(position: Vector3) -> Vector3:
return Vector3(position.x, get_water_surface_height(), position.z)
func _with_water_height(world_position: Vector3) -> Vector3:
return Vector3(
world_position.x,
get_water_surface_height(),
world_position.z,
)
func _update_line(delta: float) -> void:

View file

@ -1082,7 +1082,7 @@ func get_fishable_water_region(
query,
32
)
var selected_region: FishableWaterRegionType
var selected_region: FishableWaterRegionType = null
for result: Dictionary in results:
var collider: Object = result.get("collider")
var region: FishableWaterRegionType = collider as FishableWaterRegionType

View file

@ -9,8 +9,8 @@ var _target: Vector3
var _active: bool = false
func setup(owner: Player) -> void:
_owner = owner
func setup(owning_player: Player) -> void:
_owner = owning_player
_bobber = MeshInstance3D.new()
var bobber_mesh := SphereMesh.new()
bobber_mesh.radius = 0.12
@ -43,11 +43,11 @@ func show_cast(target: Vector3) -> void:
_redraw_line()
func update_bobber(position: Vector3) -> void:
if not _active or not position.is_finite():
func update_bobber(world_position: Vector3) -> void:
if not _active or not world_position.is_finite():
return
_target = position
_bobber.global_position = position
_target = world_position
_bobber.global_position = world_position
_redraw_line()

View file

@ -88,6 +88,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
@onready var _water_recovery: WaterRecoveryControllerType = %WaterRecovery
@onready var _save_manager: PlayerSaveManagerType = %PlayerSaveManager
@onready var _settings_manager: PlayerSettingsManagerType = %PlayerSettingsManager
@onready var _interface_fonts: InterfaceFontController = %InterfaceFontController
@onready var _title_music: AudioStreamPlayer = %TitleMusic
@onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation
@onready var _pixelation_reset: PixelationResetOverlayType = (
@ -146,11 +147,21 @@ var _pending_trust_changed: bool = false
var _identity_notice_dialog: AcceptDialog
var _data_setup_dialog: ConfirmationDialog
var _data_folder_dialog: FileDialog
var _data_folder_picker_generation: int = 0
var _restore_data_setup_after_picker: bool = false
var _application_initialized := false
var _pending_existing_root_path := ""
func _ready() -> void:
if not _settings_manager.settings_changed.is_connected(
_apply_runtime_settings
):
_settings_manager.settings_changed.connect(_apply_runtime_settings)
_settings_manager.load_settings()
_interface_fonts.set_readable_font_enabled(
_settings_manager.current_settings.use_readable_interface_font
)
if _data_root.resolve():
_configure_portable_stores()
_initialize_after_data_root()
@ -377,6 +388,7 @@ func _initialize_after_data_root() -> void:
_player_identity,
_host_identity,
_network_session,
_interface_fonts,
)
_data_root.conflict_detected.connect(_on_portable_conflict)
_data_root.status_changed.connect(_on_data_root_status)
@ -400,10 +412,6 @@ func _initialize_after_data_root() -> void:
_test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points()
)
if not _settings_manager.settings_changed.is_connected(
_apply_runtime_settings
):
_settings_manager.settings_changed.connect(_apply_runtime_settings)
_ui_pixelation.effective_pixel_size_changed.connect(
_on_effective_ui_pixel_size_changed
)
@ -420,7 +428,6 @@ func _initialize_after_data_root() -> void:
_game_ui.focus_open_settings_back_button
)
_pixelation_reset.reset_requested.connect(_reset_pixelation)
_settings_manager.load_settings()
_apply_runtime_settings(_settings_manager.current_settings)
_set_gameplay_active(false)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
@ -491,9 +498,10 @@ func _show_data_root_setup() -> void:
"Keep Current Location", false, "current"
)
_data_setup_dialog.custom_action.connect(_on_data_setup_action)
_interface_fonts.apply_utility_theme(_data_setup_dialog)
add_child(_data_setup_dialog)
var default_path := _data_root.default_visible_path()
var legacy := PortableDataMigration.legacy_files_present()
var default_path: String = _data_root.default_visible_path()
var legacy: bool = PortableDataMigration.legacy_files_present()
_data_setup_dialog.dialog_text = (
("Move your existing NETFISHING data to an easy-to-find folder."
if legacy else "Choose where NETFISHING stores your player data.")
@ -512,7 +520,7 @@ func _show_data_root_setup() -> void:
func _use_default_data_root() -> void:
var path := _data_root.default_visible_path()
var path: String = _data_root.default_visible_path()
if path.is_empty():
_show_folder_picker()
return
@ -535,7 +543,29 @@ func _show_folder_picker() -> void:
_data_folder_dialog.access = FileDialog.ACCESS_FILESYSTEM
_data_folder_dialog.use_native_dialog = false
_data_folder_dialog.dir_selected.connect(_activate_selected_data_path)
_data_folder_dialog.canceled.connect(_on_data_folder_picker_canceled)
_interface_fonts.apply_utility_theme(_data_folder_dialog)
add_child(_data_folder_dialog)
if _data_folder_dialog.visible:
return
_data_folder_picker_generation += 1
var generation: int = _data_folder_picker_generation
_restore_data_setup_after_picker = (
_data_setup_dialog != null and _data_setup_dialog.visible
)
if _data_setup_dialog != null:
_data_setup_dialog.hide()
_open_folder_picker_after_modal.call_deferred(generation)
func _open_folder_picker_after_modal(generation: int) -> void:
await get_tree().process_frame
if (
generation != _data_folder_picker_generation
or _data_folder_dialog == null
or _data_folder_dialog.visible
):
return
_data_folder_dialog.current_dir = (
_data_root.default_visible_path().get_base_dir()
if not _data_root.default_visible_path().is_empty()
@ -544,10 +574,29 @@ func _show_folder_picker() -> void:
_data_folder_dialog.popup_centered_ratio(0.75)
func _on_data_folder_picker_canceled() -> void:
_data_folder_picker_generation += 1
var generation: int = _data_folder_picker_generation
var should_restore_setup: bool = _restore_data_setup_after_picker
_restore_data_setup_after_picker = false
if _data_folder_dialog != null:
_data_folder_dialog.hide()
if should_restore_setup:
_restore_data_setup_after_picker_after_modal.call_deferred(generation)
func _restore_data_setup_after_picker_after_modal(generation: int) -> void:
await get_tree().process_frame
if generation != _data_folder_picker_generation:
return
_show_data_root_setup()
func _activate_selected_data_path(path: String, app_data: bool = false) -> void:
var ok := false
_restore_data_setup_after_picker = false
var ok: bool = false
if PortableDataMigration.legacy_files_present():
var result := (
var result: Dictionary = (
PortableDataMigration.adopt_legacy_app_data(_data_root)
if app_data
else PortableDataMigration.migrate_legacy_to(_data_root, path)
@ -571,7 +620,7 @@ func _activate_selected_data_path(path: String, app_data: bool = false) -> void:
func _show_existing_root_choice(path: String) -> void:
_pending_existing_root_path = path
var dialog := ConfirmationDialog.new()
var dialog: ConfirmationDialog = ConfirmationDialog.new()
dialog.title = "Existing NETFISHING data"
dialog.ok_button_text = "Use Data Already in Selected Folder"
dialog.cancel_button_text = "Cancel"
@ -594,7 +643,7 @@ func _show_existing_root_choice(path: String) -> void:
dialog.custom_action.connect(func(action: StringName) -> void:
if action != &"replace":
return
var result := PortableDataMigration.replace_existing_with_legacy(
var result: Dictionary = PortableDataMigration.replace_existing_with_legacy(
_data_root, _pending_existing_root_path
)
if bool(result.get("ok", false)):
@ -605,6 +654,7 @@ func _show_existing_root_choice(path: String) -> void:
else:
_show_data_error(str(result.get("message", "Migration failed.")))
)
_interface_fonts.apply_utility_theme(dialog)
add_child(dialog)
dialog.popup_centered(Vector2i(680, 360))
@ -616,7 +666,8 @@ func _show_data_error(message: String) -> void:
func _on_portable_conflict(message: String, _path: String) -> void:
var dialog := AcceptDialog.new()
var dialog: AcceptDialog = AcceptDialog.new()
_interface_fonts.apply_utility_theme(dialog)
dialog.title = "Player data conflict"
dialog.dialog_text = (
message
@ -635,7 +686,8 @@ func _on_portable_conflict(message: String, _path: String) -> void:
func _on_data_root_status(message: String) -> void:
if "Syncthing conflict" not in message:
return
var dialog := AcceptDialog.new()
var dialog: AcceptDialog = AcceptDialog.new()
_interface_fonts.apply_utility_theme(dialog)
dialog.title = "Synced data needs review"
dialog.dialog_text = message
dialog.add_button("Open Data Folder", false, "open")
@ -771,6 +823,9 @@ func _process(_delta: float) -> void:
func _apply_runtime_settings(settings: PlayerSettingsType) -> void:
if settings == null:
return
_interface_fonts.set_readable_font_enabled(
settings.use_readable_interface_font
)
_apply_world_pixelation(settings.world_pixel_size)
_ui_pixelation.set_pixel_size(settings.ui_pixel_size)
_game_ui.get_title_screen().set_world_pixelation(

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=39 format=3]
[gd_scene load_steps=40 format=3]
[ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"]
@ -38,6 +38,7 @@
[ext_resource type="Script" path="res://network/network_player_list_service.gd" id="36_player_list"]
[ext_resource type="Script" path="res://network/player_data_root.gd" id="37_data_root"]
[ext_resource type="Script" path="res://network/identity_backup_service.gd" id="38_identity_backup"]
[ext_resource type="Script" path="res://ui/interface_font_controller.gd" id="39_fonts"]
[node name="Main" type="Node3D"]
script = ExtResource("3_main")
@ -46,6 +47,10 @@ pelican_buyer_profile = ExtResource("7_pelicans")
main_shop_buyer_profile = ExtResource("12_main_shop")
item_catalog = ExtResource("11_items")
[node name="InterfaceFontController" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("39_fonts")
[node name="PlayerDataRoot" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("37_data_root")

View file

@ -24,11 +24,15 @@ func is_banned(host_fingerprint: String, target_fingerprint: String) -> bool:
return Dictionary(_namespaces.get(host_fingerprint, {})).has(target_fingerprint)
func ban(host_fingerprint: String, target_fingerprint: String, name: String) -> bool:
func ban(
host_fingerprint: String,
target_fingerprint: String,
display_name: String,
) -> bool:
if (
not NetworkIdentityCrypto.valid_fingerprint(host_fingerprint)
or not NetworkIdentityCrypto.valid_fingerprint(target_fingerprint)
or not NetworkProfilePreferences.is_valid_display_name(name)
or not NetworkProfilePreferences.is_valid_display_name(display_name)
):
return false
_ensure_loaded()
@ -41,7 +45,7 @@ func ban(host_fingerprint: String, target_fingerprint: String, name: String) ->
records[target_fingerprint] = {
"host_fingerprint": host_fingerprint,
"target_fingerprint": target_fingerprint,
"last_known_display_name": name,
"last_known_display_name": display_name,
"banned_unix": int(Time.get_unix_time_from_system()),
"reason": "host_ban",
}

View file

@ -25,12 +25,12 @@ func setup(
func default_export_path(identity_type: String) -> String:
var store := _store(identity_type)
var store: LocalSigningIdentityStore = _store(identity_type)
if store == null:
return ""
if not store.is_ready() and not store.load_or_create():
return ""
var timestamp := Time.get_datetime_string_from_system().replace(":", "-")
var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-")
return _data_root.identity_backup_directory().path_join(
"%s-%s-%s.nfidentity" % [
identity_type,
@ -50,14 +50,14 @@ func export_backup(
return _finish(false, "Passphrases must match and contain at least 12 characters.")
if FileAccess.file_exists(path):
return _finish(false, "Choose a new backup filename.")
var store := _store(identity_type)
var store: LocalSigningIdentityStore = _store(identity_type)
if store == null or (not store.is_ready() and not store.load_or_create()):
return _finish(false, "The active identity is unavailable.")
var material := store.export_identity_material()
var proof := store.sign("identity_backup_self_test", [
var material: Dictionary = store.export_identity_material()
var proof: PackedByteArray = store.sign("identity_backup_self_test", [
identity_type, material["fingerprint"],
])
var envelope := {
var envelope: Dictionary = {
"magic": MAGIC,
"format_version": FORMAT_VERSION,
"identity_type": identity_type,
@ -70,16 +70,18 @@ func export_backup(
"self_signature": Marshalls.raw_to_base64(proof),
}
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, passphrase)
var file: FileAccess = FileAccess.open_encrypted_with_pass(
path, FileAccess.WRITE, passphrase
)
if file == null:
return _finish(false, "Could not write this identity backup.")
file.store_string(JSON.stringify(envelope))
file.flush()
var ok := file.get_error() == OK
var ok: bool = file.get_error() == OK
file.close()
if not ok:
return _finish(false, "Could not write this identity backup.")
var verified := inspect_backup(path, passphrase, identity_type)
var verified: Dictionary = inspect_backup(path, passphrase, identity_type)
if not bool(verified.get("ok", false)):
DirAccess.remove_absolute(path)
return _finish(false, "Could not verify this identity backup.")
@ -97,12 +99,14 @@ func inspect_backup(
or FileAccess.get_size(path) > MAX_BACKUP_BYTES
):
return {"ok": false}
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, passphrase)
var file: FileAccess = FileAccess.open_encrypted_with_pass(
path, FileAccess.READ, passphrase
)
if file == null:
return {"ok": false}
var text := file.get_as_text()
var text: String = file.get_as_text()
file.close()
var json := JSON.new()
var json: JSON = JSON.new()
if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY:
return {"ok": false}
var data: Dictionary = json.data
@ -113,21 +117,23 @@ func inspect_backup(
or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM
):
return {"ok": false}
var private_pem := str(data.get("private_pem", ""))
var public_pem := NetworkIdentityCrypto.normalize_public_pem(
var private_pem: String = str(data.get("private_pem", ""))
var public_pem: String = NetworkIdentityCrypto.normalize_public_pem(
str(data.get("public_pem", ""))
)
var fingerprint := str(data.get("fingerprint", ""))
var fingerprint: String = str(data.get("fingerprint", ""))
if (
private_pem.length() > 128 * 1024
or public_pem.length() > 32 * 1024
or NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint
):
return {"ok": false}
var key := CryptoKey.new()
var key: CryptoKey = CryptoKey.new()
if key.load_from_string(private_pem) != OK:
return {"ok": false}
var signature := Marshalls.base64_to_raw(str(data.get("self_signature", "")))
var signature: PackedByteArray = Marshalls.base64_to_raw(
str(data.get("self_signature", ""))
)
if not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"identity_backup_self_test",
@ -135,7 +141,7 @@ func inspect_backup(
signature,
):
return {"ok": false}
var fresh := NetworkIdentityCrypto.sign_fields(
var fresh: PackedByteArray = NetworkIdentityCrypto.sign_fields(
key, "identity_import_self_test", [fingerprint]
)
if not NetworkIdentityCrypto.verify_fields(
@ -160,12 +166,12 @@ func import_backup(
passphrase: String,
confirmed_replacement: bool,
) -> Dictionary:
var inspected := inspect_backup(path, passphrase, identity_type)
var inspected: Dictionary = inspect_backup(path, passphrase, identity_type)
if not bool(inspected.get("ok", false)):
_finish(false, "Could not open this identity backup.")
return {"ok": false}
var store := _store(identity_type)
var incoming := str(inspected["fingerprint"])
var store: LocalSigningIdentityStore = _store(identity_type)
var incoming: String = str(inspected["fingerprint"])
if store.fingerprint == incoming:
_finish(true, "This identity is already active.")
return {"ok": true, "same": true}
@ -176,7 +182,7 @@ func import_backup(
"current_fingerprint": store.fingerprint,
"incoming_fingerprint": incoming,
}
var result := store.install_identity_material(
var result: Dictionary = store.install_identity_material(
str(inspected["private_pem"]),
str(inspected["public_pem"]),
incoming,

View file

@ -245,10 +245,10 @@ func _on_peer_removed(peer_id: int) -> void:
_rate_times.erase(peer_id)
if not _session.is_host():
return
var name: String = _peer_names.get(peer_id, "Player")
var display_name: String = _peer_names.get(peer_id, "Player")
_peer_names.erase(peer_id)
_broadcast(_make_message(
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s left." % name
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s left." % display_name
))

View file

@ -144,14 +144,14 @@ func get_recipient_choices() -> Array[Dictionary]:
var normalized := str(choice["name"]).to_lower()
counts[normalized] = int(counts.get(normalized, 0)) + 1
for choice: Dictionary in choices:
var name: String = choice["name"]
if int(counts.get(name.to_lower(), 0)) > 1:
var display_name: String = choice["name"]
if int(counts.get(display_name.to_lower(), 0)) > 1:
choice["label"] = "%s · %s" % [
name,
display_name,
NetworkIdentityCrypto.compact_suffix(choice["fingerprint"]),
]
else:
choice["label"] = name
choice["label"] = display_name
return choices
@ -531,8 +531,8 @@ func prepare_recipient(transfer_id: String, letter: Dictionary) -> void:
func _prepare_recipient(transfer_id: String, letter: Dictionary) -> void:
var ready := _can_receive(letter["attachment"])
_send_phase_ack("recipient_prepared", transfer_id, ready)
var recipient_ready: bool = _can_receive(letter["attachment"])
_send_phase_ack("recipient_prepared", transfer_id, recipient_ready)
func _request_sender_commit(peer_id: int, transfer_id: String, letter: Dictionary) -> void:

View file

@ -117,12 +117,17 @@ func kick(peer_id: int, fingerprint: String, revision: int) -> bool:
return ok
func ban(peer_id: int, fingerprint: String, name: String, revision: int) -> bool:
func ban(
peer_id: int,
fingerprint: String,
display_name: String,
revision: int,
) -> bool:
if not _valid_moderation_target(peer_id, fingerprint, revision):
moderation_finished.emit(false, "That player is no longer connected.")
return false
var host_fingerprint := _session.get_host_identity_fingerprint()
if not _bans.ban(host_fingerprint, fingerprint, name):
if not _bans.ban(host_fingerprint, fingerprint, display_name):
moderation_finished.emit(false, "Ban could not be saved.")
return false
var ok := _session.kick_authenticated_peer(peer_id, fingerprint, true)

View file

@ -172,10 +172,14 @@ func submit_profile_apply(data: Dictionary) -> void:
func _process_apply_request(peer_id: int, data: Dictionary) -> void:
var name := str(data["display_name"]).strip_edges()
var conflict := _name_conflicts(peer_id, name)
var accepted := not conflict or bool(data["use_anyway"])
var suggestions := _make_suggestions(peer_id, name) if conflict else PackedStringArray()
var display_name: String = str(data["display_name"]).strip_edges()
var conflict: bool = _name_conflicts(peer_id, display_name)
var accepted: bool = not conflict or bool(data["use_anyway"])
var suggestions: PackedStringArray = (
_make_suggestions(peer_id, display_name)
if conflict
else PackedStringArray()
)
if peer_id == _session.get_local_peer_id():
_apply_local_result(
str(data["request_id"]), accepted, "", conflict, suggestions
@ -184,7 +188,7 @@ func _process_apply_request(peer_id: int, data: Dictionary) -> void:
if accepted:
_host_pending_apply[str(data["request_id"])] = {
"peer_id": peer_id,
"display_name": name,
"display_name": display_name,
"appearance": Dictionary(data["appearance"]).duplicate(true),
"authorization": {
"request_id": data["request_id"],
@ -214,10 +218,10 @@ func confirm_profile_saved(request_id: String) -> void:
):
return
_host_pending_apply.erase(request_id)
var name := str(pending["display_name"])
var appearance := Dictionary(pending["appearance"])
_session.apply_canonical_profile(sender_id, name, appearance)
var record := _session.get_peer_record(sender_id)
var display_name: String = str(pending["display_name"])
var appearance: Dictionary = Dictionary(pending["appearance"])
_session.apply_canonical_profile(sender_id, display_name, appearance)
var record: PeerRegistry.PeerRecord = _session.get_peer_record(sender_id)
if record != null:
record.profile_authorization = pending.get("authorization", {}).duplicate(true)
_apply_to_avatar(sender_id, appearance)

View file

@ -1145,13 +1145,13 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
)
_registry.update_appearance(peer_id, appearance)
var transform: Transform3D = _spawn_service.get_spawn_transform_for_index(0)
var position: Array = entry["position"]
if position.size() != 3:
var spawn_position: Array = entry["position"]
if spawn_position.size() != 3:
return
transform.origin = Vector3(
float(position[0]),
float(position[1]),
float(position[2])
float(spawn_position[0]),
float(spawn_position[1]),
float(spawn_position[2])
)
transform.basis = Basis(Vector3.UP, float(entry["yaw"]))
var avatar := _spawn_service.spawn_remote_player(peer_id, transform, false)

View file

@ -34,7 +34,7 @@ var override_active := false
func resolve() -> bool:
_load_bootstrap_identity()
var command_line := _command_line_override()
var command_line: String = _command_line_override()
if not command_line.is_empty():
override_active = true
mode = Mode.COMMAND_LINE_OVERRIDE
@ -42,18 +42,18 @@ func resolve() -> bool:
if OS.has_environment(ENVIRONMENT_VARIABLE):
override_active = true
mode = Mode.ENVIRONMENT_OVERRIDE
var environment_path := OS.get_environment(ENVIRONMENT_VARIABLE)
var environment_path: String = OS.get_environment(ENVIRONMENT_VARIABLE)
if not environment_path.is_absolute_path():
return _fail("NETFISHING_DATA_DIR must be an absolute path.")
return _activate_existing(environment_path, "", false)
var bootstrap := _read_json(BOOTSTRAP_PATH, 64 * 1024)
var bootstrap: Dictionary = _read_json(BOOTSTRAP_PATH, 64 * 1024)
if bootstrap.is_empty() and FileAccess.file_exists(BOOTSTRAP_PATH + ".backup"):
bootstrap = _read_json(BOOTSTRAP_PATH + ".backup", 64 * 1024)
if not bootstrap.is_empty():
if bootstrap.get("format_version") != BOOTSTRAP_VERSION:
return _fail("The data-folder pointer uses an unsupported version.")
var selected := str(bootstrap.get("selected_absolute_path", ""))
var expected := str(bootstrap.get("expected_root_id", ""))
var selected: String = str(bootstrap.get("selected_absolute_path", ""))
var expected: String = str(bootstrap.get("expected_root_id", ""))
if selected.is_empty():
return _fail("The data-folder pointer is incomplete.")
mode = (
@ -68,7 +68,7 @@ func resolve() -> bool:
func default_visible_path() -> String:
var documents := OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
var documents: String = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
return documents.path_join("NETFISHING") if not documents.is_empty() else ""
@ -77,14 +77,14 @@ func select_new_root(path: String, app_data: bool = false) -> bool:
return _fail("The data folder is controlled by a process override.")
if device_id.length() != 32:
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
var normalized := _normalize(path)
var normalized: String = _normalize(path)
if app_data:
normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
if not _validate_candidate(normalized, true):
return false
var manifest_path := normalized.path_join(MANIFEST_FILENAME)
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
if FileAccess.file_exists(manifest_path):
var manifest := _read_json(manifest_path)
var manifest: Dictionary = _read_json(manifest_path)
if not _valid_manifest(manifest):
return _fail("The selected folder has a malformed NETFISHING manifest.")
root_id = str(manifest["root_id"])
@ -107,30 +107,30 @@ func select_new_root(path: String, app_data: bool = false) -> bool:
func create_unbound_root(path: String) -> Dictionary:
var normalized := _normalize(path)
var normalized: String = _normalize(path)
if not _validate_candidate(normalized, true):
return {"ok": false, "message": error_message}
if not _directory_is_empty(normalized):
return {"ok": false, "message": "The destination staging folder is not empty."}
var id := Crypto.new().generate_random_bytes(16).hex_encode()
var id: String = Crypto.new().generate_random_bytes(16).hex_encode()
if not _create_layout(normalized, id):
return {"ok": false, "message": "The portable layout could not be created."}
return {"ok": true, "root_id": id}
func create_app_data_layout_for_migration(path: String) -> Dictionary:
var normalized := _normalize(path)
var normalized: String = _normalize(path)
if not _validate_candidate(normalized, false):
return {"ok": false, "message": error_message}
var manifest_path := normalized.path_join(MANIFEST_FILENAME)
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
if FileAccess.file_exists(manifest_path):
var manifest := _read_json(manifest_path)
var manifest: Dictionary = _read_json(manifest_path)
return (
{"ok": true, "root_id": str(manifest.get("root_id", ""))}
if _valid_manifest(manifest)
else {"ok": false, "message": "The app-data manifest is malformed."}
)
var id := Crypto.new().generate_random_bytes(16).hex_encode()
var id: String = Crypto.new().generate_random_bytes(16).hex_encode()
if not _create_layout(normalized, id):
return {"ok": false, "message": "The app-data layout could not be created."}
return {"ok": true, "root_id": id}
@ -141,7 +141,7 @@ func use_existing_root(path: String) -> bool:
return _fail("The data folder is controlled by a process override.")
if device_id.length() != 32:
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
var normalized := _normalize(path)
var normalized: String = _normalize(path)
if not _activate_existing(normalized, "", true):
return false
if not _write_bootstrap(root_path, root_id):
@ -156,7 +156,7 @@ func use_existing_root(path: String) -> bool:
return true
func path_for(owner: StringName) -> String:
func path_for(store_owner: StringName) -> String:
var relative: String = {
&"player_save": "player/player_save.json",
&"network_profile": "player/network_profile.json",
@ -166,7 +166,7 @@ func path_for(owner: StringName) -> String:
&"player_relationships": "social/player_relationships.json",
&"server_trust": "social/server_trust.json",
&"host_bans": "social/host_bans.json",
}.get(owner, "")
}.get(store_owner, "")
return root_path.path_join(relative) if not relative.is_empty() else ""
@ -202,16 +202,16 @@ func report_conflict(message: String, conflict_path: String) -> void:
func _activate_existing(path: String, expected_id: String, permit_creation: bool) -> bool:
var normalized := _normalize(path)
var normalized: String = _normalize(path)
if not _validate_candidate(normalized, permit_creation):
return false
var manifest_path := normalized.path_join(MANIFEST_FILENAME)
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
if not FileAccess.file_exists(manifest_path):
return _fail("The selected folder is not a NETFISHING data folder.")
var manifest := _read_json(manifest_path)
var manifest: Dictionary = _read_json(manifest_path)
if not _valid_manifest(manifest):
return _fail("The NETFISHING data-folder manifest is malformed.")
var found_id := str(manifest["root_id"])
var found_id: String = str(manifest["root_id"])
if not expected_id.is_empty() and expected_id != found_id:
return _fail("The selected data folder does not match this device pointer.")
if not _test_writable(normalized):
@ -228,7 +228,7 @@ func _activate_existing(path: String, expected_id: String, permit_creation: bool
func _validate_candidate(path: String, create: bool) -> bool:
if path.is_empty() or not path.is_absolute_path():
return _fail("Choose an absolute filesystem folder.")
var project := ProjectSettings.globalize_path("res://").trim_suffix("/")
var project: String = ProjectSettings.globalize_path("res://").trim_suffix("/")
if path == project or path.begins_with(project + "/"):
return _fail("The project folder cannot be used as the player data folder.")
if not DirAccess.dir_exists_absolute(path):
@ -238,8 +238,10 @@ func _validate_candidate(path: String, create: bool) -> bool:
func _test_writable(path: String) -> bool:
var probe := path.path_join(".netfishing-write-%s.tmp" % device_id.left(12))
var file := FileAccess.open(probe, FileAccess.WRITE)
var probe: String = path.path_join(
".netfishing-write-%s.tmp" % device_id.left(12)
)
var file: FileAccess = FileAccess.open(probe, FileAccess.WRITE)
if file == null:
return false
file.store_string("probe")
@ -254,8 +256,8 @@ func _create_layout(path: String, id: String) -> bool:
]:
if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:
return false
var now := int(Time.get_unix_time_from_system())
var manifest := {
var now: int = int(Time.get_unix_time_from_system())
var manifest: Dictionary = {
"format_version": MANIFEST_VERSION,
"layout_version": LAYOUT_VERSION,
"application": APPLICATION_ID,
@ -267,7 +269,7 @@ func _create_layout(path: String, id: String) -> bool:
path.path_join(MANIFEST_FILENAME), JSON.stringify(manifest, "\t")
):
return false
var readme := (
var readme: String = (
"NETFISHING player data\n\n"
+ "This folder is safe to synchronize with tools such as Syncthing.\n"
+ "Active private identity keys remain device-local.\n"
@ -280,8 +282,8 @@ func _create_layout(path: String, id: String) -> bool:
func _write_bootstrap(path: String, id: String) -> bool:
var now := int(Time.get_unix_time_from_system())
var data := {
var now: int = int(Time.get_unix_time_from_system())
var data: Dictionary = {
"format_version": BOOTSTRAP_VERSION,
"selected_absolute_path": path,
"expected_root_id": id,
@ -296,7 +298,7 @@ func _write_bootstrap(path: String, id: String) -> bool:
func _load_bootstrap_identity() -> void:
var data := _read_json(BOOTSTRAP_PATH, 64 * 1024)
var data: Dictionary = _read_json(BOOTSTRAP_PATH, 64 * 1024)
if data.is_empty():
data = _read_json(BOOTSTRAP_PATH + ".backup", 64 * 1024)
device_id = str(data.get("device_id", ""))
@ -305,9 +307,9 @@ func _load_bootstrap_identity() -> void:
func _command_line_override() -> String:
var args := OS.get_cmdline_user_args()
var args: PackedStringArray = OS.get_cmdline_user_args()
for index: int in args.size():
var value := args[index]
var value: String = args[index]
if value.begins_with("--data-dir="):
return value.trim_prefix("--data-dir=")
if value == "--data-dir" and index + 1 < args.size():
@ -326,15 +328,15 @@ func _valid_manifest(data: Dictionary) -> bool:
func _directory_is_empty(path: String) -> bool:
var access := DirAccess.open(path)
var access: DirAccess = DirAccess.open(path)
if access == null:
return true
access.list_dir_begin()
var name := access.get_next()
while name in [".", ".."]:
name = access.get_next()
var entry_name: String = access.get_next()
while entry_name in [".", ".."]:
entry_name = access.get_next()
access.list_dir_end()
return name.is_empty()
return entry_name.is_empty()
func _normalize(path: String) -> String:
@ -344,37 +346,37 @@ func _normalize(path: String) -> String:
func _read_json(path: String, maximum := 1024 * 1024) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > maximum:
return {}
var json := JSON.new()
var error := json.parse(file.get_as_text())
var json: JSON = JSON.new()
var error: Error = json.parse(file.get_as_text())
file.close()
return json.data if error == OK and typeof(json.data) == TYPE_DICTIONARY else {}
func _write_text_atomic(path: String, text: String) -> bool:
var absolute := (
var absolute: String = (
ProjectSettings.globalize_path(path)
if path.begins_with("user://")
else path
)
if DirAccess.make_dir_recursive_absolute(absolute.get_base_dir()) != OK:
return false
var temporary := absolute + ".tmp"
var file := FileAccess.open(temporary, FileAccess.WRITE)
var temporary: String = absolute + ".tmp"
var file: FileAccess = FileAccess.open(temporary, FileAccess.WRITE)
if file == null:
return false
file.store_string(text)
file.flush()
var ok := file.get_error() == OK
var ok: bool = file.get_error() == OK
file.close()
if not ok:
return false
var backup := absolute + ".backup"
var backup: String = absolute + ".backup"
if FileAccess.file_exists(backup):
DirAccess.remove_absolute(backup)
var had_primary := FileAccess.file_exists(absolute)
var had_primary: bool = FileAccess.file_exists(absolute)
if had_primary and DirAccess.rename_absolute(absolute, backup) != OK:
DirAccess.remove_absolute(temporary)
return false

View file

@ -78,10 +78,10 @@ func get_avatar(peer_id: int) -> Player:
return _avatars.get(peer_id)
func set_peer_presentation_visible(peer_id: int, visible: bool) -> void:
func set_peer_presentation_visible(peer_id: int, should_be_visible: bool) -> void:
var avatar: Player = _avatars.get(peer_id)
if avatar != null and is_instance_valid(avatar):
avatar.set_remote_presentation_visible(visible)
avatar.set_remote_presentation_visible(should_be_visible)
func get_peer_ids() -> Array[int]:

View file

@ -24,9 +24,9 @@ static func migrate_legacy_to(
data_root: PlayerDataRoot,
destination: String,
) -> Dictionary:
var normalized := destination.simplify_path().trim_suffix("/")
var normalized: String = destination.simplify_path().trim_suffix("/")
if DirAccess.dir_exists_absolute(normalized):
var existing_manifest := normalized.path_join(
var existing_manifest: String = normalized.path_join(
PlayerDataRoot.MANIFEST_FILENAME
)
if FileAccess.file_exists(existing_manifest):
@ -40,24 +40,24 @@ static func migrate_legacy_to(
"ok": false,
"message": "Choose an empty folder or create a NETFISHING subfolder.",
}
var staging := "%s.migration-%s" % [
var staging: String = "%s.migration-%s" % [
normalized, Crypto.new().generate_random_bytes(8).hex_encode()
]
if DirAccess.make_dir_recursive_absolute(staging) != OK:
return {"ok": false, "message": "Migration staging could not be created."}
var created := data_root.create_unbound_root(staging)
var created: Dictionary = data_root.create_unbound_root(staging)
if not bool(created.get("ok", false)):
_remove_tree(staging)
return {"ok": false, "message": str(created.get("message", ""))}
var copied: Array[String] = []
for source_name: String in LEGACY_FILES:
var source := ProjectSettings.globalize_path(
var source: String = ProjectSettings.globalize_path(
"user://".path_join(source_name)
)
if not FileAccess.file_exists(source):
continue
var target := staging.path_join(LEGACY_FILES[source_name])
var result := _copy_verified_json(source, target)
var target: String = staging.path_join(str(LEGACY_FILES[source_name]))
var result: Dictionary = _copy_verified_json(source, target)
if not bool(result.get("ok", false)):
_remove_tree(staging)
return {
@ -70,11 +70,15 @@ static func migrate_legacy_to(
if DirAccess.rename_absolute(staging, normalized) != OK:
_remove_tree(staging)
return {"ok": false, "message": "Migration could not activate its destination."}
var manifest := _read_json(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME))
var migrated_root_id := str(manifest.get("root_id", ""))
var manifest: Dictionary = _read_json(
normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
)
var migrated_root_id: String = str(manifest.get("root_id", ""))
if migrated_root_id.is_empty() or not data_root.use_existing_root(normalized):
return {"ok": false, "message": "Migration completed but could not switch roots."}
var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join(
var recovery: String = ProjectSettings.globalize_path(
"user://migration-recovery"
).path_join(
Time.get_datetime_string_from_system().replace(":", "-")
)
DirAccess.make_dir_recursive_absolute(recovery)
@ -94,7 +98,7 @@ static func migrate_active_to(
data_root: PlayerDataRoot,
destination: String,
) -> Dictionary:
var normalized := destination.simplify_path().trim_suffix("/")
var normalized: String = destination.simplify_path().trim_suffix("/")
if normalized == data_root.root_path:
return {"ok": true, "message": "This data folder is already active."}
if DirAccess.dir_exists_absolute(normalized) and not _directory_empty(normalized):
@ -108,12 +112,12 @@ static func migrate_active_to(
),
}
return {"ok": false, "message": "Choose an empty folder or a NETFISHING subfolder."}
var staging := "%s.migration-%s" % [
var staging: String = "%s.migration-%s" % [
normalized, Crypto.new().generate_random_bytes(8).hex_encode()
]
if DirAccess.make_dir_recursive_absolute(staging) != OK:
return {"ok": false, "message": "Migration staging could not be created."}
var created := data_root.create_unbound_root(staging)
var created: Dictionary = data_root.create_unbound_root(staging)
if not bool(created.get("ok", false)):
_remove_tree(staging)
return {"ok": false, "message": str(created.get("message", ""))}
@ -127,7 +131,7 @@ static func migrate_active_to(
"social/server_trust.json",
"social/host_bans.json",
]:
var source := data_root.root_path.path_join(relative)
var source: String = data_root.root_path.path_join(relative)
if not FileAccess.file_exists(source):
continue
if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)):
@ -138,7 +142,7 @@ static func migrate_active_to(
if DirAccess.rename_absolute(staging, normalized) != OK:
_remove_tree(staging)
return {"ok": false, "message": "Migration could not activate its destination."}
var old_root := data_root.root_path
var old_root: String = data_root.root_path
if not data_root.use_existing_root(normalized):
return {"ok": false, "message": "Migration copied data but did not change the pointer."}
return {
@ -149,18 +153,20 @@ static func migrate_active_to(
static func adopt_legacy_app_data(data_root: PlayerDataRoot) -> Dictionary:
var source_root := ProjectSettings.globalize_path("user://").trim_suffix("/")
var root := ProjectSettings.globalize_path(PlayerDataRoot.APP_DATA_PORTABLE_PATH)
var source_root: String = ProjectSettings.globalize_path("user://").trim_suffix("/")
var root: String = ProjectSettings.globalize_path(
PlayerDataRoot.APP_DATA_PORTABLE_PATH
)
if DirAccess.make_dir_recursive_absolute(root) != OK:
return {"ok": false, "message": "The app-data folder could not be created."}
var created := data_root.create_app_data_layout_for_migration(root)
var created: Dictionary = data_root.create_app_data_layout_for_migration(root)
if not bool(created.get("ok", false)):
return created
for source_name: String in LEGACY_FILES:
var source := source_root.path_join(source_name)
var source: String = source_root.path_join(source_name)
if not FileAccess.file_exists(source):
continue
var target := root.path_join(LEGACY_FILES[source_name])
var target: String = root.path_join(str(LEGACY_FILES[source_name]))
if not bool(_copy_verified_json(source, target).get("ok", false)):
return {
"ok": false,
@ -179,17 +185,19 @@ static func replace_existing_with_legacy(
data_root: PlayerDataRoot,
destination: String,
) -> Dictionary:
var normalized := destination.simplify_path().trim_suffix("/")
var manifest := normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
var normalized: String = destination.simplify_path().trim_suffix("/")
var manifest: String = normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
if not FileAccess.file_exists(manifest):
return {"ok": false, "message": "The selected folder is not a NETFISHING data root."}
var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join(
var recovery: String = ProjectSettings.globalize_path(
"user://migration-recovery"
).path_join(
"replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-")
)
if not _copy_tree(normalized, recovery):
return {"ok": false, "message": "The selected data could not be backed up."}
_remove_tree(normalized)
var result := migrate_legacy_to(data_root, normalized)
var result: Dictionary = migrate_legacy_to(data_root, normalized)
if bool(result.get("ok", false)):
result["replaced_root_backup"] = recovery
return result
@ -199,26 +207,28 @@ static func replace_existing_with_active(
data_root: PlayerDataRoot,
destination: String,
) -> Dictionary:
var normalized := destination.simplify_path().trim_suffix("/")
var normalized: String = destination.simplify_path().trim_suffix("/")
if not FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
return {"ok": false, "message": "The selected folder is not a NETFISHING data root."}
var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join(
var recovery: String = ProjectSettings.globalize_path(
"user://migration-recovery"
).path_join(
"replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-")
)
if not _copy_tree(normalized, recovery):
return {"ok": false, "message": "The selected data could not be backed up."}
_remove_tree(normalized)
var result := migrate_active_to(data_root, normalized)
var result: Dictionary = migrate_active_to(data_root, normalized)
if bool(result.get("ok", false)):
result["replaced_root_backup"] = recovery
return result
static func _copy_verified_json(source: String, destination: String) -> Dictionary:
var bytes := PortableFileGuard.read_bytes(source)
var bytes: PackedByteArray = PortableFileGuard.read_bytes(source)
if bytes.is_empty() and FileAccess.get_open_error() != OK:
return {"ok": false}
var json := JSON.new()
var json: JSON = JSON.new()
if (
json.parse(bytes.get_string_from_utf8()) != OK
or typeof(json.data) != TYPE_DICTIONARY
@ -229,7 +239,7 @@ static func _copy_verified_json(source: String, destination: String) -> Dictiona
return {"ok": false}
if not _write_bytes(destination, bytes):
return {"ok": false}
var copied := PortableFileGuard.read_bytes(destination)
var copied: PackedByteArray = PortableFileGuard.read_bytes(destination)
return {
"ok": (
PortableFileGuard.hash_bytes(copied)
@ -240,7 +250,7 @@ static func _copy_verified_json(source: String, destination: String) -> Dictiona
static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
if filename == "player_save.json":
var version := int(data.get("save_version", -1))
var version: int = int(data.get("save_version", -1))
return version >= 1 and version <= PlayerSaveManager.SAVE_VERSION
return (
filename not in LEGACY_FILES
@ -255,14 +265,14 @@ static func _copy_bytes(source: String, destination: String) -> bool:
static func _copy_tree(source: String, destination: String) -> bool:
if DirAccess.make_dir_recursive_absolute(destination) != OK:
return false
var access := DirAccess.open(source)
var access: DirAccess = DirAccess.open(source)
if access == null:
return false
access.list_dir_begin()
var name := access.get_next()
var name: String = access.get_next()
while not name.is_empty():
var from := source.path_join(name)
var to := destination.path_join(name)
var from: String = source.path_join(name)
var to: String = destination.path_join(name)
if access.current_is_dir():
if not _copy_tree(from, to):
access.list_dir_end()
@ -277,19 +287,19 @@ static func _copy_tree(source: String, destination: String) -> bool:
static func _write_bytes(path: String, bytes: PackedByteArray) -> bool:
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file := FileAccess.open(path, FileAccess.WRITE)
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_buffer(bytes)
file.flush()
var ok := file.get_error() == OK
var ok: bool = file.get_error() == OK
file.close()
return ok
static func _read_json(path: String) -> Dictionary:
var bytes := PortableFileGuard.read_bytes(path)
var json := JSON.new()
var bytes: PackedByteArray = PortableFileGuard.read_bytes(path)
var json: JSON = JSON.new()
return (
json.data
if json.parse(bytes.get_string_from_utf8()) == OK
@ -299,23 +309,23 @@ static func _read_json(path: String) -> Dictionary:
static func _directory_empty(path: String) -> bool:
var access := DirAccess.open(path)
var access: DirAccess = DirAccess.open(path)
if access == null:
return true
access.list_dir_begin()
var name := access.get_next()
var name: String = access.get_next()
access.list_dir_end()
return name.is_empty()
static func _remove_tree(path: String) -> void:
var access := DirAccess.open(path)
var access: DirAccess = DirAccess.open(path)
if access == null:
return
access.list_dir_begin()
var name := access.get_next()
var name: String = access.get_next()
while not name.is_empty():
var child := path.path_join(name)
var child: String = path.path_join(name)
if access.current_is_dir():
_remove_tree(child)
else:

View file

@ -7,19 +7,21 @@ const MAX_PORTABLE_FILE_BYTES := 16 * 1024 * 1024
static func hash_file(path: String) -> String:
if not FileAccess.file_exists(path):
return ""
var file := FileAccess.open(path, FileAccess.READ)
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > MAX_PORTABLE_FILE_BYTES:
return ""
var bytes := file.get_buffer(file.get_length())
var bytes: PackedByteArray = file.get_buffer(file.get_length())
file.close()
return hash_bytes(bytes)
static func hash_bytes(bytes: PackedByteArray) -> String:
var context := HashingContext.new()
if context.start(HashingContext.HASH_SHA256) != OK:
var context: HashingContext = HashingContext.new()
var start_error: Error = context.start(HashingContext.HASH_SHA256)
if start_error != OK:
return ""
if context.update(bytes) != OK:
var update_error: Error = context.update(bytes)
if update_error != OK:
return ""
return context.finish().hex_encode()
@ -27,10 +29,10 @@ static func hash_bytes(bytes: PackedByteArray) -> String:
static func read_bytes(path: String, maximum_bytes: int = MAX_PORTABLE_FILE_BYTES) -> PackedByteArray:
if not FileAccess.file_exists(path):
return PackedByteArray()
var file := FileAccess.open(path, FileAccess.READ)
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > maximum_bytes:
return PackedByteArray()
var bytes := file.get_buffer(file.get_length())
var bytes: PackedByteArray = file.get_buffer(file.get_length())
file.close()
return bytes
@ -42,10 +44,10 @@ static func write_guarded(
conflict_directory: String,
device_id: String,
) -> Dictionary:
var current_exists := FileAccess.file_exists(path)
var current_hash := hash_file(path) if current_exists else ""
var current_exists: bool = FileAccess.file_exists(path)
var current_hash: String = hash_file(path) if current_exists else ""
if current_hash != expected_hash:
var conflict_path := _write_conflict_copy(
var conflict_path: String = _write_conflict_copy(
path, bytes, conflict_directory, device_id
)
return {
@ -60,14 +62,14 @@ static func write_guarded(
}
if not _ensure_parent(path):
return {"ok": false, "conflict": false, "hash": expected_hash}
var temporary := path + ".tmp"
var backup := path + ".backup"
var file := FileAccess.open(temporary, FileAccess.WRITE)
var temporary: String = path + ".tmp"
var backup: String = path + ".backup"
var file: FileAccess = FileAccess.open(temporary, FileAccess.WRITE)
if file == null:
return {"ok": false, "conflict": false, "hash": expected_hash}
file.store_buffer(bytes)
file.flush()
var write_error := file.get_error()
var write_error: Error = file.get_error()
file.close()
if write_error != OK:
_remove(temporary)
@ -90,13 +92,13 @@ static func write_guarded(
static func has_syncthing_conflict(directory: String) -> bool:
var access := DirAccess.open(directory)
var access: DirAccess = DirAccess.open(directory)
if access == null:
return false
access.list_dir_begin()
var name := access.get_next()
var name: String = access.get_next()
while not name.is_empty():
var lower := name.to_lower()
var lower: String = name.to_lower()
if "sync-conflict" in lower or ".syncthing." in lower:
access.list_dir_end()
return true
@ -113,24 +115,24 @@ static func _write_conflict_copy(
) -> String:
if DirAccess.make_dir_recursive_absolute(conflict_directory) != OK:
return ""
var filename := canonical_path.get_file()
var safe_device := device_id.left(16)
var timestamp := Time.get_datetime_string_from_system().replace(":", "-")
var destination := conflict_directory.path_join(
var filename: String = canonical_path.get_file()
var safe_device: String = device_id.left(16)
var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-")
var destination: String = conflict_directory.path_join(
"%s.local-%s-%s" % [filename, safe_device, timestamp]
)
var file := FileAccess.open(destination, FileAccess.WRITE)
var file: FileAccess = FileAccess.open(destination, FileAccess.WRITE)
if file == null:
return ""
file.store_buffer(bytes)
file.flush()
var ok := file.get_error() == OK
var ok: bool = file.get_error() == OK
file.close()
return destination if ok else ""
static func _ensure_parent(path: String) -> bool:
var parent := path.get_base_dir()
var parent: String = path.get_base_dir()
return (
DirAccess.dir_exists_absolute(parent)
or DirAccess.make_dir_recursive_absolute(parent) == OK

View file

@ -323,9 +323,12 @@ func _validate_entry(value: Dictionary, is_saved: bool) -> Dictionary:
var endpoint := EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
return {}
var name: String = str(value.get("display_name", "")).strip_edges()
var display_name: String = str(
value.get("display_name", "")
).strip_edges()
if is_saved and (
name.is_empty() or name.length() > MAX_DISPLAY_NAME_LENGTH
display_name.is_empty()
or display_name.length() > MAX_DISPLAY_NAME_LENGTH
):
return {}
var result: Dictionary = _make_entry(

View file

@ -425,19 +425,19 @@ func apply_network_teleport(snapshot: Dictionary) -> void:
func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary:
var position: Variant = snapshot.get("position")
var snapshot_position: Variant = snapshot.get("position")
var network_velocity: Variant = snapshot.get("velocity")
if (
typeof(position) != TYPE_ARRAY
typeof(snapshot_position) != TYPE_ARRAY
or typeof(network_velocity) != TYPE_ARRAY
or position.size() != 3
or snapshot_position.size() != 3
or network_velocity.size() != 3
):
return {}
var parsed_position := Vector3(
float(position[0]),
float(position[1]),
float(position[2])
float(snapshot_position[0]),
float(snapshot_position[1]),
float(snapshot_position[2])
)
var parsed_velocity := Vector3(
float(network_velocity[0]),

View file

@ -20,6 +20,7 @@ const UI_DESKTOP_RENDER_HEIGHTS: Array[int] = [0, 612, 504, 396, 288]
const UI_COMPACT_RENDER_HEIGHTS: Array[int] = [0, 408, 336, 264, 192]
@export var auto_click_enabled: bool = false
@export var use_readable_interface_font: bool = false
@export_range(0.10, 0.50, 0.01) var auto_click_interval: float = 0.20
@export_range(0.001, 0.012, 0.0005) var mouse_camera_sensitivity: float = 0.005
@export_range(0.5, 5.0, 0.1) var controller_camera_sensitivity: float = 2.5
@ -49,6 +50,7 @@ func is_valid() -> bool:
func copy() -> PlayerSettings:
var result := PlayerSettings.new()
result.auto_click_enabled = auto_click_enabled
result.use_readable_interface_font = use_readable_interface_font
result.auto_click_interval = auto_click_interval
result.mouse_camera_sensitivity = mouse_camera_sensitivity
result.controller_camera_sensitivity = controller_camera_sensitivity

View file

@ -45,10 +45,17 @@ func load_settings() -> bool:
if (
typeof(accessibility.get("auto_click_enabled")) != TYPE_BOOL
or typeof(camera.get("invert_vertical")) != TYPE_BOOL
or (
accessibility.has("use_readable_interface_font")
and typeof(accessibility["use_readable_interface_font"]) != TYPE_BOOL
)
):
return _use_defaults_after_corruption("Player settings values are invalid.")
var loaded := PlayerSettings.new()
loaded.auto_click_enabled = accessibility["auto_click_enabled"]
loaded.use_readable_interface_font = bool(
accessibility.get("use_readable_interface_font", false)
)
loaded.auto_click_interval = _read_float(
accessibility.get("auto_click_interval"),
-1.0
@ -124,6 +131,9 @@ func save_now() -> bool:
"accessibility": {
"auto_click_enabled": current_settings.auto_click_enabled,
"auto_click_interval": current_settings.auto_click_interval,
"use_readable_interface_font": (
current_settings.use_readable_interface_font
),
},
"camera": {
"mouse_sensitivity": current_settings.mouse_camera_sensitivity,

View file

@ -0,0 +1,11 @@
We, the copyright holders of this work, hereby release it into the
public domain. This applies worldwide.
In case this is not legally possible,
We grant any entity the right to use this work for any purpose, without
any conditions, unless such conditions are required by law.
Thatcher Ulrich <tu@tulrich.com> http://tulrich.com
Karoly Barta bartakarcsi@gmail.com
Michael Evans http://www.evertype.com

BIN
ui/fonts/Tuffy_Bold.otf Normal file

Binary file not shown.

View file

@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://xhdms1j01k2q"
path="res://.godot/imported/Tuffy_Bold.otf-1ed903e9af3906035b0cf09ea141ce25.fontdata"
[deps]
source_file="res://ui/fonts/Tuffy_Bold.otf"
dest_files=["res://.godot/imported/Tuffy_Bold.otf-1ed903e9af3906035b0cf09ea141ce25.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

View file

@ -184,6 +184,7 @@ func setup_data_and_identity(
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
network_session: NetworkSession,
interface_fonts: InterfaceFontController,
) -> void:
for panel: SettingsPanelType in [
_title_settings_panel, _pause_settings_panel
@ -194,6 +195,7 @@ func setup_data_and_identity(
player_identity,
host_identity,
network_session,
interface_fonts,
)
@ -219,7 +221,7 @@ func _process(_delta: float) -> void:
}.get(item_id, "")
parts.append(
"%s %d:%02d"
% [label, int(remaining) / 60, int(remaining) % 60]
% [label, floori(remaining / 60.0), int(remaining) % 60]
)
_effect_status.text = " ".join(parts)
_effect_status.visible = not parts.is_empty()

View file

@ -0,0 +1,55 @@
class_name InterfaceFontController
extends Node
signal font_mode_changed(use_readable_font: bool)
const READABLE_FONT_PATH: String = "res://ui/fonts/Tuffy_Bold.otf"
var _game_theme: Theme = preload("res://ui/game_theme.tres")
var _game_font: Font = preload("res://ui/fonts/seattle_avenue.otf")
var _readable_font: Font
var _utility_theme: Theme
var _use_readable_font: bool = false
func _ready() -> void:
if ResourceLoader.exists(READABLE_FONT_PATH):
_readable_font = load(READABLE_FONT_PATH) as Font
if _readable_font == null:
push_error("Readable interface font resource is unavailable.")
else:
_readable_font.fallbacks = [_game_font]
_utility_theme = _game_theme.duplicate(true)
_utility_theme.default_font = readable_font()
func set_readable_font_enabled(enabled: bool) -> void:
_use_readable_font = enabled
_game_theme.default_font = (
_readable_font
if enabled and _readable_font != null
else _game_font
)
font_mode_changed.emit(_use_readable_font)
func is_readable_font_enabled() -> bool:
return _use_readable_font
func apply_utility_theme(themed_node: Node) -> void:
if themed_node == null:
return
if _utility_theme == null:
_utility_theme = _game_theme.duplicate(true)
_utility_theme.default_font = (
_readable_font if _readable_font != null else _game_font
)
if themed_node is Control:
(themed_node as Control).theme = _utility_theme
elif themed_node is Window:
(themed_node as Window).theme = _utility_theme
func readable_font() -> Font:
return _readable_font if _readable_font != null else _game_font

View file

@ -0,0 +1 @@
uid://djmxy6jibi5y3

View file

@ -194,8 +194,6 @@ enum CloseReason {
@onready var _selection_status: BubbleStatusBubbleType = %SelectionStatus
@onready var _offer_status: BubbleStatusBubbleType = %OfferStatus
@onready var _inventory_section: Control = %InventorySection
@onready var _inventory_body: BoxContainer = %InventoryBody
@onready var _inventory_scroll: ScrollContainer = %InventoryScroll
@onready var _bag_section: Control = %BagSection
@onready var _logbook_section: Control = %LogbookSection
@onready var _bag_empty: Label = %BagEmpty
@ -210,9 +208,6 @@ enum CloseReason {
@onready var _held_value: Label = %HeldValue
@onready var _cooler_count: Label = %CoolerCount
@onready var _inventory_empty: Label = %InventoryEmpty
@onready var _inventory_grid: GridContainer = %InventoryGrid
@onready var _inventory_list: Control = %InventoryList
@onready var _detail_panel: Control = %DetailPanel
@onready var _detail_texture: TextureRect = %DetailTexture
@onready var _detail_name: Label = %DetailName
@onready var _detail_data: Label = %DetailData
@ -227,8 +222,6 @@ enum CloseReason {
@onready var _cancel_sale_button: Button = %CancelSaleButton
@onready var _logbook_empty: Label = %LogbookEmpty
@onready var _logbook_grid: GridContainer = %LogbookGrid
@onready var _status_shoal: HBoxContainer = %StatusShoal
@onready var _sort_bar: HBoxContainer = %SortBar
var _compact_layout: bool = false
var _player: PlayerType
@ -1181,6 +1174,7 @@ func _update_shell_layout() -> void:
_logbook_page.size = reference_size
_logbook_page.position = Vector2.ZERO
_logbook_rest_position = Vector2.ZERO
_mail_page.set_anchors_preset(Control.PRESET_TOP_LEFT)
_mail_page.size = reference_size
_mail_page.position = Vector2.ZERO
_mail_rest_position = Vector2.ZERO
@ -1732,7 +1726,10 @@ func _reset_page_transition_visuals() -> void:
func _on_sort_selected(index: int, source: OptionButton) -> void:
_sort_mode = source.get_item_id(index)
var selected_id: int = source.get_item_id(index)
if selected_id < SortMode.CATCH_ORDER or selected_id > SortMode.RARITY:
return
_sort_mode = selected_id as SortMode
_sort_option.select(_sort_mode)
_cooler_sort_option.select(_sort_mode)
_refresh_inventory()
@ -1891,7 +1888,7 @@ func _layout_bag_items() -> void:
var row: int = floori(float(index) / float(columns))
var stable_offset := Vector2(
float(identity_hash % 13) - 6.0,
float((identity_hash / 19) % 11) - 5.0,
float(floori(float(identity_hash) / 19.0) % 11) - 5.0,
)
var lane_offset: float = 18.0 if row % 2 == 1 else 0.0
item_node.custom_minimum_size = item_size
@ -2225,7 +2222,7 @@ func _layout_cooler_fish(animate: bool = true) -> void:
var offset_center: float = 2.0 if _compact_layout else 3.0
var stable_offset := Vector2(
float(identity_hash % offset_span) - offset_center,
float((identity_hash / 17) % offset_span) - offset_center,
float(floori(float(identity_hash) / 17.0) % offset_span) - offset_center,
)
var lane_offset: float = 5.0 if row % 2 == 1 else 0.0
var target := Vector2(

View file

@ -48,6 +48,7 @@ enum PresentationMode {
@onready var _invert_y_toggle: BubbleButton = %InvertYToggle
@onready var _auto_click_toggle: BubbleButton = %AutoClickToggle
@onready var _auto_click_interval: BubbleButton = %AutoClickIntervalValue
@onready var _readable_font_toggle: BubbleButton = %ReadableFontToggle
@onready var _world_options: Array[BubbleButton] = [
%WorldLegible,
@ -83,6 +84,7 @@ var _data_root: PlayerDataRoot
var _identity_backups: IdentityBackupService
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
var _interface_fonts: InterfaceFontController
var _data_folder_dialog: FileDialog
var _backup_file_dialog: FileDialog
var _export_file_dialog: FileDialog
@ -148,6 +150,7 @@ func _ready() -> void:
)
_invert_y_toggle.pressed.connect(_toggle_invert_y)
_auto_click_toggle.pressed.connect(_toggle_auto_click)
_readable_font_toggle.pressed.connect(_toggle_readable_font)
%IntervalDecrease.pressed.connect(_adjust_auto_click_interval.bind(-1))
%IntervalIncrease.pressed.connect(_adjust_auto_click_interval.bind(1))
_auto_click_interval.pressed.connect(
@ -185,13 +188,16 @@ func setup_data_and_identity(
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
session: NetworkSession,
interface_fonts: InterfaceFontController,
) -> void:
_data_root = data_root
_identity_backups = identity_backups
_player_identity = player_identity
_host_identity = host_identity
_network_session = session
_interface_fonts = interface_fonts
_identity_backups.operation_finished.connect(_on_identity_operation_finished)
_interface_fonts.apply_utility_theme(_data_page)
_refresh_data_page()
@ -410,14 +416,18 @@ func _choose_data_folder() -> void:
_data_folder_dialog = FileDialog.new()
_data_folder_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
_data_folder_dialog.access = FileDialog.ACCESS_FILESYSTEM
_data_folder_dialog.use_native_dialog = false
_data_folder_dialog.dir_selected.connect(_change_data_folder)
_interface_fonts.apply_utility_theme(_data_folder_dialog)
add_child(_data_folder_dialog)
_data_folder_dialog.current_dir = _data_root.root_path.get_base_dir()
_data_folder_dialog.popup_centered_ratio(0.75)
func _change_data_folder(path: String) -> void:
var result := PortableDataMigration.migrate_active_to(_data_root, path)
var result: Dictionary = PortableDataMigration.migrate_active_to(
_data_root, path
)
if bool(result.get("requires_existing_root_decision", false)):
_show_existing_data_folder_choice(path)
return
@ -430,7 +440,7 @@ func _change_data_folder(path: String) -> void:
func _show_existing_data_folder_choice(path: String) -> void:
var dialog := ConfirmationDialog.new()
var dialog: ConfirmationDialog = ConfirmationDialog.new()
dialog.title = "Existing NETFISHING data"
dialog.ok_button_text = "Use Selected Data"
dialog.dialog_text = (
@ -449,7 +459,7 @@ func _show_existing_data_folder_choice(path: String) -> void:
dialog.custom_action.connect(func(action: StringName) -> void:
if action != &"replace":
return
var result := PortableDataMigration.replace_existing_with_active(
var result: Dictionary = PortableDataMigration.replace_existing_with_active(
_data_root, path
)
_feedback.text = str(result.get("message", "Could not replace selected data."))
@ -457,6 +467,7 @@ func _show_existing_data_folder_choice(path: String) -> void:
get_tree().call_deferred("quit")
dialog.queue_free()
)
_interface_fonts.apply_utility_theme(dialog)
add_child(dialog)
dialog.popup_centered(Vector2i(620, 340))
@ -466,13 +477,15 @@ func _choose_identity_export(identity_type: String) -> void:
return
_pending_identity_operation = "export"
_pending_identity_type = identity_type
var suggested := _identity_backups.default_export_path(identity_type)
var suggested: String = _identity_backups.default_export_path(identity_type)
if _export_file_dialog == null:
_export_file_dialog = FileDialog.new()
_export_file_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
_export_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
_export_file_dialog.use_native_dialog = false
_export_file_dialog.filters = PackedStringArray(["*.nfidentity ; NETFISHING identity backup"])
_export_file_dialog.file_selected.connect(_identity_export_file_selected)
_interface_fonts.apply_utility_theme(_export_file_dialog)
add_child(_export_file_dialog)
_export_file_dialog.current_dir = suggested.get_base_dir()
_export_file_dialog.current_file = suggested.get_file()
@ -495,8 +508,10 @@ func _choose_identity_import(identity_type: String) -> void:
_backup_file_dialog = FileDialog.new()
_backup_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_backup_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
_backup_file_dialog.use_native_dialog = false
_backup_file_dialog.filters = PackedStringArray(["*.nfidentity ; NETFISHING identity backup"])
_backup_file_dialog.file_selected.connect(_identity_import_file_selected)
_interface_fonts.apply_utility_theme(_backup_file_dialog)
add_child(_backup_file_dialog)
_backup_file_dialog.current_dir = _data_root.identity_backup_directory()
_backup_file_dialog.popup_centered_ratio(0.75)
@ -512,8 +527,8 @@ func _show_passphrase_dialog(exporting: bool) -> void:
_passphrase_dialog = ConfirmationDialog.new()
_passphrase_dialog.title = "Encrypted identity backup"
_passphrase_dialog.confirmed.connect(_submit_identity_passphrase)
var fields := VBoxContainer.new()
var warning := Label.new()
var fields: VBoxContainer = VBoxContainer.new()
var warning: Label = Label.new()
warning.text = (
"Anyone with this backup and passphrase can use your identity."
)
@ -528,6 +543,7 @@ func _show_passphrase_dialog(exporting: bool) -> void:
_passphrase_confirm.secret = true
fields.add_child(_passphrase_confirm)
_passphrase_dialog.add_child(fields)
_interface_fonts.apply_utility_theme(_passphrase_dialog)
add_child(_passphrase_dialog)
_passphrase_confirm.visible = exporting
_passphrase_entry.clear()
@ -541,7 +557,7 @@ func _show_passphrase_dialog(exporting: bool) -> void:
func _submit_identity_passphrase() -> void:
var passphrase := _passphrase_entry.text
var passphrase: String = _passphrase_entry.text
if _pending_identity_operation == "export":
_identity_backups.export_backup(
_pending_identity_type,
@ -550,7 +566,7 @@ func _submit_identity_passphrase() -> void:
_passphrase_confirm.text,
)
return
var inspected := _identity_backups.import_backup(
var inspected: Dictionary = _identity_backups.import_backup(
_pending_identity_type,
_pending_identity_path,
passphrase,
@ -566,7 +582,7 @@ func _submit_identity_passphrase() -> void:
func _show_identity_replacement_confirmation() -> void:
var dialog := ConfirmationDialog.new()
var dialog: ConfirmationDialog = ConfirmationDialog.new()
dialog.title = "Replace active identity?"
dialog.ok_button_text = "Review Replacement"
dialog.dialog_text = (
@ -579,6 +595,7 @@ func _show_identity_replacement_confirmation() -> void:
dialog.set_meta("confirmation_step", 1)
dialog.confirmed.connect(_advance_identity_replacement.bind(dialog))
dialog.canceled.connect(dialog.queue_free)
_interface_fonts.apply_utility_theme(dialog)
add_child(dialog)
dialog.popup_centered(Vector2i(620, 360))
@ -659,6 +676,9 @@ func _load_controls() -> void:
_mouse_sensitivity = settings.mouse_camera_sensitivity
_controller_sensitivity = settings.controller_camera_sensitivity
_invert_camera_y = settings.invert_camera_y
_readable_font_toggle.button_pressed = (
settings.use_readable_interface_font
)
_refresh_value_labels()
@ -686,6 +706,14 @@ func _refresh_value_labels() -> void:
"accessibility\nauto-click\n"
+ ("on" if _auto_click_enabled else "off")
)
_readable_font_toggle.text = (
"readable\ninterface font\n"
+ (
"on"
if settings.use_readable_interface_font
else "off"
)
)
_auto_click_interval.text = (
"auto-click\ninterval\n%.2f s" % _auto_click_interval_value
)
@ -709,6 +737,19 @@ func _set_world_pixelation(pixel_size: int) -> void:
_refresh_value_labels()
func _toggle_readable_font() -> void:
if _settings_manager == null:
return
var edited: PlayerSettings = _settings_manager.current_settings.copy()
edited.use_readable_interface_font = (
not edited.use_readable_interface_font
)
if not _settings_manager.apply_settings(edited):
_feedback.text = "failed to save accessibility settings."
return
_load_controls()
func _set_ui_pixelation(pixel_size: int) -> void:
if _settings_manager == null:
return

View file

@ -486,8 +486,8 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("3_page")
page_id = &"accessibility"
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/IntervalHelp"), NodePath("BubbleCluster/AccessibilityBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/AccessibilityBackButton")])
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/ReadableFontToggle"), NodePath("BubbleCluster/IntervalHelp"), NodePath("BubbleCluster/AccessibilityBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/ReadableFontToggle"), NodePath("BubbleCluster/AccessibilityBackButton")])
initial_focus_path = NodePath("BubbleCluster/AutoClickToggle")
back_focus_path = NodePath("BubbleCluster/AccessibilityBackButton")
@ -562,6 +562,41 @@ minimum_font_size = 13
maximum_font_size = 22
motion_phase = 4.1
[node name="ReadableFontToggle" parent="AccessibilityPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0)
toggle_mode = true
text = "readable\ninterface font\noff"
neutral_size = Vector2(180, 132)
desktop_anchor = Vector2(92, 405)
compact_anchor = Vector2(102, 374)
compact_minimum_size = Vector2(156, 118)
minimum_font_size = 13
maximum_font_size = 21
motion_phase = 4.6
[node name="ReadableFontSample" type="Label" parent="AccessibilityPage/BubbleCluster"]
layout_mode = 0
offset_left = 250.0
offset_top = 20.0
offset_right = 700.0
offset_bottom = 82.0
mouse_filter = 2
text = "The quick brown fox jumps over the lazy dog.\n0123456789"
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[node name="ReadableFontHelper" type="Label" parent="AccessibilityPage/BubbleCluster"]
layout_mode = 0
offset_left = 250.0
offset_top = 82.0
offset_right = 700.0
offset_bottom = 112.0
mouse_filter = 2
text = "Uses a clearer font for menus and interface text."
horizontal_alignment = 1
[node name="AccessibilityBackButton" parent="AccessibilityPage/BubbleCluster" instance=ExtResource("4_bubble")]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 0)