diff --git a/godot/scripts/util/config_paths.gd b/godot/scripts/util/config_paths.gd new file mode 100644 index 0000000..06f44a7 --- /dev/null +++ b/godot/scripts/util/config_paths.gd @@ -0,0 +1,49 @@ +extends Node +## Cross-platform config directory utility. +## Resolves config directories following OS conventions: +## - Windows: %APPDATA%\chobit\ +## - macOS: ~/Library/Application Support/chobit/ +## - Linux: $XDG_CONFIG_HOME/chobit/ or ~/.config/chobit/ + +class_name ConfigPaths + + +## Get the base config directory for the current OS. +static func get_config_dir(app_name: String = "chobit") -> String: + match OS.get_name(): + "Windows": + var appdata := OS.get_environment("APPDATA") + if appdata.is_empty(): + appdata = OS.get_environment("USERPROFILE") + "\\AppData\\Roaming" + return appdata + "\\" + app_name + "macOS": + var home := OS.get_environment("HOME") + if home.is_empty(): + push_error("ConfigPaths: HOME environment variable not set") + return "" + return home + "/Library/Application Support/" + app_name + _: # Linux and others + var xdg_config := OS.get_environment("XDG_CONFIG_HOME") + if not xdg_config.is_empty(): + return xdg_config + "/" + app_name + var home := OS.get_environment("HOME") + if home.is_empty(): + push_error("ConfigPaths: HOME environment variable not set") + return "" + return home + "/.config/" + app_name + + +## Get full path to a config file in the config directory. +## Ensures the directory exists. +static func get_config_file(filename: String, app_name: String = "chobit") -> String: + var config_dir := get_config_dir(app_name) + if config_dir.is_empty(): + return "" + + # Create directory if it doesn't exist + var err := DirAccess.make_dir_recursive_absolute(config_dir) + if err != OK: + push_error("ConfigPaths: Failed to create directory %s (error: %d)" % [config_dir, err]) + return "" + + return config_dir + ("/" if OS.get_name() != "Windows" else "\\") + filename diff --git a/godot/scripts/util/node_utils.gd b/godot/scripts/util/node_utils.gd index 622bbfa..7f15aa0 100644 --- a/godot/scripts/util/node_utils.gd +++ b/godot/scripts/util/node_utils.gd @@ -3,9 +3,7 @@ extends RefCounted ## Static utility methods for node tree traversal. -static func find_child_of_type( - node: Node, type_name: String -) -> Node: +static func find_child_of_type(node: Node, type_name: String) -> Node: if node.get_class() == type_name: return node for child: Node in node.get_children(): @@ -29,9 +27,7 @@ static func find_first_mesh_with_blendshapes( return null -static func find_blend_shape_index( - mesh: MeshInstance3D, shape_name: String -) -> int: +static func find_blend_shape_index(mesh: MeshInstance3D, shape_name: String) -> int: if mesh == null or mesh.mesh == null: return -1 for i: int in range(mesh.mesh.get_blend_shape_count()): @@ -40,12 +36,50 @@ static func find_blend_shape_index( return -1 -static func find_bone_case_insensitive( - skeleton: Skeleton3D, bone_name: String -) -> int: +static func find_bone_case_insensitive(skeleton: Skeleton3D, bone_name: String) -> int: var idx := skeleton.find_bone(bone_name) if idx == -1: idx = skeleton.find_bone(bone_name.to_lower()) if idx == -1: idx = skeleton.find_bone(bone_name.to_upper()) return idx + + +static func read_vrm_morph_names(vrm_path: String) -> PackedStringArray: + ## Reads original morph target names from a .vrm (GLB) file's JSON chunk. + ## VRM4Godot renames blendshapes to morph_N during import, but the + ## original names (e.g. Japanese MMD names) are preserved in + ## mesh.primitives[].extras.targetNames inside the GLB JSON. + var f := FileAccess.open(vrm_path, FileAccess.READ) + if f == null: + return PackedStringArray() + + # GLB header: magic(4) + version(4) + length(4) + f.get_32() + f.get_32() + f.get_32() + + # First chunk: length(4) + type(4) + JSON data + var chunk_length := f.get_32() + f.get_32() + var json_bytes := f.get_buffer(chunk_length) + f.close() + + var json := JSON.new() + if json.parse(json_bytes.get_string_from_utf8()) != OK: + return PackedStringArray() + + var data: Dictionary = json.data + var meshes: Array = data.get("meshes", []) + for mesh_data: Variant in meshes: + var primitives: Array = mesh_data.get("primitives", []) + for prim: Variant in primitives: + var extras: Dictionary = prim.get("extras", {}) + var target_names: Array = extras.get("targetNames", []) + if target_names.size() > 0: + var result := PackedStringArray() + for n: Variant in target_names: + result.append(str(n)) + return result + + return PackedStringArray() diff --git a/godot/scripts/util/screen_cursor.gd b/godot/scripts/util/screen_cursor.gd new file mode 100644 index 0000000..6cf140b --- /dev/null +++ b/godot/scripts/util/screen_cursor.gd @@ -0,0 +1,61 @@ +class_name ScreenCursor +extends RefCounted +## Maps the global mouse position to a normalized -1..1 range +## relative to the window center, using screen edges as bounds. +## Handles multi-monitor and Wayland focus limitations. + +## Max pixel distance from window center that maps to 1.0. +## Prevents over-sensitive tracking on ultra-wide setups. +const MAX_RANGE_PX: float = 2000.0 + +static var _last_valid: Vector2 = Vector2.ZERO + + +static func get_normalized_position() -> Vector2: + var ds := DisplayServer + var mouse := ds.mouse_get_position() + var win_pos := ds.window_get_position() + var win_size := ds.window_get_size() + + var cx := win_pos.x + win_size.x / 2 + var cy := win_pos.y + win_size.y / 2 + + var dx := float(mouse.x - cx) + var dy := float(mouse.y - cy) + + # On Wayland, mouse position may freeze at (0,0) when unfocused + if mouse.x == 0 and mouse.y == 0: + return _last_valid + + # Use screen the window is on for bounds + var screen_idx := ds.window_get_current_screen() + var screen_rect := ds.screen_get_usable_rect(screen_idx) + var screen_pos := screen_rect.position + var screen_size := screen_rect.size + + # Distance from window center to each screen edge + var dist_left := maxf(float(cx - screen_pos.x), 1.0) + var dist_right := maxf(float(screen_pos.x + screen_size.x - cx), 1.0) + var dist_up := maxf(float(cy - screen_pos.y), 1.0) + var dist_down := maxf(float(screen_pos.y + screen_size.y - cy), 1.0) + + # Cap range so ultra-wide screens don't make gaze too sensitive + dist_left = minf(dist_left, MAX_RANGE_PX) + dist_right = minf(dist_right, MAX_RANGE_PX) + dist_up = minf(dist_up, MAX_RANGE_PX) + dist_down = minf(dist_down, MAX_RANGE_PX) + + var nx: float = 0.0 + if dx > 0.0: + nx = clampf(dx / dist_right, 0.0, 1.0) + elif dx < 0.0: + nx = clampf(dx / dist_left, -1.0, 0.0) + + var ny: float = 0.0 + if dy > 0.0: + ny = -clampf(dy / dist_down, 0.0, 1.0) + elif dy < 0.0: + ny = -clampf(dy / dist_up, -1.0, 0.0) + + _last_valid = Vector2(nx, ny) + return _last_valid diff --git a/godot/scripts/util/screen_cursor.gd.uid b/godot/scripts/util/screen_cursor.gd.uid new file mode 100644 index 0000000..e3010fb --- /dev/null +++ b/godot/scripts/util/screen_cursor.gd.uid @@ -0,0 +1 @@ +uid://bwevi2bwdn3bp