aboutsummaryrefslogtreecommitdiff
path: root/client/system
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-09-16 22:28:24 +0200
committermetamuffin <metamuffin@disroot.org>2025-09-16 22:28:24 +0200
commit3f98582f903e579d9f47aba48f3976345eabe123 (patch)
tree5124f087056171ccf0196169a4b3c4e992183fa3 /client/system
parente86637eade79ed5fef5ca2e9c169f5c40a314400 (diff)
downloadhurrycurry-3f98582f903e579d9f47aba48f3976345eabe123.tar
hurrycurry-3f98582f903e579d9f47aba48f3976345eabe123.tar.bz2
hurrycurry-3f98582f903e579d9f47aba48f3976345eabe123.tar.zst
Move some scripts to new "system" dir, add argument parser
Diffstat (limited to 'client/system')
-rw-r--r--client/system/cli.gd112
-rw-r--r--client/system/cli.gd.uid1
-rw-r--r--client/system/disable_wrong_joypads.gd61
-rw-r--r--client/system/disable_wrong_joypads.gd.uid1
-rw-r--r--client/system/profile.gd91
-rw-r--r--client/system/profile.gd.uid1
-rw-r--r--client/system/server_list.gd113
-rw-r--r--client/system/server_list.gd.uid1
-rw-r--r--client/system/settings.gd200
-rw-r--r--client/system/settings.gd.uid1
-rw-r--r--client/system/translation_manager.gd57
-rw-r--r--client/system/translation_manager.gd.uid1
12 files changed, 640 insertions, 0 deletions
diff --git a/client/system/cli.gd b/client/system/cli.gd
new file mode 100644
index 00000000..b6ba8eef
--- /dev/null
+++ b/client/system/cli.gd
@@ -0,0 +1,112 @@
+# Hurry Curry! - a game about cooking
+# Copyright (C) 2025 Hurry Curry! contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, version 3 of the License only.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+#
+extends Node
+class_name Cli
+
+enum Mode { FLAG, OPTION, MULTI_OPTION, POSITIONAL }
+class Option:
+ var short #: String?
+ var long: String
+ var mode: Mode
+ var help: String
+ func _init(s, l: String, m: Mode, h: String):
+ short = s; long = l; mode = m; help = h
+
+static var OPTIONS := [
+ Option.new("h", "help", Mode.FLAG, "Show help"),
+ Option.new("s", "setting", Mode.MULTI_OPTION, "Per-launch setting override"),
+ Option.new(null, "connect_address", Mode.POSITIONAL, "Connect to a server directly without menu interaction")
+]
+
+static var opts = {} #: Dictionary[String, Variant]
+
+static func init() -> bool:
+ if not parse(): return false
+ if opts.has("help"):
+ print_help()
+ return false
+ return true
+
+static func print_help():
+ print("OPTIONS:\n")
+ for opt in OPTIONS:
+ var line = ""
+ if opt.mode == Mode.POSITIONAL:
+ line += "<" + opt.long.to_upper() + ">"
+ else:
+ if opt.short: line += "-" + opt.short + ", "
+ line += "--" + opt.long
+ if opt.mode == Mode.OPTION or opt.mode == Mode.MULTI_OPTION:
+ line += " <VALUE>"
+ while line.length() < 25: line += " "
+ line += " " + opt.help
+ print(line)
+
+static func parse() -> bool:
+ var args := OS.get_cmdline_user_args()
+ while not args.is_empty():
+ var arg := args[0]
+ args.remove_at(0)
+ if arg.begins_with("--"):
+ var long = arg.trim_prefix("--")
+ var opt_index = OPTIONS.find_custom(func(x): return x.long == long)
+ if opt_index == -1:
+ push_error("unknown long option \"%s\"" % long)
+ return false
+ if not _parse_opt(args, OPTIONS[opt_index]): return false
+ elif arg.begins_with("-"):
+ for short in arg.trim_prefix("-"):
+ var opt_index = OPTIONS.find_custom(func(x): return x.short == short)
+ if opt_index == -1:
+ push_error("unknown short option \"%s\"" % short)
+ return false
+ if not _parse_opt(args, OPTIONS[opt_index]): return false
+ else:
+ var opt_index = OPTIONS.find_custom(func(x): return x.mode == Mode.POSITIONAL)
+ if opt_index == -1:
+ push_error("no positional arguments")
+ return false
+ var opt = OPTIONS[opt_index]
+ opts[opt.long] = arg
+
+ print("Parsed options: ", opts)
+ return true
+
+static func _parse_opt(args: Array[String], opt: Option) -> bool:
+ match opt.mode:
+ Mode.FLAG:
+ opts[opt.long] = true
+ return true
+ Mode.OPTION:
+ if args.is_empty():
+ push_error("missing option value")
+ return false
+ opts[opt.long] = args[0]
+ args.remove_at(0)
+ return true
+ Mode.MULTI_OPTION:
+ if args.is_empty():
+ push_error("missing option value")
+ return false
+ if not opts.has(opt.long): opts[opt.long] = []
+ opts[opt.long].push_back(args[0])
+ args.remove_at(0)
+ return true
+ Mode.POSITIONAL:
+ push_error("positional arg doesnt need flag")
+ return false
+ push_error("unreachable")
+ return false
diff --git a/client/system/cli.gd.uid b/client/system/cli.gd.uid
new file mode 100644
index 00000000..031e72be
--- /dev/null
+++ b/client/system/cli.gd.uid
@@ -0,0 +1 @@
+uid://b26r6mw82umnc
diff --git a/client/system/disable_wrong_joypads.gd b/client/system/disable_wrong_joypads.gd
new file mode 100644
index 00000000..029768b3
--- /dev/null
+++ b/client/system/disable_wrong_joypads.gd
@@ -0,0 +1,61 @@
+# Hurry Curry! - a game about cooking
+# Copyright (C) 2025 Hurry Curry! contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, version 3 of the License only.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+#
+extends Node
+
+func _ready() -> void:
+ Input.joy_connection_changed.connect(joy_check)
+
+static var banned_words: PackedStringArray = [
+ "touchpad", "trackpad", "clickpad", "mouse", "pen", "finger", "led",
+ "Synaptics",
+]
+
+static func joy_check(device: int, connected: bool) -> void:
+ if not connected:
+ return
+ var device_name: String = Input.get_joy_name(device)
+ if not is_banned(device_name):
+ return
+ var guid: String = Input.get_joy_guid(device)
+ var mapping: String = guid + ',' + device_name.replace(',', '')
+ Input.add_joy_mapping(mapping, true)
+ for axis: JoyAxis in range(JOY_AXIS_SDL_MAX) as Array[JoyAxis]:
+ var event: InputEventJoypadMotion = InputEventJoypadMotion.new()
+ event.device = device
+ event.axis = axis
+ Input.parse_input_event(event)
+ for button_index: JoyButton in range(JOY_BUTTON_SDL_MAX) as Array[JoyButton]:
+ var event: InputEventJoypadButton = InputEventJoypadButton.new()
+ event.device = device
+ event.button_index = button_index
+ Input.parse_input_event(event)
+ prints('Ignoring joypad device:', mapping)
+
+static func is_banned(device_name: String) -> bool:
+ for word: String in banned_words:
+ var i: int = device_name.findn(word)
+ if i < 0:
+ continue
+ if i > 0 and not is_ascii_non_letter(device_name[i - 1]):
+ continue
+ var j: int = i + word.length()
+ if j < device_name.length() and not is_ascii_non_letter(device_name[j]):
+ continue
+ return true
+ return false
+
+static func is_ascii_non_letter(c: String) -> bool:
+ return c.unicode_at(0) < 128 and not (c >= 'a' and c <= 'z' or c >= 'A' and c <= 'Z')
diff --git a/client/system/disable_wrong_joypads.gd.uid b/client/system/disable_wrong_joypads.gd.uid
new file mode 100644
index 00000000..dbf5f234
--- /dev/null
+++ b/client/system/disable_wrong_joypads.gd.uid
@@ -0,0 +1 @@
+uid://chhri171ljnss
diff --git a/client/system/profile.gd b/client/system/profile.gd
new file mode 100644
index 00000000..438e8e66
--- /dev/null
+++ b/client/system/profile.gd
@@ -0,0 +1,91 @@
+# Hurry Curry! - a game about cooking
+# Copyright (C) 2025 Hurry Curry! contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, version 3 of the License only.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+#
+extends Node
+class_name Profile
+
+static var default_profile := {
+ "username": "",
+ "character_style": {
+ "color": 0,
+ "headwear": 0,
+ "hairstyle": 0
+ },
+ "last_server_url": "",
+ "tutorial_ingredients_played": [],
+ "registry_asked": false,
+ "hints": {
+ "has_moved": false,
+ "has_boosted": false,
+ "has_interacted": false,
+ "has_rotated": false,
+ "has_reset": false,
+ "has_zoomed": false,
+ "has_seen_performance": false,
+ "has_seen_join_while_running": false
+ }
+}
+
+# profile is stored in a Dictionary[String, Any]
+static var values: Dictionary
+static var loaded_path: String
+
+static func load(path: String):
+ # TOCTOU here. Godot docs says its fine.
+ if not FileAccess.file_exists(path):
+ print("Skip profile load")
+ return default_profile
+ var f = FileAccess.open(path, FileAccess.READ)
+
+ values = f.get_var(true)
+ if values != null and values is Dictionary:
+ G.add_missing_keys(values, default_profile)
+ loaded_path = path
+
+static func save():
+ var f = FileAccess.open(loaded_path, FileAccess.WRITE)
+ var to_save = values.duplicate(true)
+ f.store_var(to_save, true)
+
+static func read(key: String):
+ if values.has(key):
+ return values[key]
+ else:
+ push_error("Tried to access profile setting \"%s\", which does not exist (missing key)" % key)
+ return null
+
+static func write(key: String, value):
+ if !values.has(key):
+ push_error("Tried to set profile setting \"%s\", which does not yet exist (missing key)" % key)
+ return
+ if values[key] != value:
+ values[key] = value
+
+static func set_hint(key: String, value: bool):
+ if !values["hints"].has(key):
+ push_error("Tried to set hint \"%s\", which does not yet exist (missing key)" % key)
+ if values["hints"][key] != value:
+ if value:
+ Settings.write("gameplay.hints_started", true)
+ Settings.save()
+ values["hints"][key] = value
+ save() # TODO avoid this call when bulk-unsetting hints
+
+static func get_hint(key: String):
+ if values["hints"].has(key):
+ return values["hints"][key]
+ else:
+ push_error("Tried to access hint \"%s\", which does not exist (missing key)" % key)
+ return null
diff --git a/client/system/profile.gd.uid b/client/system/profile.gd.uid
new file mode 100644
index 00000000..5cd63b28
--- /dev/null
+++ b/client/system/profile.gd.uid
@@ -0,0 +1 @@
+uid://1hlvx8wuxl2d
diff --git a/client/system/server_list.gd b/client/system/server_list.gd
new file mode 100644
index 00000000..77b5ee0c
--- /dev/null
+++ b/client/system/server_list.gd
@@ -0,0 +1,113 @@
+# Hurry Curry! - a game about cooking
+# Copyright (C) 2025 Hurry Curry! contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, version 3 of the License only.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+#
+extends Node
+
+signal update_loading(status: bool)
+signal update_server_list(list: Array)
+
+enum Registry {
+ MDNS = 0,
+ GLOBAL = 1,
+}
+
+const MDNS_URL: String = "http://127.0.0.1:27033/v1/list"
+const HEADERS: Array[String] = [
+ "Accept: application/json",
+ "User-Agent: Hurry Curry! %s" % Global.VERSION
+ ]
+
+var current_list: Array[Array] = [[], []]
+var loading := false
+var mdns := HTTPRequest.new()
+var reg := HTTPRequest.new()
+
+# Fallback to http, since there seems to be a problem related to mbed tls in Godot.
+# See: https://github.com/godotengine/godot/issues/96103
+var using_http_fallback := false
+
+var mdns_timer := Timer.new()
+var reg_timer := Timer.new()
+# after 30 minutes we stop fetching results to reduce server load
+var timeout := Timer.new()
+
+func _ready() -> void:
+ add_child(mdns)
+ add_child(reg)
+ mdns_timer.wait_time = 5.
+ mdns_timer.one_shot = false
+ mdns_timer.timeout.connect(fetch_server_list.bind(Registry.MDNS))
+ add_child(mdns_timer)
+ reg_timer.wait_time = 60.
+ reg_timer.one_shot = false
+ reg_timer.timeout.connect(fetch_server_list.bind(Registry.GLOBAL))
+ add_child(reg_timer)
+ timeout.wait_time = 60. * 30.
+ timeout.timeout.connect(func():
+ stop()
+ )
+ add_child(timeout)
+ mdns.request_completed.connect(_on_request_completed.bind(Registry.MDNS))
+ reg.request_completed.connect(_on_request_completed.bind(Registry.GLOBAL))
+
+func fetch_server_list(registry: Registry) -> void:
+ match registry:
+ Registry.MDNS:
+ if Settings.read("online.use_discover"):
+ match Discover.state:
+ Service.State.STOPPED: Discover.start()
+ Service.State.RUNNING: mdns.request(MDNS_URL, HEADERS)
+ Registry.GLOBAL:
+ if Settings.read("online.use_registry"):
+ var url: String = Settings.read("online.registry_url")
+ url = url.replace("https:", "http:") if using_http_fallback else url
+ reg.request(url + "/v1/list", HEADERS)
+
+ loading = true
+ update_loading.emit(true)
+
+func _on_request_completed(result: int, _response_code: int, _headers: PackedStringArray, body: PackedByteArray, registry: Registry):
+ loading = false
+ update_loading.emit(false)
+ if result != 0:
+ push_warning("Fetching server list failed with code %d." % result)
+ if !using_http_fallback:
+ print("Retrying with http...")
+ using_http_fallback = true
+ fetch_server_list(Registry.GLOBAL)
+ return
+ var json = JSON.parse_string(body.get_string_from_utf8())
+ if json == null:
+ push_error("Server list response invalid")
+ return
+ current_list[registry] = json
+ update_server_list.emit(current_list)
+
+func start() -> void:
+ timeout.stop()
+ timeout.start()
+ mdns_timer.start()
+ fetch_server_list(Registry.MDNS)
+ reg_timer.start()
+ fetch_server_list(Registry.GLOBAL)
+
+func stop() -> void:
+ timeout.stop()
+ mdns_timer.stop()
+ reg_timer.stop()
+
+func one_shot() -> void:
+ start()
+ stop()
diff --git a/client/system/server_list.gd.uid b/client/system/server_list.gd.uid
new file mode 100644
index 00000000..e5479756
--- /dev/null
+++ b/client/system/server_list.gd.uid
@@ -0,0 +1 @@
+uid://b5rgw37pfh22b
diff --git a/client/system/settings.gd b/client/system/settings.gd
new file mode 100644
index 00000000..1cacfce9
--- /dev/null
+++ b/client/system/settings.gd
@@ -0,0 +1,200 @@
+# Hurry Curry! - a game about cooking
+# Copyright (C) 2025 Hurry Curry! contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, version 3 of the License only.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+#
+extends Node
+class_name Settings
+
+static func get_root():
+ return SettingsRoot.new([
+ SettingsCategory.new("gameplay", [
+ ToggleSetting.new("usernames", true),
+ ToggleSetting.new("latch_boost", true),
+ ToggleSetting.new("vibration", true),
+ ToggleSetting.new("invert_camera", false),
+ ToggleSetting.new("interpolate_camera_rotation", false),
+ ButtonSetting.new("setup_completed", false, launch_setup),
+ ToggleSetting.new("tutorial_disabled", false),
+ ToggleSetting.new("hints_started", false),
+ ToggleSetting.new("accessible_movement", false),
+ ToggleSetting.new("first_person", false),
+ DropdownSetting.new("interact_target", "dirsnap", ["dir", "dirsnap"]),
+ ]),
+ SettingsCategory.new("graphics", [
+ PresetRow.new("preset", {
+ "low": {"ui_blur": true, "aa": "disabled", "ssao": false, "taa": false, "shadows": false, "glow": false, "grass_amount": 0, "lq_trees": true},
+ "medium": {"ui_blur": true, "aa": "fx", "ssao": false, "taa": false, "shadows": true, "glow": false, "grass_amount": 16, "lq_trees": false},
+ "high": {"ui_blur": true, "aa": "ms2x", "ssao": true, "taa": false, "shadows": true, "glow": true, "grass_amount": 24, "lq_trees": false}
+ }),
+ DropdownSetting.new("fullscreen", "keep", ["keep", "always", "never"]),
+ DropdownSetting.new("aa", "ms2x" if Global.on_high_end() else "disabled", ["disabled", "fx", "ms2x", "ms4x"]),
+ ToggleSetting.new("ssao", true if Global.on_high_end() else false),
+ ToggleSetting.new("taa", false),
+ DropdownSetting.new("gi", "disabled", ["disabled", "sdfgi", "voxelgi"]),
+ ToggleSetting.new("shadows", true if Global.on_high_end() else false),
+ ToggleSetting.new("glow", true if Global.on_high_end() else false),
+ RangeSetting.new("grass_amount", 24 if Global.on_high_end() else 0, 0, 32, false),
+ ToggleSetting.new("lq_trees", false if Global.on_high_end() else true),
+ ToggleSetting.new("debug_info", false),
+ ToggleSetting.new("ui_blur", true)
+ ]),
+ SettingsCategory.new("audio", [
+ RangeSetting.new("master_volume", 0, -30, 0),
+ RangeSetting.new("music_volume", 0, -30, 0),
+ RangeSetting.new("sfx_volume", 0, -30, 0),
+ ]),
+ SettingsCategory.new("ui", [
+ DropdownSetting.new("touch_controls", "automatic", ["automatic", "enabled", "disabled"]),
+ DropdownSetting.new("language", "system", Global.language_list()),
+ ToggleSetting.new("hide_overlays", false),
+ DropdownSetting.new("scale_mode", "resize", ["resize", "disabled"]),
+ RangeSetting.new("scale_factor", 1. if not Global.on_mobile() else 1.5, 0.5, 1.5, 3),
+ ]),
+ SettingsCategory.new("input", InputManager.settings()),
+ SettingsCategory.new("server", [
+ PathSetting.new("binary_path", "", FileDialog.FileMode.FILE_MODE_OPEN_FILE),
+ PathSetting.new("editor_binary_path", "", FileDialog.FileMode.FILE_MODE_OPEN_DIR),
+ PathSetting.new("data_path", "", FileDialog.FileMode.FILE_MODE_OPEN_DIR),
+ TextSetting.new("name", "A Hurry Curry! Server"),
+ ToggleSetting.new("allow_external_connections", true),
+ ToggleSetting.new("enable_ipv6", true),
+ NumberSetting.new("bind_port", 27032, 1, 65535),
+ ToggleSetting.new("upnp", false),
+ ToggleSetting.new("mdns", true),
+ ToggleSetting.new("register", false),
+ ]),
+ SettingsCategory.new("online", [
+ ToggleSetting.new("use_registry", false),
+ TextSetting.new("registry_url", "https://hurrycurry-registry.metamuffin.org/"),
+ ToggleSetting.new("use_discover", true),
+ PathSetting.new("discover_binary", "", FileDialog.FileMode.FILE_MODE_OPEN_FILE),
+ ])
+ ])
+
+static var tree: GameSetting
+static var values: Dictionary = {}
+static var loaded_path: String
+
+static func read(key: String):
+ if !values.has(key):
+ push_error("Tried to access setting \"%s\", which does not exist (missing key)" % key)
+ return null
+ return values[key]
+
+static func write_unchecked(key: String, value):
+ value = value.duplicate(true) if value is Array else value
+ if key in values and typeof(values[key]) == typeof(value) and not value is Array and values[key] == value: return
+ values[key] = value
+ trigger_hook(key, value)
+
+static func write(key: String, value):
+ if !values.has(key):
+ push_error("Tried to set setting \"%s\", which does not yet exist (missing key)" % key)
+ return
+ else: write_unchecked(key, value)
+
+static func load(path: String):
+ tree = get_root()
+ loaded_path = path
+ var changed = {}
+ if FileAccess.file_exists(path):
+ var f = FileAccess.open(path, FileAccess.READ)
+ changed = JSON.parse_string(f.get_as_text())
+ tree.load(changed)
+
+static func save():
+ var changed = {}
+ tree.save(changed)
+ var f = FileAccess.open(loaded_path, FileAccess.WRITE)
+ f.store_string(JSON.stringify(changed))
+
+static func trigger_hook(key: String, value):
+ if change_hooks_display.get(key) != null: change_hooks_display.get(key).callv([value] if value != null else [])
+ if change_hooks_apply.get(key) != null: change_hooks_apply.get(key).callv([value] if value != null else [])
+ if key.find(".") != -1: trigger_hook(key.rsplit(".", false, 1)[0], null)
+
+static func hook_changed(key: String, display: bool, callable: Callable):
+ if display: change_hooks_display[key] = callable
+ else: change_hooks_apply[key] = callable
+
+static func hook_changed_init(key: String, display: bool, callable: Callable):
+ hook_changed(key, display, callable)
+ callable.call(read(key))
+
+static func get_category_dict(prefix: String):
+ var map = {}
+ for k in values.keys():
+ var kn = k.trim_prefix(prefix + ".")
+ if kn == k: continue
+ map[kn] = read(k)
+ return map
+
+static func launch_setup():
+ Global.focused_menu.submenu("res://gui/menus/setup/setup.tscn")
+
+static var change_hooks_display = {}
+static var change_hooks_apply = {
+ "input": h_input,
+ "gameplay.hints_started": h_hints_started,
+ "graphics.aa": h_aa,
+ "graphics.taa": h_taa,
+ "graphics.fullscreen": h_fullscreen,
+ "ui.scale_mode": h_scale_mode,
+ "ui.scale_factor": h_scale_factor,
+ "ui.language": h_language,
+ "audio.master_volume": h_volume_master,
+ "audio.music_volume": h_volume_music,
+ "audio.sfx_volume": h_volume_sfx,
+}
+
+static func h_aa(_mode):
+ Global.configure_viewport_aa(Global.get_viewport())
+
+static func h_taa(enabled):
+ Global.get_viewport().use_taa = enabled
+
+static func h_scale_mode(mode: String):
+ var root = Global.get_tree().root
+ if OS.has_feature("movie"):
+ root.content_scale_mode = Window.CONTENT_SCALE_MODE_VIEWPORT
+ root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_KEEP
+ else: match mode:
+ "resize": root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
+ "disabled": root.content_scale_mode = Window.CONTENT_SCALE_MODE_DISABLED
+
+static func h_scale_factor(value: float):
+ Global.get_tree().root.content_scale_factor = value
+
+static func h_volume_master(value: float): Sound.set_volume(0, value)
+static func h_volume_music(value: float): Sound.set_volume(1, value)
+static func h_volume_sfx(value: float): Sound.set_volume(2, value)
+
+static func h_language(language: String):
+ if language == "system": language = OS.get_locale_language()
+ TranslationServer.set_locale(language)
+
+static func h_fullscreen(mode: String):
+ match mode:
+ "keep": pass
+ "always": DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
+ "never": if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN:
+ DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
+
+static func h_input():
+ InputManager.apply_input_map(Settings.get_category_dict("input"))
+
+static func h_hints_started(started: bool):
+ if not started:
+ for k in Profile.values["hints"].keys():
+ Profile.set_hint(k, false)
diff --git a/client/system/settings.gd.uid b/client/system/settings.gd.uid
new file mode 100644
index 00000000..ca85e233
--- /dev/null
+++ b/client/system/settings.gd.uid
@@ -0,0 +1 @@
+uid://dkingwif2bsek
diff --git a/client/system/translation_manager.gd b/client/system/translation_manager.gd
new file mode 100644
index 00000000..9aa374b2
--- /dev/null
+++ b/client/system/translation_manager.gd
@@ -0,0 +1,57 @@
+# Hurry Curry! - a game about cooking
+# Copyright (C) 2025 Hurry Curry! contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, version 3 of the License only.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+#
+extends Node
+
+const LOCALE_PATH := "res://locale/"
+const LOCALE_BOOK_PATH := "res://locale_book/"
+const NATIVE_LANGUAGE_NAMES_FILE_NAME := "native_language_names.ini1"
+
+func _init() -> void:
+ # Use english as fallback
+ var native_language_names := get_ini_dict(NATIVE_LANGUAGE_NAMES_FILE_NAME, LOCALE_PATH)
+ var fallback_strings := get_ini_dict("en.ini", LOCALE_PATH)
+ var fallback_strings_book := get_ini_dict("en.ini", LOCALE_BOOK_PATH)
+
+ for file_name in DirAccess.get_files_at(LOCALE_PATH):
+ if !file_name.ends_with(".ini"):
+ continue
+
+ var translation := Translation.new()
+ translation.locale = file_name.trim_suffix(".ini")
+ var trans_strings := get_ini_dict(file_name, LOCALE_PATH)
+ var trans_book_strings := get_ini_dict(file_name, LOCALE_BOOK_PATH)
+
+ for k in fallback_strings.keys():
+ translation.add_message(k, trans_strings[k] if trans_strings.has(k) else fallback_strings[k])
+ for k in fallback_strings_book.keys():
+ translation.add_message(k, trans_book_strings[k] if trans_book_strings.has(k) else fallback_strings_book[k])
+ for k in native_language_names.keys():
+ translation.add_message("c.settings.ui.language.%s" % k, native_language_names[k])
+
+ TranslationServer.add_translation(translation)
+
+
+func get_ini_dict(file_name: String, locale_path: String) -> Dictionary: # Dictionary[String, String]
+ var dict := {}
+ var lines := FileAccess.get_file_as_string(locale_path + file_name).split("\n", false)
+ if lines.size() > 0:
+ lines.remove_at(0)
+
+ for line in lines:
+ var halves := line.split("=", true, 1)
+ dict[halves[0].strip_edges()] = halves[1].strip_edges().replace("%n", "\n")
+
+ return dict
diff --git a/client/system/translation_manager.gd.uid b/client/system/translation_manager.gd.uid
new file mode 100644
index 00000000..ea5fd7da
--- /dev/null
+++ b/client/system/translation_manager.gd.uid
@@ -0,0 +1 @@
+uid://cn5c1hvcxe736