diff --git a/demo/Blackholio/client-godot/.editorconfig b/demo/Blackholio/client-godot/.editorconfig new file mode 100644 index 00000000000..f28239ba528 --- /dev/null +++ b/demo/Blackholio/client-godot/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/demo/Blackholio/client-godot/.gitattributes b/demo/Blackholio/client-godot/.gitattributes new file mode 100644 index 00000000000..f23e3482272 --- /dev/null +++ b/demo/Blackholio/client-godot/.gitattributes @@ -0,0 +1,6 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf + +.godot +spacetime_data/schema +spacetime_data/codegen_debug \ No newline at end of file diff --git a/demo/Blackholio/client-godot/.gitignore b/demo/Blackholio/client-godot/.gitignore new file mode 100644 index 00000000000..0af181cfb54 --- /dev/null +++ b/demo/Blackholio/client-godot/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen.gd new file mode 100644 index 00000000000..f45b7e31727 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen.gd @@ -0,0 +1,567 @@ +class_name SpacetimeCodegen extends Resource + +const REQUIRED_FOLDERS_IN_CODEGEN_FOLDER: Array[String] = ["tables", "types"] +const OPTION_CLASS_NAME := "Option" +const AUTOGENERATED_COMMENT := "# THIS FILE IS AUTOMATICALLY GENERATED BY THE SPACETIMEDB ADDON. EDITS TO THIS\n" + \ + "# FILE WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.\n" + +var _config: SpacetimeCodegenConfig +var _schema_path: String + +func _init(p_schema_path: String) -> void: + _schema_path = p_schema_path + _config = SpacetimeCodegenConfig.new() + +func generate_bindings(module_schemas: Dictionary[String, String]) -> Array[String]: + var generated_files: Array[String] = [] + + for module_name in module_schemas: + generated_files.append_array(_generate_module_bindings(module_name, module_schemas[module_name])) + + var autoload_content := _generate_autoload_gdscript(module_schemas.keys()) + var autoload_output_file_path := "%s/%s" % [_schema_path, SpacetimePlugin.AUTOLOAD_FILE_NAME] + var autoload_file := FileAccess.open(autoload_output_file_path, FileAccess.WRITE) + if autoload_file: + autoload_file.store_string(autoload_content) + autoload_file.close() + generated_files.append(autoload_output_file_path) + + SpacetimePlugin.print_log("Generated files:") + for generated_file in generated_files: + SpacetimePlugin.print_log(generated_file) + + return generated_files + +func _generate_module_bindings(module_name: String, json_string: String) -> Array[String]: + var json = JSON.parse_string(json_string) + var schema := SpacetimeSchemaParser.parse_schema(json, module_name) + if schema.is_empty(): + SpacetimePlugin.print_err("Schema parsing failed for module: %s. Aborting codegen for this module." % module_name) + return [] + + for folder in REQUIRED_FOLDERS_IN_CODEGEN_FOLDER: + var folder_path := "%s/%s" % [_schema_path, folder] + if not DirAccess.dir_exists_absolute(folder_path): + DirAccess.make_dir_recursive_absolute(folder_path) + + var debug_dir_path := "%s/%s" % [SpacetimePlugin.BINDINGS_PATH, "codegen_debug"] + if not DirAccess.dir_exists_absolute(debug_dir_path): + DirAccess.make_dir_recursive_absolute(debug_dir_path) + + var file = FileAccess.open("%s/readme.txt" % [debug_dir_path], FileAccess.WRITE) + file.store_string("You can delete this directory and files. It's only used for codegen debugging.") + file = FileAccess.open("%s/schema_%s.json" % [debug_dir_path, module_name], FileAccess.WRITE) + file.store_string(JSON.stringify(schema.to_dictionary(), "\t", false)) + + var generated_files := _generate_gdscript_from_schema(schema) + return generated_files + +func _generate_gdscript_from_schema(schema: SpacetimeParsedSchema) -> Array[String]: + var generated_files: Array[String] = [] + + for type_def in schema.types: + if type_def.has("gd_native"): continue + + var content: String + if type_def.has("struct"): + var generated_table_names: Array[String] + if type_def.has("table_names"): + if not type_def.has("primary_key_name"): continue + if _config.hide_private_tables and not type_def.get("is_public", []).has(true): + SpacetimePlugin.print_log("Skipping private table struct %s" % type_def.get("name", "")) + continue + var table_names_arr: Array = type_def.get("table_names", []) + for i in table_names_arr.size(): + var tbl_name: String = table_names_arr[i] + if _config.hide_private_tables and not type_def.get("is_public", [])[i]: + SpacetimePlugin.print_log("Skipping private table %s" % tbl_name) + continue + generated_table_names.append(tbl_name) + + content = _generate_struct_gdscript(schema, type_def, generated_table_names) + elif type_def.has("enum"): + if not type_def.get("is_sum_type"): continue + content = _generate_enum_gdscript(schema, type_def) + + var output_file_name := "%s_%s.gd" % \ + [schema.module.to_snake_case(), type_def.get("name", "").to_snake_case()] + var folder_path := "%s/types" % _schema_path + var output_file_path := "%s/%s" % [folder_path, output_file_name] + if not DirAccess.dir_exists_absolute(folder_path): + DirAccess.make_dir_recursive_absolute(folder_path) + + var file := FileAccess.open(output_file_path, FileAccess.WRITE) + if file: + file.store_string(content) + file.close() + generated_files.append(output_file_path) + + for table_def in schema.tables: + var table_name = table_def.get("name", null) + if table_name == null: continue + if _config.hide_private_tables and not table_def.get("is_public", true): + SpacetimePlugin.print_log("Skipping private table: %s" % table_name) + continue + + var unique_indexes = table_def.get("unique_indexes", []) + for unique_index in unique_indexes: + var content := _generate_table_unique_index_gdscript(schema, unique_index, table_def) + + var output_file_name := "%s_%s_%s_unique_index.gd" % \ + [schema.module.to_snake_case(), table_name.to_snake_case(), unique_index.get("name", "").to_snake_case()] + var folder_path := "%s/tables" % _schema_path + var output_file_path := "%s/%s" % [folder_path, output_file_name] + if not DirAccess.dir_exists_absolute(folder_path): + DirAccess.make_dir_recursive_absolute(folder_path) + + var file := FileAccess.open(output_file_path, FileAccess.WRITE) + if file: + file.store_string(content) + file.close() + generated_files.append(output_file_path) + + var content := _generate_table_gdscript(schema, table_def) + + var output_file_name := "%s_%s_table.gd" % \ + [schema.module.to_snake_case(), table_name.to_snake_case()] + var folder_path := "%s/tables" % _schema_path + var output_file_path := "%s/%s" % [folder_path, output_file_name] + if not DirAccess.dir_exists_absolute(folder_path): + DirAccess.make_dir_recursive_absolute(folder_path) + + var file := FileAccess.open(output_file_path, FileAccess.WRITE) + if file: + file.store_string(content) + file.close() + generated_files.append(output_file_path) + + var module_content := _generate_module_client_gdscript(schema) + var output_file_name_module := "module_%s_client.gd" % schema.module.to_snake_case() + var output_file_path_module := "%s/%s" % [_schema_path, output_file_name_module] + var file_module := FileAccess.open(output_file_path_module, FileAccess.WRITE) + if file_module: + file_module.store_string(module_content) + file_module.close() + generated_files.append(output_file_path_module) + + var db_content := _generate_db_gdscript(schema) + var db_output_file_name := "module_%s_db.gd" % schema.module.to_snake_case() + var db_output_file_path := "%s/%s" % [_schema_path, db_output_file_name] + var db_file := FileAccess.open(db_output_file_path, FileAccess.WRITE) + if db_file: + db_file.store_string(db_content) + db_file.close() + generated_files.append(db_output_file_path) + + var reducers_content := _generate_reducers_gdscript(schema) + var output_file_name_reducers := "module_%s_reducers.gd" % schema.module.to_snake_case() + var output_file_path_reducers := "%s/%s" % [_schema_path, output_file_name_reducers] + var file_reducers := FileAccess.open(output_file_path_reducers, FileAccess.WRITE) + if file_reducers: + file_reducers.store_string(reducers_content) + file_reducers.close() + generated_files.append(output_file_path_reducers) + + var types_content := _generate_types_gdscript(schema) + var output_file_name_types := "module_%s_types.gd" % schema.module.to_snake_case() + var output_file_path_types := "%s/%s" % [_schema_path, output_file_name_types] + var file_types := FileAccess.open(output_file_path_types, FileAccess.WRITE) + if file_types: + file_types.store_string(types_content) + file_types.close() + generated_files.append(output_file_path_types) + + return generated_files + +func _generate_table_unique_index_gdscript(schema: SpacetimeParsedSchema, unique_index_def: Dictionary, table_def: Dictionary) -> String: + var table_name: String = table_def.get("name", "") + var field_name: String = unique_index_def.get("name", "") + var original_field_type: String = unique_index_def.get("type", "Variant") + var field_type: String = schema.type_map.get(original_field_type, "Variant") + var type_def: Dictionary = schema.types[table_def.get("type_idx")] if table_def.has("type_idx") else {} + var original_type_name: String = type_def.get("name", "Variant") + var type_name: String = schema.type_map.get(original_type_name, "Variant") + + var _class_name := "%s%s%sUniqueIndex" % \ + [schema.module.to_pascal_case(), table_name.to_pascal_case(), field_name.to_pascal_case()] + var content: String = AUTOGENERATED_COMMENT + \ + "class_name %s extends _ModuleTableUniqueIndex\n\n" % _class_name + \ + "var _cache: Dictionary[%s, %s] = {}\n\n" % [field_type, type_name] + \ + "func _init(p_local_db: LocalDatabase) -> void:\n" + \ + "\tset_meta(\"table_name\", \"%s\")\n" % table_name + \ + "\tset_meta(\"field_name\", \"%s\")\n" % field_name + \ + "\t_connect_cache_to_db(_cache, p_local_db)\n\n" + \ + "func find(col_val: %s) -> %s:\n" % [field_type, type_name] + \ + "\treturn _cache.get(col_val, null)\n" + + return content + +func _generate_table_gdscript(schema: SpacetimeParsedSchema, table_def: Dictionary) -> String: + var table_name: String = table_def.get("name", "") + var type_def: Dictionary = schema.types[table_def.get("type_idx")] if table_def.has("type_idx") else {} + var original_type_name: String = type_def.get("name", "Variant") + var type_name: String = schema.type_map.get(original_type_name, "Variant") + var unique_index_fields: Dictionary[String, String] = {} + for unique_index_def in table_def.get("unique_indexes", []): + var field_name: String = unique_index_def.get("name", "") + var unique_index_class_name := "%s%s%sUniqueIndex" % \ + [schema.module.to_pascal_case(), table_name.to_pascal_case(), field_name.to_pascal_case()] + unique_index_fields[field_name] = unique_index_class_name + + var _class_name := "%s%sTable" % [schema.module.to_pascal_case(), table_name.to_pascal_case()] + var content: String = AUTOGENERATED_COMMENT + \ + "class_name %s extends _ModuleTable\n\n" % _class_name + for field_name in unique_index_fields: + var unique_index_class_name := unique_index_fields[field_name] + content += "var %s: %s\n" % [field_name, unique_index_class_name] + + content += "\nfunc _init(p_local_db: LocalDatabase) -> void:\n" + \ + "\tsuper(p_local_db)\n" + \ + "\tset_meta(\"table_name\", \"%s\")\n" % table_name + + for field_name in unique_index_fields: + var unique_index_class_name := unique_index_fields[field_name] + content += "\t%s = %s.new(p_local_db)\n" % [field_name, unique_index_class_name] + + content += "\nfunc iter() -> Array[%s]:\n" % type_name + \ + "\tvar rows: Array = super()\n" + \ + "\tvar typed_array: Array[%s] = []\n" % type_name + \ + "\ttyped_array.assign(rows)\n" + \ + "\treturn typed_array\n" + + return content + +func _generate_struct_gdscript(schema: SpacetimeParsedSchema, type_def: Dictionary, table_names: Array[String]) -> String: + var struct_name: String = type_def.get("name", "") + var fields: Array = type_def.get("struct", []) + var meta_data: Array = [] + var table_name: String = type_def.get("table_name", "") + var _class_name: String = schema.module.to_pascal_case() + struct_name.to_pascal_case() + var _extends_class = "Resource" + if table_name: + _extends_class = "_ModuleTableType" + var primary_key_name: String = type_def.get("primary_key_name", "") + if primary_key_name: + meta_data.append("set_meta('primary_key', '%s')" % primary_key_name) + + var content: String = AUTOGENERATED_COMMENT + \ + "@tool\n" + \ + "class_name %s extends %s\n\n" % [_class_name, _extends_class] + + if table_names.size() > 0: + content += "const module_name := \"%s\"\n" % schema.module + \ + "const table_names: Array[String] = [%s]\n\n" % \ + [", ".join(table_names.map(func(x): return "'%s'" % x))] + + var class_fields: Array = [] + var create_func_documentation_comment: String + # format for create_func_documentation_comment + var format_cfdc: Callable = func(index, field_name, nested_type) -> String: + return "## %d. %s: %s[br]\n" % [index, field_name, " of ".join(nested_type)] + + for i in fields.size(): + var field: Dictionary = fields[i] + var field_name: String = field.get("name", "") + var original_inner_type_name: String = field.get("type", "Variant") + var gd_field_type: String + var bsatn_meta_type_string: String + var documentation_comment: String + var nested_type: Array = field.get("nested_type", []).duplicate() + nested_type.append(schema.type_map.get(original_inner_type_name, "Variant")) + + if field.has("is_option"): + gd_field_type = OPTION_CLASS_NAME + documentation_comment = "## %s" % [" of ".join(nested_type)] + create_func_documentation_comment += format_cfdc.call(i, field_name, nested_type) + if field.has("is_array_inside_option"): + bsatn_meta_type_string = "vec_%s" % schema.meta_type_map.get(original_inner_type_name, "Variant") + else: + bsatn_meta_type_string = schema.meta_type_map.get(original_inner_type_name, original_inner_type_name) + elif field.has("is_array"): + var element_gd_type = schema.type_map.get(original_inner_type_name, "Variant") + if field.has("is_option_inside_array"): + element_gd_type = OPTION_CLASS_NAME + documentation_comment = "## %s" % [" of ".join(nested_type)] + create_func_documentation_comment += format_cfdc.call(i, field_name, nested_type) + gd_field_type = "Array[%s]" % element_gd_type + var inner_meta = schema.meta_type_map.get(original_inner_type_name, original_inner_type_name) + bsatn_meta_type_string = "%s" % inner_meta + else: + gd_field_type = schema.type_map.get(original_inner_type_name, "Variant") + bsatn_meta_type_string = schema.meta_type_map.get(original_inner_type_name, original_inner_type_name) + create_func_documentation_comment += format_cfdc.call(i, field_name, nested_type) + + var add_meta_for_field = false + if field.has("is_option") or field.has("is_array"): + add_meta_for_field = true + elif not SpacetimeSchemaParser.GDNATIVE_TYPES.has(original_inner_type_name): + add_meta_for_field = true + elif schema.meta_type_map.has(original_inner_type_name): + add_meta_for_field = true + + if add_meta_for_field and not bsatn_meta_type_string.is_empty(): + meta_data.append("set_meta('bsatn_type_%s', &'%s')" % [field_name, bsatn_meta_type_string]) + + content += "@export var %s: %s %s\n" % [field_name, gd_field_type, documentation_comment] + class_fields.append([field_name, gd_field_type]) + + content += "\nfunc _init() -> void:\n" + for m in meta_data: + content += "\t%s\n" % m + if meta_data.size() == 0: + content += "\tpass\n" + + content += "\n" + create_func_documentation_comment + content += "static func create(%s) -> %s:\n" % \ + [", ".join(class_fields.map(func(x): return "p_%s: %s" % [x[0], x[1]])), _class_name] + \ + "\tvar result = %s.new()\n" % [_class_name] + for field_data in class_fields: + var f_name: String = field_data[0] + content += "\tresult.%s = p_%s\n" % [f_name, f_name] + content += "\treturn result\n" + return content + +func _generate_enum_gdscript(schema: SpacetimeParsedSchema, type_def: Dictionary) -> String: + var enum_name: String = type_def.get("name", "") + var variants: Array = type_def.get("enum", [""]) + var variant_names: String = "\n".join(variants.map(func(x): + return "\t%s," % [x.get("name", "")])) + + var _class_name: String = schema.module.to_pascal_case() + enum_name.to_pascal_case() + var content: String = AUTOGENERATED_COMMENT + \ + "class_name %s extends RustEnum\n\n" % _class_name + \ + "enum {\n%s\n}\n\n" % variant_names + \ + "func _init():\n" + \ + "\tset_meta('enum_options', [%s])\n" % \ + [", ".join(variants.map(func(x): + var type = x.get("type", "") + var rust_type = schema.meta_type_map.get(type, type) + if x.has("is_array_inside_option"): + rust_type = "opt_vec_%s" % rust_type + elif x.has("is_option_inside_array"): + rust_type = "vec_opt_%s" % rust_type + elif x.has("is_array"): + rust_type = "vec_%s" % rust_type + elif x.has("is_option"): + rust_type = "opt_%s" % rust_type + return "&'%s'" % rust_type if not rust_type.is_empty() else "&''" + ))] + \ + "\tset_meta('bsatn_enum_type', &'%s')\n" % _class_name + \ + "\n" + \ + "static func parse_enum_name(i: int) -> String:\n" + \ + "\tmatch i:\n" + for i in range(variants.size()): + content += "\t\t%d: return &'%s'\n" % [i, variants[i].get("name", "")] + content += "\t\t_:\n" + \ + "\t\t\tprinterr(\"Enum does not have value for %d. This is out of bounds.\" % i)\n" + \ + "\t\t\treturn &'Unknown'\n\n" + var get_funcs: Array[String] + var create_funcs: Array[String] + for v_schema in variants: + var variant_name: String = v_schema.get("name", "") + var variant_gd_type: String = schema.type_map.get(v_schema.get("type", ""), "Variant") + var nested_type: Array = v_schema.get("nested_type", []).duplicate() + nested_type.append(variant_gd_type) + if v_schema.has("is_option"): + variant_gd_type = OPTION_CLASS_NAME + elif v_schema.has("is_array"): + if v_schema.has("is_option_inside_array"): + variant_gd_type = OPTION_CLASS_NAME + variant_gd_type = "Array[%s]" % variant_gd_type + + if v_schema.has("type"): + if v_schema.has("is_option") or v_schema.has("is_option_inside_array"): + get_funcs.append("## Returns: %s\n" % \ + [" of ".join(nested_type)]) + create_funcs.append("## 0. data: %s\n" % \ + [" of ".join(nested_type)]) + get_funcs.append("func get_%s() -> %s:\n" % [variant_name.to_snake_case(), variant_gd_type] + \ + "\treturn data\n\n") + create_funcs.append("static func create_%s(_data: %s) -> %s:\n" % [variant_name.to_snake_case(), variant_gd_type, _class_name] + \ + "\treturn create(%s, _data)\n\n" % [variant_name]) + else: + create_funcs.append("static func create_%s() -> %s:\n" % [variant_name.to_snake_case(), _class_name] + \ + "\treturn create(%s)\n\n" % [variant_name]) + + content += "".join(get_funcs) + content += "static func create(p_type: int, p_data: Variant = null) -> %s:\n" % _class_name + \ + "\tvar result = %s.new()\n" % _class_name + \ + "\tresult.value = p_type\n" + \ + "\tresult.data = p_data\n" + \ + "\treturn result\n\n" + content += "".join(create_funcs) + + # Clean up trailing newlines + while content.ends_with("\n"): + content = content.left(-1) + + return content + +func _generate_module_client_gdscript(schema: SpacetimeParsedSchema) -> String: + var content := AUTOGENERATED_COMMENT + \ + "@tool\n" + \ + "class_name %sModuleClient extends SpacetimeDBClient\n\n" % schema.module.to_pascal_case() + \ + "const Types = preload('%s/module_%s_types.gd')\n\n" % [_schema_path, schema.module.to_snake_case()] + + var types_part := _generate_types_gdscript(schema, true) + if not types_part.is_empty(): + content += types_part + "\n" + + content += "var reducers: %sModuleReducers\n" % schema.module.to_pascal_case() + \ + "var db: %sModuleDb\n\n" % schema.module.to_pascal_case() + \ + "func _init() -> void:\n" + \ + "\tset_meta(\"module_name\", \"%s\")\n" % schema.module + \ + "\tname = \"%sModule\"\n" % schema.module.to_pascal_case() + \ + "\treducers = preload('%s/module_%s_reducers.gd').new(self)\n" % [_schema_path, schema.module.to_snake_case()] + \ + "\nfunc _init_db(p_local_db: LocalDatabase) -> void:\n" + \ + "\tdb = preload('%s/module_%s_db.gd').new(p_local_db)\n" % [_schema_path, schema.module.to_snake_case()] + + return content + +func _generate_db_gdscript(schema: SpacetimeParsedSchema) -> String: + var tables: Dictionary[String, String] = {} + var table_names: Array[String] = [] + for table_def in schema.tables: + var table_name = table_def.get("name", null) + if table_name == null: continue + if _config.hide_private_tables and not table_def.get("is_public", true): continue + tables[table_name] = "%s%sTable" % [schema.module.to_pascal_case(), table_name.to_pascal_case()] + table_names.append("\"%s\"" % table_name) + + var content := AUTOGENERATED_COMMENT + \ + "class_name %sModuleDb extends RefCounted\n\n" % schema.module.to_pascal_case() + \ + "const table_names := [%s]\n\n" % ", ".join(table_names) + for table_name in tables: + var table_type := tables[table_name] + content += "var %s: %s\n" % [table_name.to_snake_case(), table_type] + + content += "\nfunc _init(p_local_db: LocalDatabase) -> void:\n" + for table_name in tables: + content += "\t%s = preload('%s/tables/%s_%s_table.gd').new(p_local_db)\n" % \ + [table_name.to_snake_case(), _schema_path, schema.module.to_snake_case(), table_name.to_snake_case()] + + return content + +func _generate_types_gdscript(schema: SpacetimeParsedSchema, const_pointer: bool = false) -> String: + var content := "" if const_pointer else AUTOGENERATED_COMMENT + "\n" + for _type_def in schema.types: + if _type_def.has("gd_native"): continue + var type_name: String = _type_def.get("name", "") + var file_suffix := "" + if _type_def.has("table_name"): + if not _type_def.has("primary_key_name"): + continue + if _config.hide_private_tables and not _type_def.get("is_public", []).has(true): continue + + if const_pointer: + content += "const %s = Types.%s\n" % \ + [type_name.to_pascal_case(), type_name.to_pascal_case()] + else: + if _type_def.has("is_sum_type") and not _type_def.get("is_sum_type"): + content += "enum %s {\n" % type_name.to_pascal_case() + var variants_str := "" + for variant in _type_def.get("enum", []): + var variant_name: String = variant.get("name", "") + variants_str += "\t%s,\n" % variant_name.to_pascal_case() + if not variants_str.is_empty(): + variants_str = variants_str.left(-2) + content += variants_str + content += "\n}\n" + else: + content += "const %s = preload('%s/types/%s_%s.gd')\n" % \ + [type_name.to_pascal_case(), _schema_path, + schema.module.to_snake_case(), type_name.to_snake_case()] + return content + +func _generate_reducers_gdscript(schema: SpacetimeParsedSchema) -> String: + var content := AUTOGENERATED_COMMENT + \ + "class_name %sModuleReducers extends RefCounted\n\n" % schema.module.to_pascal_case() + \ + "var _client: SpacetimeDBClient\n\n" + \ + "func _init(p_client: SpacetimeDBClient) -> void:\n" + \ + "\t_client = p_client\n" + + for reducer in schema.reducers: + if reducer.get("is_scheduled", false) and _config.hide_scheduled_reducers: continue + + var params_str_parts: Array[String] = [] + var description_comment: Array = [] + var reducer_params = reducer.get("params", []) + for i in reducer_params.size(): + var param = reducer_params[i] + var param_name: String = param.get("name", "") + var gd_param_type: String + var original_inner_type_name: String = param.get("type", "Variant") + var nested_type: Array = param.get("nested_type", []).duplicate() + nested_type.append(schema.type_map.get(original_inner_type_name, "Variant")) + description_comment.append("## %d. %s: %s [br]" % [i, param_name, " of ".join(nested_type)]) + if param.has("is_option"): + gd_param_type = OPTION_CLASS_NAME + elif param.has("is_array"): + var element_gd_type = schema.type_map.get(original_inner_type_name, "Variant") + if param.has("is_option_inside_array"): + element_gd_type = OPTION_CLASS_NAME + gd_param_type = "Array[%s]" % element_gd_type + else: + gd_param_type = schema.type_map.get(original_inner_type_name, "Variant") + params_str_parts.append("%s: %s" % [param_name, gd_param_type]) + + var params_str: String + if params_str_parts.is_empty(): + params_str = "cb: Callable = func(_t: TransactionUpdateMessage): pass" + else: + params_str = ", ".join(params_str_parts) + ", cb: Callable = func(_t: TransactionUpdateMessage): pass" + + var param_names_list = reducer.get("params", []).map(func(x): return x.get("name", "")) + var param_names_str = "" + if not param_names_list.is_empty(): + param_names_str = ", ".join(param_names_list) + + var param_bsatn_types_list = (reducer.get("params", []) as Array).map(func(x): + var original_inner_type_name_bsatn: String = x.get("type", "Variant") + var bsatn_param_type: String + + if x.has("is_option"): + var inner_meta_for_option: String + if x.has("is_array_inside_option"): + inner_meta_for_option = "vec_%s" % schema.meta_type_map.get(original_inner_type_name_bsatn, original_inner_type_name_bsatn) + else: + inner_meta_for_option = schema.meta_type_map.get(original_inner_type_name_bsatn, original_inner_type_name_bsatn) + bsatn_param_type = "%s" % inner_meta_for_option + else: + bsatn_param_type = schema.meta_type_map.get(original_inner_type_name_bsatn, original_inner_type_name_bsatn) + + if bsatn_param_type.is_empty(): return "''" + return "&'%s'" % bsatn_param_type + ) + var param_bsatn_types_str := "" + if not param_bsatn_types_list.is_empty(): + param_bsatn_types_str = ", ".join(param_bsatn_types_list) + + content += "\n".join(description_comment) + "\n" + var reducer_name: String = reducer.get("name", "") + content += "func %s(%s) -> Error:\n" % [reducer_name, params_str] + \ + "\tvar __handle__ := _client.call_reducer('%s', [%s], [%s])\n" % \ + [reducer_name, param_names_str, param_bsatn_types_str] + \ + "\tif __handle__.error: return __handle__.error\n" + \ + "\tvar __result__ = await __handle__.wait_for_response()\n" + \ + "\tcb.call(__result__)\n" + \ + "\treturn OK\n\n" + + if not content.is_empty(): + content = content.left(-2) + return content + +func _generate_autoload_gdscript(modules: Array[String]) -> String: + var content := AUTOGENERATED_COMMENT + \ + "class_name SpacetimeAutoload extends Node\n\n" + for module_name in modules: + content += "var %s: %sModuleClient\n" % \ + [module_name.to_pascal_case(), module_name.to_pascal_case()] + + content += "\nfunc _init() -> void:\n" + for module_name in modules: + content += "\t%s = preload('%s/module_%s_client.gd').new()\n" % \ + [module_name.to_pascal_case(), _schema_path, module_name.to_snake_case()] + \ + "\tadd_child(%s)\n" % [module_name.to_pascal_case()] + + return content diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen.gd.uid new file mode 100644 index 00000000000..5a5f20d9caf --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen.gd.uid @@ -0,0 +1 @@ +uid://4536ajssqru1 diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen_config.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen_config.gd new file mode 100644 index 00000000000..1e0e5f9ca75 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen_config.gd @@ -0,0 +1,52 @@ +class_name SpacetimeCodegenConfig extends RefCounted + +const CONFIG_VERSION := 2 +const DEFAULT_CONFIG := { + "config_version": CONFIG_VERSION, + "hide_scheduled_reducers": true, + "hide_private_tables": true +} + +var hide_private_tables := DEFAULT_CONFIG.hide_private_tables +var hide_scheduled_reducers := DEFAULT_CONFIG.hide_scheduled_reducers + +var _codegen_config_path := SpacetimePlugin.BINDINGS_PATH + "/codegen_config.json" + +func _init() -> void: + load_config() + +func load_config() -> void: + var file: FileAccess + if not FileAccess.file_exists(_codegen_config_path): + file = FileAccess.open(_codegen_config_path, FileAccess.WRITE_READ) + file.store_string(JSON.stringify(DEFAULT_CONFIG, "\t", false)) + else: + file = FileAccess.open(_codegen_config_path, FileAccess.READ) + + var config: Dictionary = JSON.parse_string(file.get_as_text()) as Dictionary + file.close() + + var version: int = config.get("config_version", -1) as int + + if version < CONFIG_VERSION: + config = DEFAULT_CONFIG.duplicate() if version == -1 else _migrate_config(config, version) + save_config(config) + + hide_scheduled_reducers = config.get("hide_scheduled_reducers", hide_scheduled_reducers) as bool + hide_private_tables = config.get("hide_private_tables", hide_private_tables) as bool + +func save_config(config: Dictionary) -> void: + var file = FileAccess.open(_codegen_config_path, FileAccess.WRITE) + file.store_string(JSON.stringify(config, "\t", false)) + file.close() + +func _migrate_config(config: Dictionary, version: int) -> Dictionary: + if version == 1: + config = { + "config_version": 2, + "hide_scheduled_reducers": config.get("hide_scheduled_reducers", hide_scheduled_reducers), + "hide_private_tables": config.get("hide_private_tables", hide_private_tables) + } + + return config + diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen_config.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen_config.gd.uid new file mode 100644 index 00000000000..584f9ec8bee --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/codegen_config.gd.uid @@ -0,0 +1 @@ +uid://ddsksyghkkq1p diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/parsed_schema.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/parsed_schema.gd new file mode 100644 index 00000000000..0ea6a13c992 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/parsed_schema.gd @@ -0,0 +1,23 @@ +class_name SpacetimeParsedSchema extends Resource + +var module: String = "" +var types: Array[Dictionary] = [] +var reducers: Array[Dictionary] = [] +var tables: Array[Dictionary] = [] +var type_map: Dictionary[String, String] = {} +var meta_type_map: Dictionary[String, String] = {} +var typespace: Array = [] + +func is_empty() -> bool: + return types.is_empty() and reducers.is_empty() + +func to_dictionary() -> Dictionary: + return { + "module": module, + "types": types, + "reducers": reducers, + "tables": tables, + "type_map": type_map, + "meta_type_map": meta_type_map, + "typespace": typespace + } diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/parsed_schema.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/parsed_schema.gd.uid new file mode 100644 index 00000000000..b56486cb6ce --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/parsed_schema.gd.uid @@ -0,0 +1 @@ +uid://dmmc3e5wbqu30 diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/schema_parser.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/schema_parser.gd new file mode 100644 index 00000000000..c3aaec51e23 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/schema_parser.gd @@ -0,0 +1,342 @@ +class_name SpacetimeSchemaParser + +const GDNATIVE_TYPES: Dictionary[String, String] = { + "I8": "int", + "I16": "int", + "I32": "int", + "I64": "int", + "U8": "int", + "U16": "int", + "U32": "int", + "U64": "int", + "F32": "float", + "F64": "float", + "String": "String", + "Vector4": "Vector4", + "Vector4I": "Vector4i", + "Vector3": "Vector3", + "Vector3I": "Vector3i", + "Vector2": "Vector2", + "Vector2I": "Vector2i", + "Plane": "Plane", + "Color": "Color", + "Quaternion": "Quaternion", + "Bool": "bool", + "Nil": "null", # For Option<()> +} + +const DEFAULT_TYPE_MAP: Dictionary[String, String] = { + "__identity__": "PackedByteArray", + "__connection_id__": "PackedByteArray", + "__timestamp_micros_since_unix_epoch__": "int", + "__time_duration_micros__": "int", +} + +const DEFAULT_META_TYPE_MAP: Dictionary[String, String] = { + "I8": "i8", + "I16": "i16", + "I32": "i32", + "I64": "i64", + "U8": "u8", + "U16": "u16", + "U32": "u32", + "U64": "u64", + "F32": "f32", + "F64": "f64", + "String": "string", # For BSATN, e.g. option_string or vec_String (if Option>) + "Bool": "bool", # For BSATN, e.g. option_bool + "Nil": "nil", # For BSATN Option<()> + "__identity__": "identity", + "__connection_id__": "connection_id", + "__timestamp_micros_since_unix_epoch__": "i64", + "__time_duration_micros__": "i64", +} + +static func parse_schema(schema: Dictionary, module_name: String) -> SpacetimeParsedSchema: + var type_map: Dictionary[String, String] = DEFAULT_TYPE_MAP.duplicate() as Dictionary[String, String] + type_map.merge(GDNATIVE_TYPES) + var meta_type_map = DEFAULT_META_TYPE_MAP.duplicate() + + var schema_tables: Array = schema.get("tables", []) + var schema_types_raw: Array = schema.get("types", []) + schema_types_raw.sort_custom(func(a, b): return a.get("ty", -1) < b.get("ty", -1)) + var schema_reducers: Array = schema.get("reducers", []) + var typespace: Array = schema.get("typespace", {}).get("types", []) + + var parsed_schema := SpacetimeParsedSchema.new() + parsed_schema.module = module_name.to_pascal_case() + + var parsed_types_list: Array[Dictionary] = [] + for type_info in schema_types_raw: + var type_name: String = type_info.get("name", {}).get("name", null) + if not type_name: + SpacetimePlugin.print_err("Invalid schema: Type name not found for type: %s" % type_info) + return parsed_schema + var type_data := {"name": type_name} + if GDNATIVE_TYPES.has(type_name): + type_data["gd_native"] = true + + var ty_idx := int(type_info.get("ty", -1)) + if ty_idx == -1: + SpacetimePlugin.print_err("Invalid schema: Type 'ty' not found for type: %s" % type_info) + return parsed_schema + if ty_idx >= typespace.size(): + SpacetimePlugin.print_err("Invalid schema: Type index %d out of bounds for typespace (size %d) for type %s" % [ty_idx, typespace.size(), type_name]) + return parsed_schema + + var current_type_definition = typespace[ty_idx] + var struct_def: Dictionary = current_type_definition.get("Product", {}) + var sum_type_def: Dictionary = current_type_definition.get("Sum", {}) + + if struct_def: + var struct_elements: Array[Dictionary] = [] + for el in struct_def.get("elements", []): + var data = { + "name": el.get("name", {}).get("some", null), + } + var type = _parse_field_type(el.get("algebraic_type", {}), data, schema_types_raw) + if not type.is_empty(): + data["type"] = type + struct_elements.append(data) + + if not type_data.has("gd_native"): + type_map[type_name] = module_name.to_pascal_case() + type_name.to_pascal_case() + meta_type_map[type_name] = module_name.to_pascal_case() + type_name.to_pascal_case() + type_data["struct"] = struct_elements + parsed_types_list.append(type_data) + elif sum_type_def: + var parsed_variants := [] + type_data["is_sum_type"] = _is_sum_type(sum_type_def) + for v in sum_type_def.get("variants", []): + var variant_data := { "name": v.get("name",{}).get("some", null) } + var type = _parse_sum_type(v.get("algebraic_type", {}), variant_data, schema_types_raw) + if not type.is_empty(): + variant_data["type"] = type + parsed_variants.append(variant_data) + type_data["enum"] = parsed_variants + parsed_types_list.append(type_data) + + if not type_data.get("is_sum_type"): + meta_type_map[type_name] = "u8" + type_map[type_name] = "%sModuleClient.Types.%s" % [module_name.to_pascal_case(), type_name.to_pascal_case()] + else: + type_map[type_name] = module_name.to_pascal_case() + type_name.to_pascal_case() + meta_type_map[type_name] = module_name.to_pascal_case() + type_name.to_pascal_case() + else: + if not type_data.has("gd_native"): + if type_map.has(type_name) and not GDNATIVE_TYPES.has(type_name): + type_data["struct"] = [] + parsed_types_list.append(type_data) + else: + SpacetimePlugin.print_log("Type '%s' has no Product/Sum definition in typespace and is not GDNative. Skipping." % type_name) + + var parsed_tables_list: Array[Dictionary] = [] + var scheduled_reducers: Array[String] = [] + for table_info in schema_tables: + var table_name_str: String = table_info.get("name", null) + var ref_idx_raw = table_info.get("product_type_ref", null) + if ref_idx_raw == null or table_name_str == null: continue + var ref_idx = int(ref_idx_raw) + + var target_type_def = null + var target_type_idx = 0 + var original_type_name_for_table = "UNKNOWN_TYPE_FOR_TABLE" + if ref_idx < schema_types_raw.size(): + original_type_name_for_table = schema_types_raw[ref_idx].get("name", {}).get("name") + for pt in parsed_types_list: + if pt.name == original_type_name_for_table: + target_type_def = pt + break + target_type_idx += 1 + + if target_type_def == null or not target_type_def.has("struct"): + SpacetimePlugin.print_err("Table '%s' refers to an invalid or non-struct type (index %s in original schema, name %s)." % [table_name_str, str(ref_idx), original_type_name_for_table if original_type_name_for_table else "N/A"]) + continue + + var table_data := { + "name": table_name_str, + "type_idx": target_type_idx + } + + if not target_type_def.has("table_names"): + target_type_def.table_names = [] + target_type_def.table_names.append(table_name_str) + target_type_def.table_name = table_name_str + + var primary_key_indices: Array = table_info.get("primary_key", []) + if primary_key_indices.size() == 1: + var pk_field_idx = int(primary_key_indices[0]) + if pk_field_idx < target_type_def.struct.size(): + var pk_field_name: String = target_type_def.struct[pk_field_idx].name + table_data.primary_key = pk_field_idx + table_data.primary_key_name = pk_field_name + target_type_def.primary_key = pk_field_idx + target_type_def.primary_key_name = pk_field_name + else: + SpacetimePlugin.print_err("Primary key index %d out of bounds for table %s (struct size %d)" % [pk_field_idx, table_name_str, target_type_def.struct.size()]) + + var parsed_unique_indexes: Array[Dictionary] = [] + var constraints_def = table_info.get("constraints", []) + for constraint_def in constraints_def: + var constraint_name_str: String = constraint_def.get("name", {}).get("some", null) + var column_indices: Array = constraint_def.get("data", {}).get("Unique", {}).get("columns", []) + if column_indices.size() != 1 or constraint_name_str == null: continue + + var unique_field_idx = int(column_indices[0]) + if unique_field_idx < target_type_def.struct.size(): + var unique_index: Dictionary = target_type_def.struct[unique_field_idx].duplicate() + unique_index.constraint_name = constraint_name_str + parsed_unique_indexes.append(unique_index) + else: + SpacetimePlugin.print_err("Unique field index %d out of bounds for table %s (struct size %d)" % [unique_field_idx, table_name_str, target_type_def.struct.size()]) + + table_data.unique_indexes = parsed_unique_indexes + + var is_public = true + if not target_type_def.has("is_public"): + target_type_def.is_public = [] + if table_info.get("table_access", {}).has("Private"): + is_public = false + + table_data.is_public = is_public + target_type_def.is_public.append(is_public) + + if table_info.get("schedule", {}).has("some"): + var schedule = table_info.get("schedule", {}).some + table_data.schedule = schedule + target_type_def.schedule = schedule + scheduled_reducers.append(schedule.reducer_name) + parsed_tables_list.append(table_data) + + var parsed_reducers_list: Array[Dictionary] = [] + for reducer_info in schema_reducers: + var lifecycle = reducer_info.get("lifecycle", {}).get("some", null) + if lifecycle: continue + var r_name = reducer_info.get("name", null) + if r_name == null: + SpacetimePlugin.print_err("Reducer found with no name: %s" % [reducer_info]) + continue + var reducer_data: Dictionary = {"name": r_name} + + var reducer_raw_params = reducer_info.get("params", {}).get("elements", []) + var reducer_params = [] + for raw_param in reducer_raw_params: + var data = {"name": raw_param.get("name", {}).get("some", null)} + var type = _parse_field_type(raw_param.get("algebraic_type", {}), data, schema_types_raw) + data["type"] = type + reducer_params.append(data) + reducer_data["params"] = reducer_params + + if r_name in scheduled_reducers: + reducer_data["is_scheduled"] = true + parsed_reducers_list.append(reducer_data) + + parsed_schema.types = parsed_types_list + parsed_schema.reducers = parsed_reducers_list + parsed_schema.tables = parsed_tables_list + parsed_schema.type_map = type_map + parsed_schema.meta_type_map = meta_type_map + parsed_schema.typespace = typespace + return parsed_schema + +static func _is_sum_type(sum_def) -> bool: + var variants = sum_def.get("variants", []) + for variant in variants: + var type = variant.get("algebraic_type", {}) + if not type.has("Product"): + return true + var elements = type.Product.get("elements", []) + if elements.size() > 0: + return true + return false + +static func _is_sum_option(sum_def) -> bool: + var variants = sum_def.get("variants", []) + if variants.size() != 2: + return false + + var name1 = variants[0].get("name", {}).get("some", "") + var name2 = variants[1].get("name", {}).get("some", "") + + var found_some = false + var found_none = false + var none_is_unit = false + + for v_idx in range(variants.size()): + var v_name = variants[v_idx].get("name", {}).get("some", "") + if v_name == "some": + found_some = true + elif v_name == "none": + found_none = true + var none_variant_type = variants[v_idx].get("algebraic_type", {}) + if none_variant_type.has("Product") and none_variant_type.Product.get("elements", []).is_empty(): + none_is_unit = true + elif none_variant_type.is_empty(): + none_is_unit = true + + + return found_some and found_none and none_is_unit + +# Recursively parse a field type +static func _parse_field_type(field_type: Dictionary, data: Dictionary, schema_types: Array) -> String: + if field_type.has("Array"): + var nested_type = data.get("nested_type", []) + nested_type.append(&"Array") + data["nested_type"] = nested_type + if data.has("is_option"): + data["is_array_inside_option"] = true + else: + data["is_array"] = true + field_type = field_type.Array + return _parse_field_type(field_type, data, schema_types) + elif field_type.has("Product"): + return field_type.Product.get("elements", [])[0].get('name', {}).get('some', null) + elif field_type.has("Sum"): + if _is_sum_option(field_type.Sum): + var nested_type = data.get("nested_type", []) + nested_type.append(&"Option") + data["nested_type"] = nested_type + if data.has("is_array"): + data["is_option_inside_array"] = true + else: + data["is_option"] = true + field_type = field_type.Sum.variants[0].get('algebraic_type', {}) + return _parse_field_type(field_type, data, schema_types) + elif field_type.has("Ref"): + return schema_types[field_type.Ref].get("name", {}).get("name", null) + else: + return field_type.keys()[0] + +# Recursively parse a sum type +static func _parse_sum_type(variant_type: Dictionary, data: Dictionary, schema_types: Array) -> String: + if variant_type.has("Array"): + var nested_type = data.get("nested_type", []) + nested_type.append(&"Array") + data["nested_type"] = nested_type + if data.has("is_option"): + data["is_array_inside_option"] = true + else: + data["is_array"] = true + variant_type = variant_type.Array + return _parse_sum_type(variant_type, data, schema_types) + elif variant_type.has("Product"): + var variant_type_array = variant_type.Product.get("elements", []) + if variant_type_array.size() >= 1: + return variant_type_array[0].get('name', {}).get('some', null) + else: + return "" + elif variant_type.has("Sum"): + if _is_sum_option(variant_type.Sum): + var nested_type = data.get("nested_type", []) + nested_type.append(&"Option") + data["nested_type"] = nested_type + if data.has("is_array"): + data["is_option_inside_array"] = true + else: + data["is_option"] = true + variant_type = variant_type.Sum.variants[0].get('algebraic_type', {}) + return _parse_sum_type(variant_type, data, schema_types) + elif variant_type.has("Ref"): + return schema_types[variant_type.Ref].get("name", {}).get("name", null) + else: + return variant_type.keys()[0] diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/schema_parser.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/schema_parser.gd.uid new file mode 100644 index 00000000000..a866bec9cb3 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/codegen/schema_parser.gd.uid @@ -0,0 +1 @@ +uid://dr2bltrpnakim diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_deserializer.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_deserializer.gd new file mode 100644 index 00000000000..ac58eecfd56 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_deserializer.gd @@ -0,0 +1,909 @@ +class_name BSATNDeserializer extends RefCounted + +# --- Constants --- +const MAX_STRING_LEN := 4 * 1024 * 1024 # 4 MiB limit for strings +const MAX_VEC_LEN := 131072 # Limit for vector elements (used by read_vec_u8 and _read_array) +const MAX_BYTE_ARRAY_LEN := 16 * 1024 * 1024 # Limit for Vec style byte arrays +const IDENTITY_SIZE := 32 +const CONNECTION_ID_SIZE := 16 + +const COMPRESSION_NONE := 0x00 +const COMPRESSION_BROTLI := 0x01 +const COMPRESSION_GZIP := 0x02 + +# Row List Format Tags +const ROW_LIST_FIXED_SIZE := 0 +const ROW_LIST_ROW_OFFSETS := 1 + +# --- Properties --- +var _last_error: String = "" +var _deserialization_plan_cache: Dictionary = {} +var _pending_data := PackedByteArray() +var _schema: SpacetimeDBSchema +var debug_mode := false # Controls verbose debug printing + +# --- Initialization --- +func _init(p_schema: SpacetimeDBSchema, p_debug_mode: bool = false) -> void: + debug_mode = p_debug_mode + _schema = p_schema + +# --- Error Handling --- +func has_error() -> bool: return _last_error != "" +func get_last_error() -> String: var err := _last_error; _last_error = ""; return err +func clear_error() -> void: _last_error = "" +func _set_error(msg: String, position: int = -1) -> void: + if _last_error == "": # Prevent overwriting the first error + var pos_str := " (at approx. position %d)" % position if position >= 0 else "" + _last_error = "BSATNDeserializer Error: %s%s" % [msg, pos_str] + printerr(_last_error) # Always print errors +func _check_read(spb: StreamPeerBuffer, bytes_needed: int) -> bool: + if has_error(): return false + if spb.get_position() + bytes_needed > spb.get_size(): + _set_error("Attempted to read %d bytes past end of buffer (size: %d)." % [bytes_needed, spb.get_size()], spb.get_position()) + return false + return true + +# --- Primitive Value Readers --- +func read_i8(spb: StreamPeerBuffer) -> int: + if not _check_read(spb, 1): return 0 + return spb.get_8(); +func read_i16_le(spb: StreamPeerBuffer) -> int: + if not _check_read(spb, 2): return 0 + spb.big_endian = false; return spb.get_16(); +func read_i32_le(spb: StreamPeerBuffer) -> int: + if not _check_read(spb, 4): return 0 + spb.big_endian = false; return spb.get_32(); +func read_i64_le(spb: StreamPeerBuffer) -> int: + if not _check_read(spb, 8): return 0 + spb.big_endian = false; return spb.get_64(); +func read_u8(spb: StreamPeerBuffer) -> int: + if not _check_read(spb, 1): return 0 + return spb.get_u8() +func read_u16_le(spb: StreamPeerBuffer) -> int: + if not _check_read(spb, 2): return 0 + spb.big_endian = false; return spb.get_u16() +func read_u32_le(spb: StreamPeerBuffer) -> int: + if not _check_read(spb, 4): return 0 + spb.big_endian = false; return spb.get_u32() +func read_u64_le(spb: StreamPeerBuffer) -> int: + if not _check_read(spb, 8): return 0 + spb.big_endian = false; return spb.get_u64() +func read_f32_le(spb: StreamPeerBuffer) -> float: + if not _check_read(spb, 4): return 0.0 + spb.big_endian = false; return spb.get_float() +func read_f64_le(spb: StreamPeerBuffer) -> float: + if not _check_read(spb, 8): return 0.0 + spb.big_endian = false; return spb.get_double() +func read_bool(spb: StreamPeerBuffer) -> bool: + var byte := read_u8(spb) + if has_error(): return false + if byte != 0 and byte != 1: _set_error("Invalid boolean value: %d (expected 0 or 1)" % byte, spb.get_position() - 1); return false + return byte == 1 +func read_bytes(spb: StreamPeerBuffer, num_bytes: int) -> PackedByteArray: + if num_bytes < 0: _set_error("Attempted to read negative bytes: %d" % num_bytes, spb.get_position()); return PackedByteArray() + if num_bytes == 0: return PackedByteArray() + if not _check_read(spb, num_bytes): return PackedByteArray() + var result: Array = spb.get_data(num_bytes) + if result[0] != OK: _set_error("StreamPeerBuffer.get_data failed: %d" % result[0], spb.get_position() - num_bytes); return PackedByteArray() + return result[1] +func read_string_with_u32_len(spb: StreamPeerBuffer) -> String: + var start_pos := spb.get_position() + var length := read_u32_le(spb) + if has_error() or length == 0: return "" + if length > MAX_STRING_LEN: _set_error("String length %d exceeds limit %d" % [length, MAX_STRING_LEN], start_pos); return "" + var str_bytes := read_bytes(spb, length) + if has_error(): return "" + var str_result := str_bytes.get_string_from_utf8() + # More robust check for UTF-8 decoding errors + if str_result == "" and length > 0 and (str_bytes.get_string_from_ascii() == "" or str_bytes.find(0) != -1): + _set_error("Failed to decode UTF-8 string length %d" % length, start_pos); return "" + return str_result +func read_identity(spb: StreamPeerBuffer) -> PackedByteArray: + var identity := read_bytes(spb, IDENTITY_SIZE) + identity.reverse() # We receive the identity bytes in reverse + return identity +func read_connection_id(spb: StreamPeerBuffer) -> PackedByteArray: + return read_bytes(spb, CONNECTION_ID_SIZE) +func read_timestamp(spb: StreamPeerBuffer) -> int: + return read_i64_le(spb) # Timestamps are i64 +func read_vector3(spb: StreamPeerBuffer) -> Vector3: + var x := read_f32_le(spb); var y := read_f32_le(spb); var z := read_f32_le(spb) + return Vector3.ZERO if has_error() else Vector3(x, y, z) +func read_vector2(spb: StreamPeerBuffer) -> Vector2: + var x := read_f32_le(spb); var y := read_f32_le(spb) + return Vector2.ZERO if has_error() else Vector2(x, y) +func read_vector2i(spb: StreamPeerBuffer) -> Vector2i: + var x := read_i32_le(spb); var y := read_i32_le(spb) + return Vector2i.ZERO if has_error() else Vector2i(x, y) +func read_color(spb: StreamPeerBuffer) -> Color: + var r := read_f32_le(spb); var g := read_f32_le(spb); var b := read_f32_le(spb); var a := read_f32_le(spb) + return Color.BLACK if has_error() else Color(r, g, b, a) +func read_quaternion(spb: StreamPeerBuffer) -> Quaternion: + var x := read_f32_le(spb); var y := read_f32_le(spb); var z := read_f32_le(spb); var w := read_f32_le(spb) + return Quaternion.IDENTITY if has_error() else Quaternion(x, y, z, w) +func read_vec_u8(spb: StreamPeerBuffer) -> PackedByteArray: + var start_pos := spb.get_position() + var length := read_u32_le(spb) + if has_error(): return PackedByteArray() + if length > MAX_BYTE_ARRAY_LEN: _set_error("Vec length %d exceeds limit %d" % [length, MAX_BYTE_ARRAY_LEN], start_pos); return PackedByteArray() + if length == 0: return PackedByteArray() + return read_bytes(spb, length) + +# --- BsatnRowList Reader --- +func read_bsatn_row_list(spb: StreamPeerBuffer) -> Array[PackedByteArray]: + var start_pos := spb.get_position() + var size_hint_type := read_u8(spb) + if has_error(): return [] + var rows: Array[PackedByteArray] = [] + match size_hint_type: + ROW_LIST_FIXED_SIZE: + var row_size := read_u16_le(spb); var data_len := read_u32_le(spb) + if has_error(): return [] + if row_size == 0: + if data_len != 0: _set_error("FixedSize row_size 0 but data_len %d" % data_len, start_pos); read_bytes(spb, data_len); return [] + return [] + var data := read_bytes(spb, data_len) + if has_error(): return [] + if data_len % row_size != 0: _set_error("FixedSize data_len %d not divisible by row_size %d" % [data_len, row_size], start_pos); return [] + var num_rows := data_len / row_size + rows.resize(num_rows) + for i in range(num_rows): rows[i] = data.slice(i * row_size, (i + 1) * row_size) + ROW_LIST_ROW_OFFSETS: + var num_offsets := read_u32_le(spb) + if has_error(): return [] + var offsets: Array[int] = []; offsets.resize(num_offsets) + for i in range(num_offsets): offsets[i] = read_u64_le(spb); if has_error(): return [] + var data_len := read_u32_le(spb) + if has_error(): return [] + var data := read_bytes(spb, data_len) + if has_error(): return [] + rows.resize(num_offsets) + for i in range(num_offsets): + var start_offset : int = offsets[i] + var end_offset : int = data_len if (i + 1 == num_offsets) else offsets[i+1] + if start_offset < 0 or end_offset < start_offset or end_offset > data_len: _set_error("Invalid row offsets: start=%d, end=%d, data_len=%d row %d" % [start_offset, end_offset, data_len, i], start_pos); return [] + rows[i] = data.slice(start_offset, end_offset) + _: _set_error("Unknown RowSizeHint type: %d" % size_hint_type, start_pos); return [] + return rows + +# --- Core Deserialization Logic --- + +# Helper to get a primitive reader Callable based on a BSATN type string. +func _get_primitive_reader_from_bsatn_type(bsatn_type_str: String) -> Callable: + match bsatn_type_str: + &"u64": return Callable(self, "read_u64_le") + &"i64": return Callable(self, "read_i64_le") + &"u32": return Callable(self, "read_u32_le") + &"i32": return Callable(self, "read_i32_le") + &"u16": return Callable(self, "read_u16_le") + &"i16": return Callable(self, "read_i16_le") + &"u8": return Callable(self, "read_u8") + &"i8": return Callable(self, "read_i8") + &"identity": return Callable(self, "read_identity") + &"connection_id": return Callable(self, "read_connection_id") + &"timestamp": return Callable(self, "read_timestamp") + &"f64": return Callable(self, "read_f64_le") + &"f32": return Callable(self, "read_f32_le") + &"vec_u8": return Callable(self, "read_vec_u8") + &"bool": return Callable(self, "read_bool") + &"string": return Callable(self, "read_string_with_u32_len") + _: return Callable() # Return invalid Callable if type is not primitive/known + +# Determines the correct reader function (Callable) for a given property. +func _get_reader_callable_for_property(resource: Resource, prop: Dictionary) -> Callable: + var prop_name: StringName = prop.name + var prop_type: Variant.Type = prop.type + var meta_key := "bsatn_type_" + prop_name + + var reader_callable := Callable() # Initialize with invalid Callable + + # --- Special Cases First --- + # Handle specific properties requiring custom logic before generic checks + if resource is TransactionUpdateMessage and prop_name == "status": + reader_callable = Callable(self, "_read_update_status") + # Add other special cases here if needed (e.g., Option fields if handled generically later) + if prop.class_name == &'Option': + reader_callable = Callable(self, "_read_option") + + # --- Generic Type Handling (if not a special case) --- + elif prop_type == TYPE_ARRAY: + # Handle arrays: Distinguish between standard arrays and the special TableUpdate array + if resource is DatabaseUpdateData and prop_name == "tables": + reader_callable = Callable(self, "_read_array_of_table_updates") + else: + reader_callable = Callable(self, "_read_array") # Standard array reader + else: + # Handle non-array, non-special-case properties + # 1. Check for specific BSATN type override via metadata + if resource.has_meta(meta_key): + var bsatn_type_str: String = str(resource.get_meta(meta_key)).to_lower() + reader_callable = _get_primitive_reader_from_bsatn_type(bsatn_type_str) + if not reader_callable.is_valid() and debug_mode: + # Metadata exists but doesn't map to a primitive reader + push_warning("Unknown 'bsatn_type' metadata value: '%s' for property '%s'. Falling back to default type." % [bsatn_type_str, prop_name]) + + # 2. Fallback to default reader based on property's Variant.Type if metadata didn't provide a valid reader + if not reader_callable.is_valid(): + match prop_type: + TYPE_BOOL: reader_callable = Callable(self, "read_bool") + TYPE_INT: reader_callable = Callable(self, "read_i64_le") # Default int is i64 + TYPE_FLOAT: reader_callable = Callable(self, "read_f32_le") # Default float is f32 + TYPE_STRING: reader_callable = Callable(self, "read_string_with_u32_len") + TYPE_VECTOR2: reader_callable = Callable(self, "read_vector2") + TYPE_VECTOR2I: reader_callable = Callable(self, "read_vector2i") + TYPE_VECTOR3: reader_callable = Callable(self, "read_vector3") + TYPE_COLOR: reader_callable = Callable(self, "read_color") + TYPE_QUATERNION: reader_callable = Callable(self, "read_quaternion") + TYPE_PACKED_BYTE_ARRAY: reader_callable = Callable(self, "read_vec_u8") # Default PBA is Vec + # TYPE_ARRAY is handled above + TYPE_OBJECT: + reader_callable = Callable(self, "_read_nested_resource") # Handles nested Resources + _: + # reader_callable remains invalid for unsupported types + pass + + # --- Debug Print (Optional) --- + if debug_mode: + var resource_id = resource.resource_path if resource and resource.resource_path else (resource.get_class() if resource else "NullResource") + print("DEBUG: _get_reader_callable: For '%s' in '%s', returning: %s" % [prop.name, resource_id, reader_callable.get_method() if reader_callable.is_valid() else "INVALID"]) + # --- End Debug --- + + return reader_callable + +# Reads a value for a specific property using the determined reader. +func _read_value_for_property(spb: StreamPeerBuffer, resource: Resource, prop: Dictionary): + var meta: String = "" + if resource.has_meta("bsatn_type_" + prop.name): + meta = resource.get_meta("bsatn_type_" + prop.name).to_lower() + if prop.class_name == &'Option': + return _read_option(spb, resource, prop, meta) + + var reader_callable := _get_reader_callable_for_property(resource, prop) + + if not reader_callable.is_valid(): + _set_error("Unsupported property type '%s' or missing reader for property '%s' in resource '%s'" % [type_string(prop.type), prop.name, resource.resource_path if resource else "Unknown"], spb.get_position()) + return null # Return null on error/unsupported + + # Call the determined reader function. + if reader_callable.get_object() == self: + var method_name = reader_callable.get_method() + # Check if the method requires the full context (spb, resource, prop) + # Typically needed for recursive or context-aware readers. + match method_name: + "_read_array", "_read_nested_resource", "_read_array_of_table_updates", "_read_option": + return reader_callable.call(spb, resource, prop) # Pass full context + _: + # Standard primitive/complex readers usually only need the buffer. + # This includes _read_update_status. + return reader_callable.call(spb) # Pass only spb + else: + # Should not happen with Callables created above, but handle defensively + _set_error("Internal error: Invalid reader callable.", spb.get_position()) + return null + +# Populates an existing Resource instance from the buffer based on its exported properties. +func _populate_resource_from_bytes(resource: Resource, spb: StreamPeerBuffer) -> bool: + var script := resource.get_script() + if not resource or not script: + _set_error("Cannot populate null or scriptless resource", -1 if not spb else spb.get_position()) + return false + + if resource is RustEnum: + return _populate_enum_from_bytes(spb, resource) + + var plan = _deserialization_plan_cache.get(script) + + if plan == null: + if debug_mode: print("DEBUG: Creating deserialization plan for script: %s" % script.resource_path) + + plan = [] + var properties: Array = script.get_script_property_list() + for prop in properties: + if not (prop.usage & PROPERTY_USAGE_STORAGE): + continue + + var prop_name: StringName = prop.name + var reader_callable: Callable = _get_reader_callable_for_property(resource, prop) + + if not reader_callable.is_valid(): + _set_error("Unsupported property or missing reader for '%s' in script '%s'" % [prop_name, script.resource_path], -1) + _deserialization_plan_cache[script] = [] + return false + + var method_name := reader_callable.get_method() + var needs_full_context := method_name in ["_read_array", "_read_nested_resource", "_read_array_of_table_updates", "_read_option"] + + plan.append({ + "name": prop_name, + "type": prop.type, + "reader": reader_callable, + "full_context": needs_full_context, + "prop_dict": prop + }) + + _deserialization_plan_cache[script] = plan + + for instruction in plan: + var value_start_pos = spb.get_position() + var value + if instruction.full_context: + value = instruction.reader.call(spb, resource, instruction.prop_dict) + else: + value = instruction.reader.call(spb) + + if has_error(): + if not _last_error.contains(str(instruction.name)): + var existing_error = get_last_error() + _set_error("Failed reading value for property '%s' in '%s'. Cause: %s" % [instruction.name, resource.get_script().get_global_name() if resource else "Unknown", existing_error], value_start_pos) + return false + + if value != null: + if instruction.type == TYPE_ARRAY and value is Array: + var target_array = resource.get(instruction.name) + if target_array is Array: + target_array.assign(value) + else: + resource[instruction.name] = value + else: + resource[instruction.name] = value + return true + +# Populates the value property of a sumtype enum +func _populate_enum_from_bytes(spb: StreamPeerBuffer, resource: Resource) -> bool: + var enum_type = resource.get_meta("bsatn_enum_type") + var enum_variant: int = spb.get_u8() + var instance: Resource = null + var script := _schema.get_type(enum_type.to_lower()) + if script and script.can_instantiate(): + instance = script.new() + resource.value = enum_variant + _populate_enum_data_from_bytes(resource, spb) + return true + +# Populates the data property of a sumtype enum +func _populate_enum_data_from_bytes(resource: Resource, spb: StreamPeerBuffer) -> bool: + var enum_type: StringName = resource.get_meta("enum_options")[resource.value] + if enum_type == &"": return true + var data = _read_value_from_bsatn_type(spb, enum_type.to_lower(), &"") + if data: + resource.data = data + return true + return false + +# --- Special Readers --- +# Add this new function to the BSATNDeserializer class + +# Helper function to deserialize a value based on BSATN type string. +# Assumes bsatn_type_str is already to_lower() if it's from metadata. +func _read_value_from_bsatn_type(spb: StreamPeerBuffer, bsatn_type_str: String, context_prop_name_for_error: StringName) -> Variant: + var value = null + var start_pos_val_read = spb.get_position() # For error reporting + + # 1. Try primitive reader (expects lowercase bsatn_type_str) + var primitive_reader := _get_primitive_reader_from_bsatn_type(bsatn_type_str) + if primitive_reader.is_valid(): + value = primitive_reader.call(spb) + if has_error(): return null + return value + + # 2. Handle Vec (e.g., "vec_u8", "vec_mycustomresource") + # Assumes bsatn_type_str is already lowercase + if bsatn_type_str.begins_with("vec_"): + var element_bsatn_type_str = bsatn_type_str.right(-4) # This will also be lowercase + + var array_length := read_u32_le(spb) + if has_error(): return null + if array_length == 0: return [] + if array_length > MAX_VEC_LEN: + _set_error("Array length %d (for BSATN type '%s') exceeds limit %d for context '%s'" % [array_length, bsatn_type_str, MAX_VEC_LEN, context_prop_name_for_error], spb.get_position() - 4) # -4 for u32 length + return null + + var temp_array := [] + # temp_array.resize(array_length) # Not strictly necessary if appending + for i in range(array_length): + if has_error(): return null # Stop if previous element failed + var element_value = _read_value_from_bsatn_type(spb, element_bsatn_type_str, "%s[element %d]" % [context_prop_name_for_error, i]) + if has_error(): return null # Stop if current element failed + temp_array.append(element_value) + return temp_array + + # 3. Handle Option (e.g., "opt_u8", "opt_mycustomresource") + # Assumes bsatn_type_str is already lowercase + if bsatn_type_str.begins_with("opt_"): + var element_bsatn_type_str = bsatn_type_str.right(-4) # This will also be lowercase + var option = _read_option(spb, null, {"name": context_prop_name_for_error}, element_bsatn_type_str) + return option + + # 4. Handle Custom Resource (non-array) + # schema type names are table_name.to_lower().replace("_", "") + # bsatn_type_str from metadata should be .to_lower()'d before calling this. + var schema_key = bsatn_type_str.replace("_", "") # e.g., "maindamage" -> "maindamage", "my_type" -> "mytype" + if _schema.types.has(schema_key): + var script := _schema.get_type(schema_key) + if script and script.can_instantiate(): + var nested_instance = script.new() + if not _populate_resource_from_bytes(nested_instance, spb): + # Error should be set by _populate_resource_from_bytes + if not has_error(): _set_error("Failed to populate nested resource of type '%s' (schema key '%s') for context '%s'" % [bsatn_type_str, schema_key, context_prop_name_for_error], start_pos_val_read) + return null + return nested_instance + else: + _set_error("Cannot instantiate schema for BSATN type '%s' (schema key '%s', context: '%s'). Script valid: %s, Can instantiate: %s" % [bsatn_type_str, schema_key, context_prop_name_for_error, script != null, script.can_instantiate() if script else "N/A"], start_pos_val_read) + return null + + _set_error("Unsupported BSATN type '%s' for deserialization (context: '%s'). No primitive, vec, or custom schema found." % [bsatn_type_str, context_prop_name_for_error], start_pos_val_read) + return null + +func _read_option(spb: StreamPeerBuffer, parent_resource_containing_option: Resource, option_property_dict: Dictionary, explicit_inner_bsatn_type_str: String = "") -> Option: + var option_instance := Option.new() + var option_prop_name: StringName = option_property_dict.name # For error messages and metadata key + + # Wire format: u8 tag (0 for Some, 1 for None) + # If Some (0): followed by T value + var tag_pos := spb.get_position() + var is_present_tag := read_u8(spb) + if has_error(): return null # Error reading tag + if is_present_tag == 1: # It's None + option_instance.set_none() + if debug_mode: print("DEBUG: _read_option: Read None for Option property '%s'" % option_prop_name) + return option_instance + elif is_present_tag == 0: # It's Some + var inner_bsatn_type_str_to_use: String + + if not explicit_inner_bsatn_type_str.is_empty(): + inner_bsatn_type_str_to_use = explicit_inner_bsatn_type_str # Assumed to be already .to_lower() by caller (_read_array) + else: + var bsatn_meta_key_for_inner_type := "bsatn_type_" + option_prop_name + if not parent_resource_containing_option.has_meta(bsatn_meta_key_for_inner_type): + _set_error("Missing 'bsatn_type' metadata for Option property '%s' in resource '%s'. Cannot determine inner type T." % [option_prop_name, parent_resource_containing_option.resource_path if parent_resource_containing_option else "UnknownResource"], tag_pos) + return null + inner_bsatn_type_str_to_use = str(parent_resource_containing_option.get_meta(bsatn_meta_key_for_inner_type)).to_lower() + if inner_bsatn_type_str_to_use.is_empty(): + _set_error("'bsatn_type' metadata for Option property '%s' is empty. Cannot determine inner type T." % option_prop_name, tag_pos) + return null + + if debug_mode: print("DEBUG: _read_option: Read Some for Option property '%s', deserializing inner type: '%s'" % [option_prop_name, inner_bsatn_type_str_to_use]) + var inner_value = _read_value_from_bsatn_type(spb, inner_bsatn_type_str_to_use, option_prop_name) + + if has_error(): + # Error should have been set by _read_value_from_bsatn_type or its callees. + # Add context if the error message doesn't already mention the property. + if not _last_error.contains(str(option_prop_name)): + var existing_error = get_last_error() # Consume the error + _set_error("Failed reading 'Some' value for Option property '%s' (inner BSATN type '%s'). Cause: %s" % [option_prop_name, inner_bsatn_type_str_to_use, existing_error], tag_pos + 1) # Position after tag + return null + + option_instance.set_some(inner_value) + return option_instance + else: + _set_error("Invalid tag %d for Option property '%s' (expected 0 for Some, 1 for None)." % [is_present_tag, option_prop_name], tag_pos) + return null + +# Reads an array property. +func _read_array(spb: StreamPeerBuffer, resource: Resource, prop: Dictionary) -> Array: + var prop_name: StringName = prop.name + var start_pos := spb.get_position() + var meta_key := "bsatn_type_" + prop_name + + # 1. Read array length (u32) + var length := read_u32_le(spb) + if has_error(): return [] + if length == 0: return [] + if length > MAX_VEC_LEN: _set_error("Array length %d exceeds limit %d for property '%s'" % [length, MAX_VEC_LEN, prop_name], start_pos); return [] + + # 2. Determine element prototype info (Variant.Type, class_name) from hint_string + var hint: int = prop.hint + var hint_string: String = prop.hint_string + var element_type_code: Variant.Type = TYPE_MAX + var element_class_name: StringName = &"" + + + + if hint == PROPERTY_HINT_TYPE_STRING and ":" in hint_string: # Godot 3 style: "Type:TypeName" + var hint_parts = hint_string.split(":", true, 1) + if hint_parts.size() == 2: + element_type_code = int(hint_parts[0]); + element_class_name = hint_parts[1] + else: _set_error("Array property '%s': Bad hint_string format '%s'." % [prop_name, hint_string], start_pos); return [] + elif hint == PROPERTY_HINT_ARRAY_TYPE: # Godot 4 style: "VariantType/ClassName:VariantType" or "VariantType:VariantType" + var main_type_str = hint_string.split(":", true, 1)[0] + if "/" in main_type_str: var parts = main_type_str.split("/", true, 1); element_type_code = int(parts[0]); element_class_name = parts[1] + else: element_type_code = int(main_type_str) + else: _set_error("Array property '%s' needs a typed hint. Hint: %d, HintString: '%s'" % [prop_name, hint, hint_string], start_pos); return [] + if element_type_code == TYPE_MAX: _set_error("Could not determine element type for array '%s'." % prop_name, start_pos); return [] + + # 3. Create a temporary "prototype" dictionary for the element + var element_prop_sim = { "name": prop_name + "[element]", "type": element_type_code, "class_name": element_class_name, "usage": PROPERTY_USAGE_STORAGE, "hint": 0, "hint_string": "" } + + # 4. Determine the reader function for the ELEMENTS + var element_reader_callable : Callable + var array_bsatn_meta_key := "bsatn_type_" + prop_name # Metadata for the array property itself + var inner_type_for_option_elements: String = "" # To store T's BSATN type for Array[Option] + if element_class_name == &"Option": + element_reader_callable = Callable(self, "_read_option") + if resource.has_meta(array_bsatn_meta_key): + inner_type_for_option_elements = str(resource.get_meta(array_bsatn_meta_key)).to_lower() + if inner_type_for_option_elements.is_empty(): + _set_error("Array '%s' of Options has empty 'bsatn_type' metadata. Inner type T for Option cannot be determined." % prop_name, start_pos) + return [] + else: + # This metadata is essential for Array[Option] + _set_error("Array '%s' of Options is missing 'bsatn_type' metadata. This metadata should specify the BSATN type of T in Option (e.g., 'u8' for Array[Option])." % prop_name, start_pos) + return [] + else: # Not an array of Options, proceed with existing logic + if resource.has_meta(array_bsatn_meta_key): # Check array's metadata first (defines element BSATN type) + var bsatn_element_type_str = str(resource.get_meta(array_bsatn_meta_key)).to_lower() + element_reader_callable = _get_primitive_reader_from_bsatn_type(bsatn_element_type_str) + # Check if resource is a nested resource in possible row schemas + if not element_reader_callable.is_valid() and _schema.types.has(bsatn_element_type_str): + element_reader_callable = Callable(self, "_read_nested_resource") + if not element_reader_callable.is_valid() and debug_mode: + push_warning("Array '%s' has 'bsatn_type' metadata ('%s'), but it doesn't map to a primitive reader. Falling back to element type hint." % [prop_name, bsatn_element_type_str]) + + if not element_reader_callable.is_valid(): # Fallback to element's Variant.Type if no valid primitive reader from metadata + element_reader_callable = _get_reader_callable_for_property(resource, element_prop_sim) # Use element prototype here + + if not element_reader_callable.is_valid(): + _set_error("Cannot determine reader for elements of array '%s' (element type code %d, class '%s')." % [prop_name, element_type_code, element_class_name], start_pos) + return [] + + # 5. Read elements recursively + var result_array := []; result_array.resize(length) # Pre-allocate for typed arrays if needed, or just append + var element_reader_method_name = element_reader_callable.get_method() if element_reader_callable.is_valid() else "" + + for i in range(length): + if has_error(): return [] # Stop on error + var element_start_pos = spb.get_position() + var element_value = null + + if element_reader_callable.get_object() == self: + match element_reader_method_name: + # Special handling for _read_option when it's an array element + "_read_option": + element_value = element_reader_callable.call(spb, resource, element_prop_sim, inner_type_for_option_elements) + # Existing logic for other recursive/contextual readers + "_read_array", "_read_nested_resource", "_read_array_of_table_updates": + element_value = element_reader_callable.call(spb, resource, element_prop_sim) + # Primitive reader or other simple reader + _: + element_value = element_reader_callable.call(spb) + else: + _set_error("Internal error: Invalid element reader callable for array '%s'." % prop_name, element_start_pos); return [] + + if has_error(): + if not _last_error.contains("element %d" % i) and not _last_error.contains(str(prop_name)): # Avoid redundant context + var existing_error = get_last_error(); + _set_error("Failed reading element %d for array '%s'. Cause: %s" % [i, prop_name, existing_error], element_start_pos) + return [] + result_array[i] = element_value # Or result_array.append(element_value) if not resizing + return result_array + +# Reads a nested Resource property. +func _read_nested_resource(spb: StreamPeerBuffer, resource: Resource, prop: Dictionary) -> Resource: + var prop_name: StringName = prop.name + var nested_class_name: StringName = prop.class_name + + if nested_class_name == &"": + _set_error("Property '%s' is TYPE_OBJECT but has no class_name hint in script '%s'." % [prop_name, resource.get_script().resource_path if resource and resource.get_script() else "Unknown"], spb.get_position()) + return null + + # Try to find script in preloaded schemas first (common for table rows) + var key := nested_class_name.to_lower() + var script := _schema.get_type(key) + var nested_instance: Resource = null + + if script: + nested_instance = script.new() + else: + # If not preloaded, try ClassDB (for built-ins or globally registered scripts) + if ClassDB.can_instantiate(nested_class_name): + nested_instance = ClassDB.instantiate(nested_class_name) + if not nested_instance is Resource: + _set_error("ClassDB instantiated '%s' for property '%s', but it's not a Resource. (instance: %s)" % [nested_class_name, prop_name, nested_instance], spb.get_position()) + return null + # If it's a Resource without an explicit script (e.g., built-in), population might still work + if debug_mode and nested_instance.get_script() == null: + push_warning("Instantiated nested object '%s' via ClassDB without a script. Population relies on ClassDB properties." % nested_class_name) + else: + # Cannot find script or instantiate via ClassDB + _set_error("Could not find preloaded schema or instantiate class '%s' (required by property '%s')." % [nested_class_name, prop_name], spb.get_position()) + return null + + if nested_instance == null: + _set_error("Failed to create instance of nested resource '%s' for property '%s'." % [nested_class_name, prop_name], spb.get_position()) + return null + + # Recursively populate the nested instance + if not _populate_resource_from_bytes(nested_instance, spb): + # Error should be set by the recursive call + if not has_error(): _set_error("Failed during recursive population for nested resource '%s' of type '%s'." % [prop_name, nested_class_name], spb.get_position()) + return null + + return nested_instance + +# --- Specific Message/Structure Readers --- + +# Reads UpdateStatus structure (handles enum tag) +func _read_update_status(spb: StreamPeerBuffer) -> UpdateStatusData: + var resource := UpdateStatusData.new() + var tag := read_u8(spb) # Enum tag + if has_error(): return null + + match tag: + UpdateStatusData.StatusType.COMMITTED: # 0 + resource.status_type = UpdateStatusData.StatusType.COMMITTED + var db_update_res = DatabaseUpdateData.new() + if not _populate_resource_from_bytes(db_update_res, spb): return null + resource.committed_update = db_update_res + UpdateStatusData.StatusType.FAILED: # 1 + resource.status_type = UpdateStatusData.StatusType.FAILED + resource.failure_message = read_string_with_u32_len(spb) + UpdateStatusData.StatusType.OUT_OF_ENERGY: # 2 + resource.status_type = UpdateStatusData.StatusType.OUT_OF_ENERGY + _: + _set_error("Unknown UpdateStatus tag: %d" % tag, spb.get_position() - 1) + return null + + return null if has_error() else resource + +# Reads the Vec structure specifically +func _read_array_of_table_updates(spb: StreamPeerBuffer, resource: Resource, prop: Dictionary) -> Array: + var start_pos := spb.get_position() + var length := read_u32_le(spb) + if debug_mode: print("DEBUG: _read_array_of_table_updates: Called for '%s' at pos %d. Read length: %d. New pos: %d" % [prop.name, start_pos, length, spb.get_position()]) + if has_error(): return [] + if length == 0: return [] + if length > MAX_VEC_LEN: _set_error("DatabaseUpdate tables length %d exceeds limit %d" % [length, MAX_VEC_LEN], start_pos); return [] + + var result_array := []; result_array.resize(length) + + for i in range(length): + if has_error(): return [] + var element_start_pos = spb.get_position() + var table_update_instance = TableUpdateData.new() + # Use the specialized instance reader for TableUpdateData's complex structure + if not _read_table_update_instance(spb, table_update_instance): + if not has_error(): _set_error("Failed reading TableUpdate element %d" % i, element_start_pos) + return [] + result_array[i] = table_update_instance + + return result_array + +# Reads the content of a SINGLE TableUpdate structure into an existing instance. +# Handles the custom CompressableQueryUpdate format for deletes/inserts. +func _read_table_update_instance(spb: StreamPeerBuffer, resource: TableUpdateData) -> bool: + # Read standard fields first using direct readers + resource.table_id = read_u32_le(spb) + resource.table_name = read_string_with_u32_len(spb) + resource.num_rows = read_u64_le(spb) + if has_error(): return false + + # Now handle the custom CompressableQueryUpdate structure + var updates_count := read_u32_le(spb) # Number of CompressableQueryUpdate blocks + if has_error(): return false + + var all_parsed_deletes: Array[Resource] = [] + var all_parsed_inserts: Array[Resource] = [] + + var table_name_lower := resource.table_name.to_lower().replace("_","") + var row_schema_script := _schema.get_type(table_name_lower) + + var row_spb := StreamPeerBuffer.new() + + if not row_schema_script and updates_count > 0: + if debug_mode: push_warning("No row schema found for table '%s', cannot deserialize rows. Skipping row data." % resource.table_name) + # Consume the data even if we can't parse it + + for i in range(updates_count): + if has_error(): break + var update_start_pos := spb.get_position() + var query_update_spb: StreamPeerBuffer = _get_query_update_stream(spb, resource.table_name) + if has_error() or query_update_spb == null: + if not has_error(): _set_error("Failed to get query update stream for table '%s'." % resource.table_name, update_start_pos) + break + read_bsatn_row_list(query_update_spb); if has_error(): break # Consume deletes + read_bsatn_row_list(query_update_spb); if has_error(): break # Consume inserts + if query_update_spb != spb and query_update_spb.get_position() < query_update_spb.get_size(): + push_error("Extra %d bytes remaining in skipped QueryUpdate block for table '%s'" % [query_update_spb.get_size() - query_update_spb.get_position(), resource.table_name]) + resource.deletes.assign([]); resource.inserts.assign([]) + return not has_error() + + # Schema found, parse rows + for i in range(updates_count): + if has_error(): break + var update_start_pos := spb.get_position() + var query_update_spb: StreamPeerBuffer = _get_query_update_stream(spb, resource.table_name) + if has_error() or query_update_spb == null: + if not has_error(): _set_error("Failed to get query update stream for table '%s'." % resource.table_name, update_start_pos) + break + + var raw_deletes := read_bsatn_row_list(query_update_spb); if has_error(): break + var raw_inserts := read_bsatn_row_list(query_update_spb); if has_error(): break + + if query_update_spb != spb and query_update_spb.get_position() < query_update_spb.get_size(): + push_error("Extra %d bytes remaining in decompressed QueryUpdate block for table '%s'" % [query_update_spb.get_size() - query_update_spb.get_position(), resource.table_name]) + + # Process deletes + for raw_row_bytes in raw_deletes: + var row_resource = row_schema_script.new() + row_spb.data_array = raw_row_bytes + row_spb.seek(0) # Важно! Сбрасываем позицию на начало + #var row_spb := StreamPeerBuffer.new(); row_spb.data_array = raw_row_bytes + if _populate_resource_from_bytes(row_resource, row_spb): + if row_spb.get_position() < row_spb.get_size(): push_error("Extra %d bytes after parsing delete row for table '%s'" % [row_spb.get_size() - row_spb.get_position(), resource.table_name]) + all_parsed_deletes.append(row_resource) + else: push_error("Stopping update processing for table '%s' due to delete row parsing failure." % resource.table_name); break + if has_error(): break + + # Process inserts + for raw_row_bytes in raw_inserts: + var row_resource = row_schema_script.new() + row_spb.data_array = raw_row_bytes + row_spb.seek(0) # Важно! Сбрасываем позицию на начало + if _populate_resource_from_bytes(row_resource, row_spb): + if row_spb.get_position() < row_spb.get_size(): push_error("Extra %d bytes after parsing insert row for table '%s'" % [row_spb.get_size() - row_spb.get_position(), resource.table_name]) + all_parsed_inserts.append(row_resource) + else: push_error("Stopping update processing for table '%s' due to insert row parsing failure." % resource.table_name); break + if has_error(): break + + if has_error(): return false + + resource.deletes.assign(all_parsed_deletes) + resource.inserts.assign(all_parsed_inserts) + return true + +# Helper to handle potential compression of a QueryUpdate block. +func _get_query_update_stream(spb: StreamPeerBuffer, table_name_for_error: String) -> StreamPeerBuffer: + var compression_tag_raw := read_u8(spb) + if has_error(): return null + + match compression_tag_raw: + COMPRESSION_NONE: + return spb + + COMPRESSION_GZIP: + var compressed_len := read_u32_le(spb) + if has_error(): return null + if compressed_len == 0:return StreamPeerBuffer.new() + + var compressed_data := read_bytes(spb, compressed_len) + var decompressed_data := DataDecompressor.decompress_packet(compressed_data) + var temp_spb := StreamPeerBuffer.new() + temp_spb.data_array = decompressed_data + return temp_spb + _: + _set_error("Unknown QueryUpdate compression tag %d for table '%s'" % [compression_tag_raw, table_name_for_error], spb.get_position() - 1) + return null + +# Manual reader specifically for SubscriptionErrorMessage due to Option fields +# Keep this manual until Option is handled generically (if ever needed) +func _read_subscription_error_manual(spb: StreamPeerBuffer) -> SubscriptionErrorMessage: + var resource := SubscriptionErrorMessage.new() + + resource.total_host_execution_duration_micros = read_u64_le(spb); if has_error(): return null + + # Read Option request_id (0 = Some, 1 = None) + var req_id_tag = read_u8(spb); if has_error(): return null + if req_id_tag == 0: resource.request_id = read_u32_le(spb) + elif req_id_tag == 1: resource.request_id = -1 # Using -1 to represent None + else: _set_error("Invalid tag %d for Option request_id" % req_id_tag, spb.get_position() - 1); return null + if has_error(): return null + + # Read Option query_id + var query_id_tag = read_u8(spb); if has_error(): return null + if query_id_tag == 0: resource.query_id = read_u32_le(spb) + elif query_id_tag == 1: resource.query_id = -1 # Using -1 to represent None + else: _set_error("Invalid tag %d for Option query_id" % query_id_tag, spb.get_position() - 1); return null + if has_error(): return null + + # Read Option table_id_resource + var table_id_tag = read_u8(spb); if has_error(): return null + if table_id_tag == 0: # Some(TableId) + var table_id_res = TableIdData.new() + if not _populate_resource_from_bytes(table_id_res, spb): return null + resource.table_id_resource = table_id_res + elif table_id_tag == 1: # None + resource.table_id_resource = null + else: _set_error("Invalid tag %d for Option" % table_id_tag, spb.get_position() - 1); return null + + resource.error_message = read_string_with_u32_len(spb) + return null if has_error() else resource + +func process_bytes_and_extract_messages(new_data: PackedByteArray) -> Array[Resource]: + if new_data.is_empty(): + return [] + _pending_data.append_array(new_data) + var parsed_messages: Array[Resource] = [] + var spb := StreamPeerBuffer.new() + while not _pending_data.is_empty(): + clear_error() + spb.data_array = _pending_data + spb.seek(0) + var initial_buffer_size = _pending_data.size() + var message_resource = _parse_message_from_stream(spb) + + if has_error(): + if _last_error.contains("past end of buffer"): + clear_error() + break + else: + printerr("BSATNDeserializer: Unrecoverable parsing error: %s. Clearing buffer to prevent infinite loop." % get_last_error()) + _pending_data.clear() + break + + if message_resource: + parsed_messages.append(message_resource) + var bytes_consumed = spb.get_position() + + if bytes_consumed == 0: + printerr("BSATNDeserializer: Parser consumed 0 bytes. Clearing buffer to prevent infinite loop.") + _pending_data.clear() + break + _pending_data = _pending_data.slice(bytes_consumed) + else: + break + + return parsed_messages + +# --- Top-Level Message Parsing --- +# Entry point: Parses the entire byte buffer into a top-level message Resource. +func parse_packet(buffer: PackedByteArray) -> Resource: + push_warning("BSATNDeserializer.parse_packet is deprecated. Use process_bytes_and_extract_messages instead.") + var results = process_bytes_and_extract_messages(buffer) + return results[0] if not results.is_empty() else null + + +func _parse_message_from_stream(spb: StreamPeerBuffer) -> Resource: + clear_error() + #if spb.get_available_bytes().is_empty(): _set_error("Input buffer is empty", 0); return null + + var start_pos = spb.get_position() + if not _check_read(spb, 1): + return null + + var msg_type := read_u8(spb) + if has_error(): return null + + var result_resource: Resource = null + # Path to the GDScript file for the message type + var resource_script_path := SpacetimeDBServerMessage.get_resource_path(msg_type) + + if resource_script_path == "": + _set_error("Unknown server message type: 0x%02X" % msg_type, 1) + return null + + # --- Special handling for types requiring manual parsing --- + if msg_type == SpacetimeDBServerMessage.SUBSCRIPTION_ERROR: + # Use the manual reader due to Option complexity + result_resource = _read_subscription_error_manual(spb) + if has_error(): return null + # Error message is printed by _set_error, but we can add context + if result_resource.error_message: printerr("Subscription Error Received: ", result_resource.error_message) + + # --- TODO: Implement reader for OneOffQueryResponseData --- + elif msg_type == SpacetimeDBServerMessage.ONE_OFF_QUERY_RESPONSE: + _set_error("Reader for OneOffQueryResponse (0x04) not implemented.", spb.get_position() -1) + return null # Or return an empty resource shell if preferred + + # --- Generic handling for types parsed via _populate_resource_from_bytes --- + else: + if not ResourceLoader.exists(resource_script_path): + _set_error("Script not found for message type 0x%02X: %s" % [msg_type, resource_script_path], 1) + return null + var script: GDScript = ResourceLoader.load(resource_script_path, "GDScript") + if not script or not script.can_instantiate(): + _set_error("Failed to load or instantiate script for message type 0x%02X: %s" % [msg_type, resource_script_path], 1) + return null + + result_resource = script.new() + if not _populate_resource_from_bytes(result_resource, spb): + # Error already set by _populate_resource_from_bytes or its callees + return null # Return null on population failure + + # Optional: Check if all bytes were consumed after parsing the message body + var remaining_bytes := spb.get_size() - spb.get_position() + if remaining_bytes > 0: + # This might indicate a parsing error or extra data. Warning is appropriate. + push_warning("Bytes remaining after parsing message type 0x%02X: %d" % [msg_type, remaining_bytes]) + + return result_resource diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_deserializer.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_deserializer.gd.uid new file mode 100644 index 00000000000..ecc1d2a0d8f --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_deserializer.gd.uid @@ -0,0 +1 @@ +uid://bop8ihmxnpk4f diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_serializer.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_serializer.gd new file mode 100644 index 00000000000..bf8d9924f7b --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_serializer.gd @@ -0,0 +1,467 @@ +class_name BSATNSerializer extends RefCounted + +# --- Constants --- +const IDENTITY_SIZE := 32 +const CONNECTION_ID_SIZE := 16 + +# --- Properties --- +var _last_error: String = "" +var _spb: StreamPeerBuffer # Internal buffer used by writing functions + +# --- Initialization --- +func _init() -> void: + _spb = StreamPeerBuffer.new() + _spb.big_endian = false # Use Little-Endian + +# --- Error Handling --- +func has_error() -> bool: return _last_error != "" +func get_last_error() -> String: var e = _last_error; _last_error = ""; return e +func clear_error() -> void: _last_error = "" +# Sets the error message if not already set. Internal use. +func _set_error(msg: String) -> void: + if _last_error == "": # Prevent overwriting + _last_error = "BSATNSerializer Error: %s" % msg + printerr(_last_error) # Always print errors + +# --- Primitive Value Writers --- +# These directly write basic types to the internal StreamPeerBuffer. + +func write_i8(v: int) -> void: + if v < -128 or v > 127: _set_error("Value %d out of range for i8" % v); v = 0 + _spb.put_u8(v if v >= 0 else v + 256) + +func write_i16_le(v: int) -> void: + if v < -32768 or v > 32767: _set_error("Value %d out of range for i16" % v); v = 0 + _spb.put_u16(v if v >= 0 else v + 65536) + +func write_i32_le(v: int) -> void: + if v < -2147483648 or v > 2147483647: _set_error("Value %d out of range for i32" % v); v = 0 + _spb.put_u32(v) # put_u32 handles negative i32 correctly via two's complement + +func write_i64_le(v: int) -> void: + _spb.put_u64(v) # put_u64 handles negative i64 correctly via two's complement + +func write_u8(v: int) -> void: + if v < 0 or v > 255: _set_error("Value %d out of range for u8" % v); v = 0 + _spb.put_u8(v) + +func write_u16_le(v: int) -> void: + if v < 0 or v > 65535: _set_error("Value %d out of range for u16" % v); v = 0 + _spb.put_u16(v) + +func write_u32_le(v: int) -> void: + if v < 0 or v > 4294967295: _set_error("Value %d out of range for u32" % v); v = 0 + _spb.put_u32(v) + +func write_u64_le(v: int) -> void: + if v < 0: _set_error("Value %d out of range for u64" % v); v = 0 + _spb.put_u64(v) + +func write_f32_le(v: float) -> void: + _spb.put_float(v) + +func write_f64_le(v: float) -> void: + _spb.put_double(v) + +func write_bool(v: bool) -> void: + _spb.put_u8(1 if v else 0) + +func write_bytes(v: PackedByteArray) -> void: + if v == null: v = PackedByteArray() # Avoid error on null + var result = _spb.put_data(v) + if result != OK: _set_error("StreamPeerBuffer.put_data failed with code %d" % result) + +func write_string_with_u32_len(v: String) -> void: + if v == null: v = "" + var str_bytes := v.to_utf8_buffer() + write_u32_le(str_bytes.size()) + if str_bytes.size() > 0: write_bytes(str_bytes) + +func write_identity(v: PackedByteArray) -> void: + if v == null or v.size() != IDENTITY_SIZE: + _set_error("Invalid Identity value (null or size != %d)" % IDENTITY_SIZE) + var default_bytes = PackedByteArray(); default_bytes.resize(IDENTITY_SIZE) + write_bytes(default_bytes) # Write default value to avoid stopping serialization + return + write_bytes(v) + +func write_connection_id(v: PackedByteArray) -> void: + if v == null or v.size() != CONNECTION_ID_SIZE: + _set_error("Invalid ConnectionId value (null or size != %d)" % CONNECTION_ID_SIZE) + var default_bytes = PackedByteArray(); default_bytes.resize(CONNECTION_ID_SIZE) + write_bytes(default_bytes) # Write default value + return + write_bytes(v) + +func write_timestamp(v: int) -> void: + write_i64_le(v) # Timestamps are typically i64 + +func write_vector3(v: Vector3) -> void: + if v == null: v = Vector3.ZERO # Handle potential null value + write_f32_le(v.x); write_f32_le(v.y); write_f32_le(v.z) + +func write_vector2(v: Vector2) -> void: + if v == null: v = Vector2.ZERO # Handle potential null value + write_f32_le(v.x); write_f32_le(v.y) + +func write_vector2i(v: Vector2i) -> void: + if v == null: v = Vector2i.ZERO # Handle potential null value + write_i32_le(v.x); write_i32_le(v.y) + +func write_color(v: Color) -> void: + if v == null: v = Color.BLACK # Handle potential null value + write_f32_le(v.r); write_f32_le(v.g); write_f32_le(v.b); write_f32_le(v.a) + +func write_quaternion(v: Quaternion) -> void: + if v == null: v = Quaternion.IDENTITY # Handle potential null value + write_f32_le(v.x); write_f32_le(v.y); write_f32_le(v.z); write_f32_le(v.w) + +# Writes a PackedByteArray prefixed with its u32 length (Vec format) +func write_vec_u8(v: PackedByteArray) -> void: + if v == null: v = PackedByteArray() + write_u32_le(v.size()) + if v.size() > 0: write_bytes(v) # Avoid calling put_data with empty array if possible + +#Writes a Rust sum type enum +func write_rust_enum(rust_enum: RustEnum) -> void: + write_u8(rust_enum.value) + var sub_class: String = rust_enum.get_meta("enum_options")[rust_enum.value] + var data = rust_enum.data + if sub_class.begins_with("vec"): + if data is not Array: + _set_error("Sum type of rust enum is Vec but the godot type is not an array.") + return + var vec_type = sub_class.right(-4) + # If it's an Option type, we need to remove the opt prefix for the serializer + # This is a special case, the enum needs more info for the deserializer + if vec_type.begins_with("opt"): + vec_type = vec_type.right(-4) + _write_argument_value(data, vec_type) + return + if sub_class.begins_with("opt"): + if data is not Option: + _set_error("Sum type of rust enum is Option but the godot type is not an Option.") + return + var opt_type = sub_class.right(-4) + # If it's a Vec type, we need to remove the vec prefix for the serializer + # This is a special case, the enum needs more info for the deserializer + if opt_type.begins_with("vec"): + opt_type = opt_type.right(-4) + _write_argument_value(data, opt_type) + return + if not sub_class.is_empty(): + if not data: + data = _generate_default_type(sub_class) + _write_argument_value(data, sub_class) + +func _write_option(option_value: Option, option_property_name: StringName, rust_type: String) -> bool: + if not option_value is Option: + _set_error("Value provided to _write_option is not an Option instance (type: %s) for property '%s'." % [typeof(option_value), option_property_name]) + return false + if option_value.is_none(): + write_u8(1) # Tag for None + if has_error(): + _set_error("Failed to write None tag for Option property '%s'." % option_property_name) + return false + return true + else: # is_some() + write_u8(0) # Tag for Some + if has_error(): + _set_error("Failed to write Some tag for Option property '%s'." % option_property_name) + return false + if rust_type.begins_with("vec"): + if option_value.unwrap() is not Array: + _set_error("Option type is Vec but the godot type is not an array.") + return false + var vec_type = rust_type.right(-4) + _write_argument_value(option_value.unwrap(), vec_type) + else: + _write_argument_value(option_value.unwrap(), rust_type) + return true + +func _write_array_of_option(array_of_option_value: Array, rust_type: String) -> bool: + write_u32_le(array_of_option_value.size()) + for option_value in array_of_option_value: + if not _write_option(option_value, "", rust_type): + return false + return true + +# --- Core Serialization Logic --- + +# Helper to get the specific BSATN writer METHOD NAME based on metadata value. +func _get_specific_writer_method_name(bsatn_type_value) -> StringName: + if bsatn_type_value == null: return &"" + var bsatn_type_str := str(bsatn_type_value).to_lower() + match bsatn_type_str: + &"u64": return &"write_u64_le" + &"i64": return &"write_i64_le" + &"u32": return &"write_u32_le" + &"i32": return &"write_i32_le" + &"u16": return &"write_u16_le" + &"i16": return &"write_i16_le" + &"u8": return &"write_u8" + &"i8": return &"write_i8" + &"identity": return &"write_identity" + &"connection_id": return &"write_connection_id" + &"timestamp": return &"write_timestamp" + &"f64": return &"write_f64_le" + &"f32": return &"write_f32_le" + &"vec_u8": return &"write_vec_u8" + &'string': return &'write_string_with_u32_len' + &'bool': return &'write_bool' + # Add other specific types mapped to writer methods if needed + _: return &"" # Unknown or non-primitive type + +# The central recursive function to write any value. +# Uses metadata for specific types, otherwise defaults based on Variant.Type. +func _write_value(value, value_variant_type: Variant.Type, specific_writer_override: StringName = &"", \ + element_variant_type: Variant.Type = TYPE_MAX, \ + element_class_name: StringName = &"" \ + ) -> bool: + # 1. Use specific writer method if provided (highest priority, except for arrays) + if specific_writer_override != &"" and value_variant_type != TYPE_ARRAY: + if has_method(specific_writer_override): + call(specific_writer_override, value) + else: + _set_error("Internal error: Specific writer method '%s' not found." % specific_writer_override) + return false # Critical error + else: + # 2. If no specific writer, use default based on Variant.Type + match value_variant_type: + TYPE_NIL: _set_error("Cannot serialize null value."); return false # Or handle as Option? + TYPE_BOOL: write_bool(value) + TYPE_INT: write_i64_le(value) # Default int serialization is i64 + TYPE_FLOAT: write_f32_le(value) # Default float serialization is f32 + TYPE_STRING: write_string_with_u32_len(value) + TYPE_VECTOR2: write_vector2(value) + TYPE_VECTOR2I: write_vector2i(value) + TYPE_VECTOR3: write_vector3(value) + TYPE_COLOR: write_color(value) + TYPE_QUATERNION: write_quaternion(value) + TYPE_PACKED_BYTE_ARRAY: write_vec_u8(value) # Default PackedByteArray serialization is Vec + TYPE_ARRAY: + if value == null: value = [] # Treat null array as empty for serialization + if not value is Array: _set_error("Value is not an Array but type is TYPE_ARRAY"); return false + # Element type info is required for recursive calls + if element_variant_type == TYPE_MAX: _set_error("Cannot serialize array without element type info: " + str(value)); return false + + write_u32_le(value.size()) # Write array length (u32) + + for element in value: + if has_error(): return false # Stop early if an error occurred writing previous elements + # Recursively call _write_value for the element. + # Pass the element's type info. + # Crucially, pass the specific_writer_override determined from the *array's* metadata, + # as this override applies to the *elements*. + if not _write_value(element, element_variant_type, specific_writer_override, TYPE_MAX, element_class_name): + # Error should be set by the recursive call + if not has_error(): _set_error("Failed to write array element.") # Ensure error is set + return false + TYPE_OBJECT: + if value is Option: + _set_error("Internal error: _write_value called directly for an Option instance. This should be handled by _serialize_resource_fields.") + return false + if value is RustEnum: + write_rust_enum(value) + elif value is Resource: + # Serialize nested resource fields *inline* without length prefix + if not _serialize_resource_fields(value): # Recursive call + # Error should be set by _serialize_resource_fields + return false + else: + # Cannot serialize non-Resource objects by default + _set_error("Cannot serialize non-Resource Object value of type '%s'." % value.get_class()); return false + _: + # Type not handled by specific writers or default cases + _set_error("Unsupported default value type '%s' for serialization." % type_string(value_variant_type)); return false + + # Check for errors one last time after attempting the write operation + return not has_error() + +# Serializes the fields of a Resource instance sequentially. +func _serialize_resource_fields(resource: Resource) -> bool: + if not resource or not resource.get_script(): + _set_error("Cannot serialize fields of null or scriptless resource"); return false + + if resource is RustEnum: + write_rust_enum(resource) + return true + + + var properties: Array = resource.get_script().get_script_property_list() + for prop in properties: + # Only serialize properties marked for storage + if not (prop.usage & PROPERTY_USAGE_STORAGE): continue + var prop_name: StringName = prop.name + var prop_type: Variant.Type = prop.type + var value = resource.get(prop_name) # Get the actual value from the resource instance + var specific_writer_method: StringName = &"" + var element_type: Variant.Type = TYPE_MAX + var element_class: StringName = &"" + + # Check for 'bsatn_type' metadata to override default serialization for this field + var meta_key := "bsatn_type_" + prop_name + if resource.has_meta(meta_key): + # This metadata applies to the field itself, or to the *elements* if it's an array. + specific_writer_method = _get_specific_writer_method_name(resource.get_meta(meta_key)) + + if value is Option: + _write_option(value, prop_name, resource.get_meta(meta_key)) + continue + + # If the property is an array, we need element type info for the recursive call + if prop_type == TYPE_ARRAY: + # Extract element type info from the hint string (Godot 3 or 4 format) + var hint_ok = false + if prop.hint == PROPERTY_HINT_TYPE_STRING and ":" in prop.hint_string: # Godot 3: "Type:TypeName" + var hint_parts = prop.hint_string.split(":", true, 1) + if hint_parts.size() == 2: + if hint_parts[1] == "Option": + _write_array_of_option(value, resource.get_meta(meta_key)) + continue + # Need to check if this is a split type like 24/17 + # Take the first part as the element type + var hint_type = hint_parts[0].split("/", true, 1) if "/" in hint_parts[0] else [hint_parts[0]] + element_type = int(hint_type[0]) + if element_type == TYPE_OBJECT: element_class = hint_parts[1] + hint_ok = true + elif prop.hint == PROPERTY_HINT_ARRAY_TYPE: # Godot 4: "VariantType/ClassName:VariantType" or "VariantType:VariantType" + var main_type_str = prop.hint_string.split(":", true, 1)[0] + if "/" in main_type_str: var parts = main_type_str.split("/", true, 1); element_type = int(parts[0]); element_class = parts[1] + else: element_type = int(main_type_str) + hint_ok = true # Assume format is correct if hint matches + + if not hint_ok: + _set_error("Array property '%s' needs a typed hint for serialization. Hint: %d, HintString: '%s'" % [prop_name, prop.hint, prop.hint_string]); return false + + # Call _write_value for the array. Pass the specific_writer_method (from array's metadata) + # as the override for the ELEMENTS. + if not _write_value(value, TYPE_ARRAY, specific_writer_method, element_type, element_class): + if not has_error(): _set_error("Failed writing array property '%s'" % prop_name) + return false + else: + # For non-arrays, call _write_value, passing the specific_writer_method for the value itself. + if not _write_value(value, prop_type, specific_writer_method): + if not has_error(): _set_error("Failed writing property '%s'" % prop_name) + return false + + return true # All fields serialized successfully + +# --- Argument Serialization Helpers (Optional - Keep if needed for specific use cases) --- + +# Serializes an array of arguments into a single PackedByteArray block. +# Note: This uses default serialization rules and ignores metadata. +func _serialize_arguments(args_array: Array, rust_types: Array) -> PackedByteArray: + var args_spb := StreamPeerBuffer.new(); args_spb.big_endian = false + var original_main_spb := _spb; _spb = args_spb # Temporarily redirect writes + + for i in range(args_array.size()): + var arg_value = args_array[i] + var rust_type = "" + if i < rust_types.size(): + rust_type = rust_types[i] + if not _write_argument_value(arg_value, rust_type): # Use dedicated argument writer + # Error should be set by _write_argument_value + push_error("Failed to serialize argument %d." % i) # Add context + _spb = original_main_spb # Restore main buffer + return PackedByteArray() # Return empty on error + + _spb = original_main_spb # Restore main buffer + return args_spb.data_array if not has_error() else PackedByteArray() + +# Helper to write a single *argument* value (no metadata used). +func _write_argument_value(value, rust_type: String = "") -> bool: + var value_type := typeof(value) + match value_type: + TYPE_NIL: _set_error("Cannot serialize null argument."); return false + TYPE_BOOL: write_bool(value) + TYPE_INT: + match rust_type: + &"u8": write_u8(value) + &"u16": write_u16_le(value) + &"u32": write_u32_le(value) + &"u64": write_u64_le(value) + &"i8": write_i8(value) + &"i16": write_i16_le(value) + &"i32": write_i32_le(value) + _: write_i64_le(value) #Default i64 + TYPE_FLOAT: + match rust_type: + &"f64": write_f64_le(value) + _: write_f32_le(value) # Default f32 + TYPE_STRING: write_string_with_u32_len(value) + TYPE_VECTOR2: write_vector2(value) + TYPE_VECTOR2I: write_vector2i(value) + TYPE_VECTOR3: write_vector3(value) + TYPE_COLOR: write_color(value) + TYPE_QUATERNION: write_quaternion(value) + TYPE_PACKED_BYTE_ARRAY: write_vec_u8(value) # Default Vec for arguments + TYPE_ARRAY: + write_u32_le(value.size()) # Write array length (u32) + for v in value: + _write_argument_value(v, rust_type) + TYPE_OBJECT: + if value is RustEnum: + write_rust_enum(value) + elif value is Option: + _write_option(value, "", rust_type) + elif value is Resource: + # Serialize resource fields directly inline (recursive) + if not _serialize_resource_fields(value): + if not has_error(): _set_error("Failed to serialize nested Resource argument.") + return false + else: + _set_error("Cannot serialize non-Resource Object argument."); return false + _: + _set_error("Unsupported argument type: %s" % type_string(value_type)); return false + return not has_error() + +#Helper to generate a zero struct from a rust type +func _generate_default_type(rust_type: String) -> Variant: + match rust_type: + &"i8", &"i16", &"i32", &"i64", &"u8", &"u16", &"u32", &"u64": + return int(0) + &"f32", &"f64": + return float(0) + &"bool": return false + &"String": return "" + &"Vector3": return Vector3.ZERO + &"Vector2": return Vector2.ZERO + &"Vector2i": return Vector2i.ZERO + &"Color": return Color.BLACK + &"Quaternion": return Quaternion.IDENTITY + _: return null + +# Helper to serialize a single Resource argument into raw bytes (e.g., for reducer calls) +func _serialize_resource_argument(resource_arg: Resource) -> PackedByteArray: + if not resource_arg: _set_error("Cannot serialize null resource argument."); return PackedByteArray() + var arg_spb := StreamPeerBuffer.new(); arg_spb.big_endian = false + var original_main_spb := _spb; _spb = arg_spb # Redirect writes + + if not _serialize_resource_fields(resource_arg): + # Error should be set by _serialize_resource_fields + push_error("Failed to serialize resource argument fields.") # Add context + _spb = original_main_spb # Restore + return PackedByteArray() + + _spb = original_main_spb # Restore + return arg_spb.data_array if not has_error() else PackedByteArray() + +# --- Public API --- + +# Serializes a complete ClientMessage (variant tag + payload resource fields). +func serialize_client_message(variant_tag: int, payload_resource: Resource) -> PackedByteArray: + clear_error(); _spb.data_array = PackedByteArray(); _spb.seek(0) # Reset state + + # 1. Write the message variant tag (u8) + write_u8(variant_tag) + if has_error(): return PackedByteArray() + + # 2. Serialize payload resource fields inline after the tag + if payload_resource != null: # Allow null payload for messages without one + if not _serialize_resource_fields(payload_resource): + if not has_error(): _set_error("Failed to serialize payload resource for tag %d" % variant_tag) + return PackedByteArray() + # else: No payload to serialize + + return _spb.data_array if not has_error() else PackedByteArray() diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_serializer.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_serializer.gd.uid new file mode 100644 index 00000000000..612452a9e7c --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/bsatn_serializer.gd.uid @@ -0,0 +1 @@ +uid://byq2iw48x718h diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/data_decompressor.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/data_decompressor.gd new file mode 100644 index 00000000000..17b4b0ba5f9 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/data_decompressor.gd @@ -0,0 +1,34 @@ +class_name DataDecompressor extends RefCounted + +static func decompress_packet(compressed_bytes: PackedByteArray) -> PackedByteArray: + if compressed_bytes.is_empty(): + return PackedByteArray() + + var gzip_stream := StreamPeerGZIP.new() + + if gzip_stream.start_decompression() != OK: + printerr("DataDecompressor Error: Failed to start Gzip decompression.") + return [] + + if gzip_stream.put_data(compressed_bytes) != OK: + printerr("DataDecompressor Error: Failed to put data into Gzip stream.") + return [] + + var decompressed_data := PackedByteArray() + var chunk_size := 4096 + + while true: + var result: Array = gzip_stream.get_partial_data(chunk_size) + var status: Error = result[0] + var chunk: PackedByteArray = result[1] + + if status == OK: + if chunk.is_empty(): + break + decompressed_data.append_array(chunk) + elif status == ERR_UNAVAILABLE: + break + else: + printerr("DataDecompressor Error: Failed while getting partial data.") + return [] + return decompressed_data diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/data_decompressor.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/data_decompressor.gd.uid new file mode 100644 index 00000000000..79fd677789f --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/data_decompressor.gd.uid @@ -0,0 +1 @@ +uid://b832qsgtbnjmd diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/local_database.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/local_database.gd new file mode 100644 index 00000000000..9fa0efe11d6 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/local_database.gd @@ -0,0 +1,209 @@ +class_name LocalDatabase extends Node + +var _tables: Dictionary[String, Dictionary] = {} +var _primary_key_cache: Dictionary = {} +var _schema: SpacetimeDBSchema + +var _cached_normalized_table_names: Dictionary = {} +var _cached_pk_fields: Dictionary = {} +var _insert_listeners_by_table: Dictionary = {} +var _update_listeners_by_table: Dictionary = {} +var _delete_listeners_by_table: Dictionary = {} +var _delete_key_listeners_by_table: Dictionary = {} +var _transactions_completed_listeners_by_table: Dictionary = {} + +signal row_inserted(table_name: String, row: _ModuleTableType) +signal row_updated(table_name: String, old_row: _ModuleTableType, new_row: _ModuleTableType) +signal row_deleted(table_name: String, row: _ModuleTableType) +signal row_transactions_completed(table_name: String) + +func _init(p_schema: SpacetimeDBSchema): + # Initialize _tables dictionary with known table names + _schema = p_schema + for table_name_lower in _schema.tables.keys(): + _tables[table_name_lower] = {} + +func subscribe_to_inserts(table_name: StringName, callable: Callable): + if not _insert_listeners_by_table.has(table_name): + _insert_listeners_by_table[table_name] = [] + if not _insert_listeners_by_table[table_name].has(callable): + _insert_listeners_by_table[table_name].append(callable) + +func unsubscribe_from_inserts(table_name: StringName, callable: Callable): + if _insert_listeners_by_table.has(table_name): + _insert_listeners_by_table[table_name].erase(callable) + if _insert_listeners_by_table[table_name].is_empty(): + _insert_listeners_by_table.erase(table_name) + +func subscribe_to_updates(table_name: StringName, callable: Callable): + if not _update_listeners_by_table.has(table_name): + _update_listeners_by_table[table_name] = [] + if not _update_listeners_by_table[table_name].has(callable): + _update_listeners_by_table[table_name].append(callable) + +func unsubscribe_from_updates(table_name: StringName, callable: Callable): + if _update_listeners_by_table.has(table_name): + _update_listeners_by_table[table_name].erase(callable) + if _update_listeners_by_table[table_name].is_empty(): + _update_listeners_by_table.erase(table_name) + +func subscribe_to_deletes(table_name: StringName, callable: Callable): + if not _delete_listeners_by_table.has(table_name): + _delete_listeners_by_table[table_name] = [] + if not _delete_listeners_by_table[table_name].has(callable): + _delete_listeners_by_table[table_name].append(callable) + +func unsubscribe_from_deletes(table_name: StringName, callable: Callable): + if _delete_listeners_by_table.has(table_name): + _delete_listeners_by_table[table_name].erase(callable) + if _delete_listeners_by_table[table_name].is_empty(): + _delete_listeners_by_table.erase(table_name) + +func subscribe_to_transactions_completed(table_name: StringName, callable: Callable): + if not _transactions_completed_listeners_by_table.has(table_name): + _transactions_completed_listeners_by_table[table_name] = [] + if not _transactions_completed_listeners_by_table[table_name].has(callable): + _transactions_completed_listeners_by_table[table_name].append(callable) + +func unsubscribe_from_transactions_completed(table_name: StringName, callable: Callable): + if _transactions_completed_listeners_by_table.has(table_name): + _transactions_completed_listeners_by_table[table_name].erase(callable) + if _transactions_completed_listeners_by_table[table_name].is_empty(): + _transactions_completed_listeners_by_table.erase(table_name) + +# --- Primary Key Handling --- +# Finds and caches the primary key field name for a given schema +func _get_primary_key_field(table_name_lower: String) -> StringName: + if _primary_key_cache.has(table_name_lower): + return _primary_key_cache[table_name_lower] + + if not _schema.types.has(table_name_lower): + printerr("LocalDatabase: No schema found for table '", table_name_lower, "' to determine PK.") + return &"" # Return empty StringName + + var schema := _schema.get_type(table_name_lower) + var instance = schema.new() # Need instance for metadata/properties + + # 1. Check metadata (preferred) + if instance and instance.has_meta("primary_key"): + var pk_field : StringName = instance.get_meta("primary_key") + _primary_key_cache[table_name_lower] = pk_field + return pk_field + + # 2. Convention: Check for "identity" or "id" field + var properties = schema.get_script_property_list() + for prop in properties: + if prop.usage & PROPERTY_USAGE_STORAGE: + if prop.name == &"identity" or prop.name == &"id": + _primary_key_cache[table_name_lower] = prop.name + return prop.name + # 3. Fallback: Assume first exported property (less reliable) + # Uncomment if this is your desired convention + # _primary_key_cache[table_name_lower] = prop.name + # return prop.name + + printerr("LocalDatabase: Could not determine primary key for table '", table_name_lower, "'. Add metadata or use convention.") + _primary_key_cache[table_name_lower] = &"" # Cache failure + return &"" + + +# --- Applying Updates --- + +func apply_database_update(db_update: DatabaseUpdateData): + if not db_update: return + for table_update: TableUpdateData in db_update.tables: + apply_table_update(table_update) + +func apply_table_update(table_update: TableUpdateData): + var table_name_original := StringName(table_update.table_name) + var table_name_lower: String + + if _cached_normalized_table_names.has(table_name_original): + table_name_lower = _cached_normalized_table_names[table_name_original] + else: + table_name_lower = table_update.table_name.to_lower().replace("_", "") + _cached_normalized_table_names[table_name_original] = table_name_lower + + if not _tables.has(table_name_lower): + printerr("LocalDatabase: Received update for unknown table '", table_name_original, "' (normalized: '", table_name_lower, "')") + return + + var pk_field: StringName + if _cached_pk_fields.has(table_name_lower): + pk_field = _cached_pk_fields[table_name_lower] + else: + pk_field = _get_primary_key_field(table_name_lower) + if pk_field == &"": + printerr("LocalDatabase: Cannot apply update for table '", table_name_original, "' without primary key.") + return + _cached_pk_fields[table_name_lower] = pk_field + + var table_dict := _tables[table_name_lower] + + var inserted_pks_set: Dictionary = {} # { pk_value: true } + + for inserted_row: _ModuleTableType in table_update.inserts: + var pk_value = inserted_row.get(pk_field) + if pk_value == null: + push_error("LocalDatabase: Inserted row for table '", table_name_original, "' has null PK value for field '", pk_field, "'. Skipping.") + continue + + inserted_pks_set[pk_value] = true + + var prev_row_resource: _ModuleTableType = table_dict.get(pk_value, null) + + table_dict[pk_value] = inserted_row + if prev_row_resource != null: + if _update_listeners_by_table.has(table_name_original): + for listener: Callable in _update_listeners_by_table[table_name_original]: + listener.call(prev_row_resource, inserted_row) + row_updated.emit(table_name_original, prev_row_resource, inserted_row) + else: + if _insert_listeners_by_table.has(table_name_original): + for listener: Callable in _insert_listeners_by_table[table_name_original]: + listener.call(inserted_row) + row_inserted.emit(table_name_original, inserted_row) + + for deleted_row: _ModuleTableType in table_update.deletes: + var pk_value = deleted_row.get(pk_field) + if pk_value == null: + push_warning("LocalDatabase: Deleted row for table '", table_name_original, "' has null PK value for field '", pk_field, "'. Skipping.") + continue + + if not inserted_pks_set.has(pk_value): + if table_dict.erase(pk_value): + if _delete_listeners_by_table.has(table_name_original): + for listener: Callable in _delete_listeners_by_table[table_name_original]: + listener.call(deleted_row) + row_deleted.emit(table_name_original, deleted_row) + + if _transactions_completed_listeners_by_table.has(table_name_original): + for listener: Callable in _transactions_completed_listeners_by_table[table_name_original]: + listener.call() + row_transactions_completed.emit(table_name_original) + +# --- Access Methods --- +func get_row_by_pk(table_name: String, primary_key_value) -> _ModuleTableType: + var table_name_lower := table_name.to_lower().replace("_","") + if _tables.has(table_name_lower): + return _tables[table_name_lower].get(primary_key_value) + return null + +func get_all_rows(table_name: String) -> Array[_ModuleTableType]: + var rows = _get_all_rows_untyped(table_name) + var typed_result_array: Array[_ModuleTableType] = [] + typed_result_array.assign(rows) + + return typed_result_array + +func count_all_rows(table_name: String) -> int: + var rows = _get_all_rows_untyped(table_name) + return rows.size() + +func _get_all_rows_untyped(table_name: String) -> Array: + var table_name_lower := table_name.to_lower().replace("_","") + if _tables.has(table_name_lower): + var table_dict := _tables[table_name_lower] + return table_dict.values() + + return [] diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/local_database.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/local_database.gd.uid new file mode 100644 index 00000000000..26f30b3e632 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/local_database.gd.uid @@ -0,0 +1 @@ +uid://cs1r6qc8ma0fa diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table.gd new file mode 100644 index 00000000000..1080d6244b7 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table.gd @@ -0,0 +1,30 @@ +class_name _ModuleTable extends RefCounted + +var _db: LocalDatabase + +func _init(db: LocalDatabase) -> void: + _db = db + +func count() -> int: + return _db.count_all_rows(get_meta("table_name", "")) + +func iter() -> Array: + return _db.get_all_rows(get_meta("table_name", "")) + +func on_insert(listener: Callable) -> void: + _db.subscribe_to_inserts(get_meta("table_name", ""), listener) + +func remove_on_insert(listener: Callable) -> void: + _db.unsubscribe_from_inserts(get_meta("table_name", ""), listener) + +func on_update(listener: Callable) -> void: + _db.subscribe_to_updates(get_meta("table_name", ""), listener) + +func remove_on_update(listener: Callable) -> void: + _db.unsubscribe_from_updates(get_meta("table_name", ""), listener) + +func on_delete(listener: Callable) -> void: + _db.subscribe_to_deletes(get_meta("table_name", ""), listener) + +func remove_on_delete(listener: Callable) -> void: + _db.unsubscribe_from_deletes(get_meta("table_name", ""), listener) diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table.gd.uid new file mode 100644 index 00000000000..7edbad77951 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table.gd.uid @@ -0,0 +1 @@ +uid://gs230ykktnfn diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_type.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_type.gd new file mode 100644 index 00000000000..e4037242ab5 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_type.gd @@ -0,0 +1 @@ +class_name _ModuleTableType extends Resource diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_type.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_type.gd.uid new file mode 100644 index 00000000000..fd061616502 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_type.gd.uid @@ -0,0 +1 @@ +uid://dvhukr5cdcxxs diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_unique_index.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_unique_index.gd new file mode 100644 index 00000000000..3c171f6773f --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_unique_index.gd @@ -0,0 +1,22 @@ +class_name _ModuleTableUniqueIndex extends Resource + +func _connect_cache_to_db(cache: Dictionary, db: LocalDatabase) -> void: + var table_name: String = get_meta("table_name", "") + var field_name: String = get_meta("field_name", "") + + db.subscribe_to_inserts(table_name, func(r: _ModuleTableType): + var col_val = r[field_name] + cache[col_val] = r + ) + db.subscribe_to_updates(table_name, func(p: _ModuleTableType, r: _ModuleTableType): + var previous_col_val = p[field_name] + var col_val = r[field_name] + + if previous_col_val != col_val: + cache.erase(previous_col_val) + cache[col_val] = r + ) + db.subscribe_to_deletes(table_name, func(r: _ModuleTableType): + var col_val = r[field_name] + cache.erase(col_val) + ) diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_unique_index.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_unique_index.gd.uid new file mode 100644 index 00000000000..0381b59ec37 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/module_table_unique_index.gd.uid @@ -0,0 +1 @@ +uid://dm0w8gfdcmjk1 diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_client.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_client.gd new file mode 100644 index 00000000000..8287f5093d7 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_client.gd @@ -0,0 +1,512 @@ +@tool +class_name SpacetimeDBClient extends Node + +# --- Configuration --- +@export var base_url: String = "http://127.0.0.1:3000" +@export var database_name: String = "quickstart-chat" # Example +@export var schema_path: String = "res://spacetime_bindings/schema" +@export var auto_connect: bool = false +@export var auto_request_token: bool = true +@export var token_save_path: String = "user://spacetimedb_token.dat" # Use a more specific name +@export var one_time_token: bool = false +@export var compression: SpacetimeDBConnection.CompressionPreference +@export var debug_mode: bool = true +@export var current_subscriptions: Dictionary[int, SpacetimeDBSubscription] +@export var use_threading: bool = true + +var deserializer_worker: Thread +var _packet_queue: Array[PackedByteArray] = [] +var _packet_semaphore: Semaphore +var _result_queue: Array[Resource] = [] +var _result_mutex: Mutex +var _packet_mutex: Mutex +var _thread_should_exit: bool = false +var _message_limit_in_frame: int = 5 + +var connection_options: SpacetimeDBConnectionOptions +var pending_subscriptions: Dictionary[int, SpacetimeDBSubscription] + +# --- Components --- +var _connection: SpacetimeDBConnection +var _deserializer: BSATNDeserializer +var _serializer: BSATNSerializer +var _local_db: LocalDatabase +var _rest_api: SpacetimeDBRestAPI # Optional, for token/REST calls + +# --- State --- +var _connection_id: PackedByteArray +var _identity: PackedByteArray +var _token: String +var _is_initialized := false +var _received_initial_subscription := false +var _next_query_id := 0 +var _next_request_id := 0 + +# --- Signals --- +signal connected(identity: PackedByteArray, token: String) +signal disconnected +signal connection_error(code: int, reason: String) +signal database_initialized # Emitted after InitialSubscription is processed +signal database_update(table_update: TableUpdateData) # Emitted for each table update + +# From LocalDatabase +signal row_inserted(table_name: String, row: Resource) +signal row_updated(table_name: String, old_row: Resource, new_row: Resource) +signal row_deleted(table_name: String, row: Resource) +signal row_transactions_completed(table_name: String) + +signal reducer_call_response(response: Resource) # TODO: Define response resource +signal reducer_call_timeout(request_id: int) # TODO: Implement timeout logic +signal transaction_update_received(update: TransactionUpdateMessage) + +func _ready(): + if auto_connect: + initialize_and_connect() + +func _exit_tree(): + if deserializer_worker: + _thread_should_exit = true + _packet_semaphore.post() + deserializer_worker.wait_to_finish() + deserializer_worker = null + +func print_log(log_message: String): + if debug_mode: + print(log_message) + +func initialize_and_connect(): + if _is_initialized: + return + + print_log("SpacetimeDBClient: Initializing...") + + # 1. Load Schema + var module_name: String = get_meta("module_name", "") + var schema := SpacetimeDBSchema.new(module_name, schema_path, debug_mode) + + # 2. Initialize Parser + _deserializer = BSATNDeserializer.new(schema, debug_mode) + _serializer = BSATNSerializer.new() + + # 3. Initialize Local Database + _local_db = LocalDatabase.new(schema) + _init_db(_local_db) + + # Connect to LocalDatabase signals to re-emit them + _local_db.row_inserted.connect(func(tn, r) -> void: row_inserted.emit(tn, r)) + _local_db.row_updated.connect(func(tn, p, r) -> void: row_updated.emit(tn, p, r)) + _local_db.row_deleted.connect(func(tn, r) -> void: row_deleted.emit(tn, r)) + _local_db.row_transactions_completed.connect(func(tn) -> void: row_transactions_completed.emit(tn)) + _local_db.name = "LocalDatabase" + add_child(_local_db) # Add as child if it needs signals + + # 4. Initialize REST API Handler (optional, mainly for token) + _rest_api = SpacetimeDBRestAPI.new(base_url, debug_mode) + _rest_api.token_received.connect(_on_token_received) + _rest_api.token_request_failed.connect(_on_token_request_failed) + _rest_api.name = "RestAPI" + add_child(_rest_api) + + # 5. Initialize Connection Handler + _connection = SpacetimeDBConnection.new(connection_options) + _connection.disconnected.connect(func(): disconnected.emit()) + _connection.connection_error.connect(func(c, r): connection_error.emit(c, r)) + _connection.message_received.connect(_on_websocket_message_received) + _connection.name = "Connection" + add_child(_connection) + + _is_initialized = true + print_log("SpacetimeDBClient: Initialization complete.") + + # 6. Get Token and Connect + _load_token_or_request() + +func _init_db(local_db: LocalDatabase) -> void: + pass + +func _load_token_or_request(): + if _token: + # If token is already set, use it + _on_token_received(_token) + return + + if one_time_token == false: + # Try loading saved token + if FileAccess.file_exists(token_save_path): + var file := FileAccess.open(token_save_path, FileAccess.READ) + if file: + var saved_token := file.get_as_text().strip_edges() + file.close() + if not saved_token.is_empty(): + print_log("SpacetimeDBClient: Using saved token.") + _on_token_received(saved_token) # Directly use the saved token + return + + # If no valid saved token, request a new one if auto-request is enabled + if auto_request_token: + print_log("SpacetimeDBClient: No valid saved token found, requesting new one.") + _rest_api.request_new_token() + else: + printerr("SpacetimeDBClient: No token available and auto_request_token is false.") + emit_signal("connection_error", -1, "Authentication token unavailable") + +func _generate_connection_id() -> String: + var random_bytes := PackedByteArray() + random_bytes.resize(16) + var rng := RandomNumberGenerator.new() + for i in 16: + random_bytes[i] = rng.randi_range(0, 255) + return random_bytes.hex_encode() # Return as hex string + +func _on_token_received(received_token: String): + print_log("SpacetimeDBClient: Token acquired.") + self._token = received_token + if not one_time_token: + _save_token(received_token) + var conn_id = _generate_connection_id() + # Pass token to components that need it + _connection.set_token(self._token) + _rest_api.set_token(self._token) # REST API might also need it + + # Now attempt to connect WebSocket + _connection.connect_to_database(base_url, database_name, conn_id) + +func _on_token_request_failed(error_code: int, response_body: String): + printerr("SpacetimeDBClient: Failed to acquire token. Cannot connect.") + emit_signal("connection_error", error_code, "Failed to acquire authentication token") + +func _save_token(token_to_save: String): + var file := FileAccess.open(token_save_path, FileAccess.WRITE) + if file: + file.store_string(token_to_save) + file.close() + else: + printerr("SpacetimeDBClient: Failed to save token to path: ", token_save_path) + +# --- WebSocket Message Handling --- +func _physics_process(_delta: float) -> void: + _process_results_asynchronously() + +func _on_websocket_message_received(raw_bytes: PackedByteArray): + if not _is_initialized: return + if use_threading: + _packet_mutex.lock() + _packet_queue.append(raw_bytes) + _packet_mutex.unlock() + _packet_semaphore.post() + else: + var message = _parse_packet_and_get_resource(_decompress_and_parse(raw_bytes)) + _result_queue.append(message) + +func _thread_loop() -> void: + while not _thread_should_exit: + _packet_semaphore.wait() + if _thread_should_exit: break + + _packet_mutex.lock() + + if _packet_queue.is_empty(): + _packet_mutex.unlock() + continue + + var packet_to_process: PackedByteArray = _packet_queue.pop_back() + _packet_mutex.unlock() + + var message_resource: Resource = null + var payload := _decompress_and_parse(packet_to_process) + message_resource = _parse_packet_and_get_resource(payload) + + if message_resource: + _result_mutex.lock() + _result_queue.append(message_resource) + _result_mutex.unlock() + +func _process_results_asynchronously(): + if use_threading and not _result_mutex: return + + if use_threading: _result_mutex.lock() + + if _result_queue.is_empty(): + if use_threading: _result_mutex.unlock() + return + + var processed_count = 0 + + while not _result_queue.is_empty() and processed_count < _message_limit_in_frame: + _handle_parsed_message(_result_queue.pop_front()) + processed_count += 1 + + if use_threading: _result_mutex.unlock() + +func _decompress_and_parse(raw_bytes: PackedByteArray) -> PackedByteArray: + var compression = raw_bytes[0] + var payload = raw_bytes.slice(1) + match compression: + 0: pass + 1: printerr("SpacetimeDBClient (Thread) : Brotli compression not supported!") + 2: payload = DataDecompressor.decompress_packet(payload) + return payload + +func _parse_packet_and_get_resource(bsatn_bytes: PackedByteArray) -> Resource: + if not _deserializer: return null + + var result := _deserializer.process_bytes_and_extract_messages(bsatn_bytes) + if result.is_empty(): return null + var message_resource: Resource = result[0] + + if _deserializer.has_error(): + printerr("SpacetimeDBClient: Failed to parse BSATN packet: ", _deserializer.get_last_error()) + return null + + return message_resource + +func _handle_parsed_message(message_resource: Resource): + if message_resource == null: + printerr("SpacetimeDBClient: Parser returned null message resource.") + return + + # Handle known message types + if message_resource is InitialSubscriptionMessage: + var initial_sub: InitialSubscriptionMessage = message_resource + print_log("SpacetimeDBClient: Processing Initial Subscription (Query ID: %d)" % initial_sub.query_id.id) + _local_db.apply_database_update(initial_sub.database_update) + if not _received_initial_subscription: + _received_initial_subscription = true + self.database_initialized.emit() + + elif message_resource is SubscribeMultiAppliedMessage: + var sub: SubscribeMultiAppliedMessage = message_resource + print_log("SpacetimeDBClient: Processing Multi Subscription (Query ID: %d)" % sub.query_id.id) + _local_db.apply_database_update(sub.database_update) + if pending_subscriptions.has(sub.query_id.id): + var subscription := pending_subscriptions[sub.query_id.id] + current_subscriptions[sub.query_id.id] = subscription + pending_subscriptions.erase(sub.query_id.id) + subscription.applied.emit() + + if not _received_initial_subscription: + _received_initial_subscription = true + self.database_initialized.emit() + + elif message_resource is UnsubscribeMultiAppliedMessage: + var unsub: UnsubscribeMultiAppliedMessage = message_resource + _local_db.apply_database_update(unsub.database_update) + print_log("Unsubscribe: " + str(current_subscriptions[unsub.query_id.id])) + if current_subscriptions.has(unsub.query_id.id): + var subscription := current_subscriptions[unsub.query_id.id] + current_subscriptions.erase(unsub.query_id.id) + subscription.end.emit() + subscription.queue_free() + + elif message_resource is IdentityTokenMessage: + var identity_token: IdentityTokenMessage = message_resource + print_log("SpacetimeDBClient: Received Identity Token.") + _identity = identity_token.identity + if not _token and identity_token.token: + _token = identity_token.token + _connection_id = identity_token.connection_id + self.connected.emit(_identity, _token) + + elif message_resource is TransactionUpdateMessage: + var tx_update: TransactionUpdateMessage = message_resource + #print_log("SpacetimeDBClient: Processing Transaction Update (Reducer: %s, Req ID: %d)" % [tx_update.reducer_call.reducer_name, tx_update.reducer_call.request_id]) + # Apply changes to local DB only if committed + if tx_update.status.status_type == UpdateStatusData.StatusType.COMMITTED: + if tx_update.status.committed_update: # Check if update data exists + _local_db.apply_database_update(tx_update.status.committed_update) + else: + # This might happen if a transaction committed but affected 0 rows relevant to the client + print_log("SpacetimeDBClient: Committed transaction had no relevant row updates.") + elif tx_update.status.status_type == UpdateStatusData.StatusType.FAILED: + printerr("SpacetimeDBClient: Reducer call failed: ", tx_update.status.failure_message) + elif tx_update.status.status_type == UpdateStatusData.StatusType.OUT_OF_ENERGY: + printerr("SpacetimeDBClient: Reducer call ran out of energy.") + + # Emit the full transaction update signal regardless of status + self.transaction_update_received.emit(tx_update) + + else: + print_log("SpacetimeDBClient: Received unhandled message resource type: " + message_resource.get_class()) + + +# --- Public API --- + +func connect_db(host_url: String, database_name: String, options: SpacetimeDBConnectionOptions = null): + if not options: + options = SpacetimeDBConnectionOptions.new() + connection_options = options + self.base_url = host_url + self.database_name = database_name + self.compression = options.compression + self.one_time_token = options.one_time_token + if options.token: + self._token = options.token + self.debug_mode = options.debug_mode + self.use_threading = options.threading + + if OS.has_feature("web") and use_threading == true: + push_error("Threads are not supported on Web. Threading has been disabled.") + use_threading = false + + if use_threading: + _packet_mutex = Mutex.new() + _packet_semaphore = Semaphore.new() + _result_mutex = Mutex.new() + deserializer_worker = Thread.new() + deserializer_worker.start(_thread_loop) + + if not _is_initialized: + initialize_and_connect() + elif not _connection.is_connected_db(): + # Already initialized, just need token and connect + _load_token_or_request() + +func disconnect_db(): + _token = "" + if _connection: + _connection.disconnect_from_server() + +func is_connected_db() -> bool: + return _connection and _connection.is_connected_db() + +# The untyped local database instance, use the generated .Db property for querying +func get_local_database() -> LocalDatabase: + return _local_db + +func get_local_identity() -> PackedByteArray: + return _identity + +func subscribe(queries: PackedStringArray) -> SpacetimeDBSubscription: + if not is_connected_db(): + printerr("SpacetimeDBClient: Cannot subscribe, not connected.") + return SpacetimeDBSubscription.fail(ERR_CONNECTION_ERROR) + + # 1. Generate a request ID + var query_id := _next_query_id + _next_query_id += 1 + # 2. Create the correct payload Resource + var payload_data := SubscribeMultiMessage.new(queries, query_id) + + # 3. Serialize the complete ClientMessage using the universal function + var message_bytes := _serializer.serialize_client_message( + SpacetimeDBClientMessage.SUBSCRIBE_MULTI, + payload_data + ) + + if _serializer.has_error(): + printerr("SpacetimeDBClient: Failed to serialize SubscribeMulti message: %s" % _serializer.get_last_error()) + return SpacetimeDBSubscription.fail(ERR_PARSE_ERROR) + + # 4. Create subscription handle + var subscription := SpacetimeDBSubscription.create(self, query_id, queries) + + # 5. Send the binary message via WebSocket + if _connection and _connection._websocket: + var err := _connection.send_bytes(message_bytes) + if err != OK: + printerr("SpacetimeDBClient: Error sending SubscribeMulti BSATN message: %s" % error_string(err)) + subscription.error = err + subscription._ended = true + else: + print_log("SpacetimeDBClient: SubscribeMulti request sent successfully (BSATN), Query ID: %d" % query_id) + pending_subscriptions.set(query_id, subscription) + # Add as child for signals + subscription.name = "Subscription" + add_child(subscription) + + return subscription + + printerr("SpacetimeDBClient: Internal error - WebSocket peer not available in connection.") + subscription.error = ERR_CONNECTION_ERROR + subscription._ended = true + return subscription + +func unsubscribe(query_id: int) -> Error: + if not is_connected_db(): + printerr("SpacetimeDBClient: Cannot unsubscribe, not connected.") + return ERR_CONNECTION_ERROR + + # 1. Create the correct payload Resource + var payload_data := UnsubscribeMultiMessage.new(query_id) + + # 2. Serialize the complete ClientMessage using the universal function + var message_bytes := _serializer.serialize_client_message( + SpacetimeDBClientMessage.UNSUBSCRIBE_MULTI, + payload_data + ) + + if _serializer.has_error(): + printerr("SpacetimeDBClient: Failed to serialize SubscribeMulti message: %s" % _serializer.get_last_error()) + return ERR_PARSE_ERROR + + # 3. Send the binary message via WebSocket + if _connection and _connection._websocket: + var err := _connection.send_bytes(message_bytes) + if err != OK: + printerr("SpacetimeDBClient: Error sending SubscribeMulti BSATN message: %s" % error_string(err)) + return err + + print_log("SpacetimeDBClient: UnsubscribeMulti request sent successfully (BSATN), Query ID: %d" % query_id) + return OK + + printerr("SpacetimeDBClient: Internal error - WebSocket peer not available in connection.") + return ERR_CONNECTION_ERROR + +func call_reducer(reducer_name: String, args: Array = [], types: Array = []) -> SpacetimeDBReducerCall: + if not is_connected_db(): + printerr("SpacetimeDBClient: Cannot call reducer, not connected.") + return SpacetimeDBReducerCall.fail(ERR_CONNECTION_ERROR) + + var args_bytes := _serializer._serialize_arguments(args, types) + + if _serializer.has_error(): + printerr("Failed to serialize args for %s: %s" % [reducer_name, _serializer.get_last_error()]) + return SpacetimeDBReducerCall.fail(ERR_PARSE_ERROR) + + var request_id := _next_request_id + _next_request_id += 1 + + var call_data := CallReducerMessage.new(reducer_name, args_bytes, request_id, 0) + var message_bytes := _serializer.serialize_client_message( + SpacetimeDBClientMessage.CALL_REDUCER, + call_data + ) + + # Access the internal _websocket peer directly (might need adjustment if _connection API changes) + if _connection and _connection._websocket: # Basic check + var err := _connection.send_bytes(message_bytes) + if err != OK: + print("SpacetimeDBClient: Error sending CallReducer JSON message: ", err) + return SpacetimeDBReducerCall.fail(err) + + return SpacetimeDBReducerCall.create(self, request_id) + + print("SpacetimeDBClient: Internal error - WebSocket peer not available in connection.") + return SpacetimeDBReducerCall.fail(ERR_CONNECTION_ERROR) + +func wait_for_reducer_response(request_id_to_match: int, timeout_seconds: float = 10.0) -> TransactionUpdateMessage: + if request_id_to_match < 0: + return null + + var signal_result: TransactionUpdateMessage = null + var timeout_ms: float = timeout_seconds * 1000.0 + var start_time: float = Time.get_ticks_msec() + + while Time.get_ticks_msec() - start_time < timeout_ms: + var received_signal = await transaction_update_received + if _check_reducer_response(received_signal, request_id_to_match): + signal_result = received_signal + break + + if signal_result == null: + printerr("SpacetimeDBClient: Timeout waiting for response for Req ID: %d" % request_id_to_match) + self.reducer_call_timeout.emit(request_id_to_match) + return null + else: + var tx_update: TransactionUpdateMessage = signal_result + print_log("SpacetimeDBClient: Received matching response for Req ID: %d" % request_id_to_match) + self.reducer_call_response.emit(tx_update.reducer_call) + return tx_update + +func _check_reducer_response(update: TransactionUpdateMessage, request_id_to_match: int) -> bool: + return update != null and update.reducer_call != null and update.reducer_call.request_id == request_id_to_match diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_client.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_client.gd.uid new file mode 100644 index 00000000000..c11f37e744f --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_client.gd.uid @@ -0,0 +1 @@ +uid://qtxibldey2qi diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection.gd new file mode 100644 index 00000000000..929383a4f0e --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection.gd @@ -0,0 +1,220 @@ +@tool +class_name SpacetimeDBConnection extends Node + +var _websocket := WebSocketPeer.new() +var _target_url: String +var _token: String +var _is_connected := false +var _connection_requested := false +var _debug_mode := false + +# Protocol constants +const BSATN_PROTOCOL = "v1.bsatn.spacetimedb" + +enum CompressionPreference { NONE = 0, BROTLI = 1, GZIP = 2 } +var preferred_compression: CompressionPreference = CompressionPreference.NONE # Default to None + +var _total_bytes_send := 0 +var _second_bytes_send := 0 +var _total_bytes_received := 0 +var _second_bytes_received := 0 + +var _total_messages_send := 0 +var _second_messages_send := 0 +var _total_messages_received := 0 +var _second_messages_received := 0 + +signal connected +signal disconnected +signal connection_error(code: int, reason: String) +signal message_received(data: PackedByteArray) +signal total_messages(sent: int, received: int) +signal total_bytes(sent: int, received: int) + + +func _init(options: SpacetimeDBConnectionOptions): + #Performance.add_custom_monitor("spacetime/second_received_packets", get_second_received_packets) + #Performance.add_custom_monitor("spacetime/second_received_bytes", get_second_received_bytes) + #Performance.add_custom_monitor("spacetime/total_received_packets", get_received_packets) + #Performance.add_custom_monitor("spacetime/total_received_kbytes", get_received_kbytes) + #Performance.add_custom_monitor("spacetime/second_sent_packets", get_second_sent_packets) + #Performance.add_custom_monitor("spacetime/second_sent_bytes", get_second_sent_bytes) + #Performance.add_custom_monitor("spacetime/total_sent_packets", get_sent_packets) + #Performance.add_custom_monitor("spacetime/total_sent_kbytes", get_sent_kbytes) + + _websocket.inbound_buffer_size = options.inbound_buffer_size + _websocket.outbound_buffer_size = options.outbound_buffer_size + set_compression_preference(options.compression) + self._debug_mode = options.debug_mode + set_process(false) # Don't process until connect is called + +func _print_log(log_message:String): + if _debug_mode: + print(log_message) + +func get_second_sent_bytes(): + var amount = _second_bytes_send + _second_bytes_send = 0 + return amount + +func get_second_received_bytes(): + var amount = _second_bytes_received + _second_bytes_received = 0 + return amount + +func get_second_sent_packets(): + var amount = _second_messages_send + _second_messages_send = 0 + return amount + +func get_second_received_packets(): + var amount = _second_messages_received + _second_messages_received = 0 + return amount + +func get_sent_kbytes() -> float: + return float(float(_total_bytes_send)/1000.0) + +func get_received_kbytes() -> float: + return float(float(_total_bytes_received)/1000.0) + +func get_sent_packets(): + return _total_messages_send + +func get_received_packets(): + return _total_messages_received + +func set_token(token: String): + self._token = token + +func set_compression_preference(preference: CompressionPreference): + self.preferred_compression = preference + +func send_bytes(bytes: PackedByteArray) -> Error: + var err := _websocket.send(bytes) + if err == OK: + _second_bytes_send += bytes.size() + _total_bytes_send += bytes.size() + _second_messages_send += 1 + _total_messages_send += 1 + total_messages.emit(_total_messages_send, _total_messages_received) + total_bytes.emit(_total_bytes_send, _total_bytes_received) + return err + +func connect_to_database(base_url: String, database_name: String, connection_id: String): # Added connection_id + if _is_connected or _connection_requested: + _print_log("SpacetimeDBConnection: Already connected or connecting.") + return + + if _token.is_empty(): + _print_log("SpacetimeDBConnection: Cannot connect without auth token.") + return + + if connection_id.is_empty(): + printerr("SpacetimeDBConnection: Cannot connect without Connection ID.") + return + + # Construct WebSocket URL base + var ws_url_base := base_url.replace("http", "ws").replace("https", "wss") + ws_url_base = ws_url_base.path_join("/v1/database").path_join(database_name).path_join("subscribe") + + # --- Add Query Parameters --- + # Start with connection_id + var query_params := "?connection_id=" + connection_id + # Add compression preference + # Convert enum value to string for the URL parameter + var compression_str : String + + match preferred_compression: + CompressionPreference.NONE: compression_str = "None" # Use string "None" as seen in C# enum + CompressionPreference.BROTLI: compression_str = "Brotli" + CompressionPreference.GZIP: compression_str = "Gzip" + _: compression_str = "None" # Fallback + + + query_params += "&compression=" + compression_str + # Add light mode parameter if needed (based on C# code) + # var light_mode = false # Example + # if light_mode: + # query_params += "&light=true" + + if OS.get_name() == "Web": + query_params += "&token=" + _token + else: + var auth_header := "Authorization: Bearer " + _token + _websocket.handshake_headers = [auth_header] + + _target_url = ws_url_base + query_params + _print_log("SpacetimeDBConnection: Attempting to connect to: " + _target_url) + + _websocket.supported_protocols = [BSATN_PROTOCOL] + + var err := _websocket.connect_to_url(_target_url) + if err != OK: + printerr("SpacetimeDBConnection: Error initiating connection: ", err) + emit_signal("connection_error", err, "Failed to initiate connection") + else: + _print_log("SpacetimeDBConnection: Connection initiated.") + _connection_requested = true + set_process(true) + +func disconnect_from_server(code: int = 1000, reason: String = "Client initiated disconnect"): + if _websocket.get_ready_state() != WebSocketPeer.STATE_CLOSED: + _print_log("SpacetimeDBConnection: Closing connection...") + _websocket.close(code, reason) + _is_connected = false + _connection_requested = false + set_process(false) + +func is_connected_db() -> bool: + return _is_connected + +func _physics_process(delta: float) -> void: + if _websocket == null: return + + _websocket.poll() + var state := _websocket.get_ready_state() + + match state: + WebSocketPeer.STATE_OPEN: + if not _is_connected: + _print_log("SpacetimeDBConnection: Connection established.") + _is_connected = true + _connection_requested = false + connected.emit() + + # Process incoming packets + while _websocket.get_available_packet_count() > 0: + var packet_bytes := _websocket.get_packet() + if packet_bytes.is_empty(): continue + + _total_bytes_received += packet_bytes.size() + _second_bytes_received += packet_bytes.size() + _total_messages_received += 1 + + message_received.emit(packet_bytes) + total_messages.emit(_total_messages_send, _total_messages_received) + total_bytes.emit(_total_bytes_send, _total_bytes_received) + + WebSocketPeer.STATE_CONNECTING: + # Still trying to connect + pass + + WebSocketPeer.STATE_CLOSING: + # Connection is closing + pass + + WebSocketPeer.STATE_CLOSED: + var code := _websocket.get_close_code() + var reason := _websocket.get_close_reason() + if _is_connected or _connection_requested: # Only report if we were connected or trying + if code == -1: # Abnormal closure + printerr("SpacetimeDBConnection: Connection closed unexpectedly.") + emit_signal("connection_error", code, "Abnormal closure") + else: + _print_log("SpacetimeDBConnection: Connection closed (Code: %d, Reason: %s)" % [code, reason]) + emit_signal("disconnected") # Normal closure signal + + _is_connected = false + _connection_requested = false + set_process(false) # Stop polling diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection.gd.uid new file mode 100644 index 00000000000..cdb2da33b18 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection.gd.uid @@ -0,0 +1 @@ +uid://buyvfw02d6hyk diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection_options.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection_options.gd new file mode 100644 index 00000000000..afbfc2f92c9 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection_options.gd @@ -0,0 +1,15 @@ +class_name SpacetimeDBConnectionOptions extends Resource + +const CompressionPreference = SpacetimeDBConnection.CompressionPreference + +var compression: CompressionPreference = CompressionPreference.NONE +var threading: bool = true +var one_time_token: bool = true +var token: String = "" +var debug_mode: bool = false +var inbound_buffer_size: int = 1024 * 1024 * 2 # 2MB +var outbound_buffer_size: int = 1024 * 1024 * 2 # 2MB + +func set_all_buffer_size(size: int): + inbound_buffer_size = size + outbound_buffer_size = size diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection_options.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection_options.gd.uid new file mode 100644 index 00000000000..c8924f173a8 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_connection_options.gd.uid @@ -0,0 +1 @@ +uid://0kr0ftnvrnur diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_reducer_call.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_reducer_call.gd new file mode 100644 index 00000000000..83b22f08c5c --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_reducer_call.gd @@ -0,0 +1,27 @@ +class_name SpacetimeDBReducerCall extends Resource + +var request_id: int = -1 +var error: Error = OK + +var _client: SpacetimeDBClient + +static func create( + p_client: SpacetimeDBClient, + p_request_id: int +) -> SpacetimeDBReducerCall: + var reducer_call := SpacetimeDBReducerCall.new() + reducer_call._client = p_client + reducer_call.request_id = p_request_id + + return reducer_call + +static func fail(error: Error) -> SpacetimeDBReducerCall: + var reducer_call := SpacetimeDBReducerCall.new() + reducer_call.error = error + return reducer_call + +func wait_for_response(timeout_sec: float = 10) -> TransactionUpdateMessage: + if error: + return null + + return await _client.wait_for_reducer_response(request_id, timeout_sec) diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_reducer_call.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_reducer_call.gd.uid new file mode 100644 index 00000000000..1bb199a3717 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_reducer_call.gd.uid @@ -0,0 +1 @@ +uid://chqngkbs8sdjs diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_rest_api.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_rest_api.gd new file mode 100644 index 00000000000..0ab9513e4ab --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_rest_api.gd @@ -0,0 +1,147 @@ +class_name SpacetimeDBRestAPI extends Node + +# Enum to track the type of the currently pending request +enum RequestType { NONE, TOKEN, REDUCER_CALL } # Add more if needed for other REST calls + +var _http_request := HTTPRequest.new() +var _base_url: String +var _token: String +# State variable to track the expected response type +var _pending_request_type := RequestType.NONE +var _debug_mode := false + +signal token_received(token: String) +signal token_request_failed(error_code: int, response_body: String) +signal reducer_call_completed(result: Dictionary) # Or specific resource +signal reducer_call_failed(error_code: int, response_body: String) + +func _init(base_url: String, debug_mode: bool): + self._base_url = base_url + self._debug_mode = debug_mode + add_child(_http_request) + # Connect the signal ONCE + if not _http_request.is_connected("request_completed", Callable(self, "_on_request_completed")): + _http_request.request_completed.connect(_on_request_completed) + +func print_log(log_message: String): + if _debug_mode: + print(log_message) + +func set_token(token: String): + self._token = token + +# --- Token Management --- + +func request_new_token(): + # Prevent concurrent requests if this handler isn't designed for it + if _pending_request_type != RequestType.NONE: + printerr("SpacetimeDBRestAPI: Cannot request token while another request is pending (%s)." % RequestType.keys()[_pending_request_type]) + # Optionally queue or emit a busy error + return + + print_log("SpacetimeDBRestAPI: Requesting new token...") + var url := _base_url.path_join("/v1/identity") + # Set state *before* making the request + _pending_request_type = RequestType.TOKEN + var error := _http_request.request(url, [], HTTPClient.METHOD_POST) + if error != OK: + printerr("SpacetimeDBRestAPI: Error initiating token request: ", error) + # Reset state on immediate failure + _pending_request_type = RequestType.NONE + emit_signal("token_request_failed", error, "Failed to initiate request") + +func _handle_token_response(result_code: int, response_code: int, headers: PackedStringArray, body: PackedByteArray): + # (Logic for handling token response - remains the same as before) + if result_code != HTTPRequest.RESULT_SUCCESS: + printerr("SpacetimeDBRestAPI: Token request failed. Result code: ", result_code) + emit_signal("token_request_failed", result_code, body.get_string_from_utf8()) + return + + var body_text := body.get_string_from_utf8() + var json = JSON.parse_string(body_text) + + if response_code >= 400 or json == null: + printerr("SpacetimeDBRestAPI: Token request failed. Response code: ", response_code) + printerr("SpacetimeDBRestAPI: Response body: ", body_text) + emit_signal("token_request_failed", response_code, body_text) + return + + if json.has("token") and json.token is String and not json.token.is_empty(): + var new_token: String = json.token + print_log("SpacetimeDBRestAPI: New token received.") + set_token(new_token) # Store it internally as well + emit_signal("token_received", new_token) + else: + printerr("SpacetimeDBRestAPI: Token not found or empty in JSON response: ", body_text) + emit_signal("token_request_failed", response_code, "Invalid token format in response") + + +# --- Reducer Call (REST Example) --- + +func call_reducer(database: String, reducer_name: String, args: Dictionary): + if _pending_request_type != RequestType.NONE: + printerr("SpacetimeDBRestAPI: Cannot call reducer while another request is pending (%s)." % RequestType.keys()[_pending_request_type]) + emit_signal("reducer_call_failed", -1, "Another request pending") + return + + if _token.is_empty(): + printerr("SpacetimeDBRestAPI: Cannot call reducer without auth token.") + emit_signal("reducer_call_failed", -1, "Auth token not set") + return + + var url := _base_url.path_join("/v1/database").path_join(database).path_join("call").path_join(reducer_name) + var headers := [ + "Authorization: Bearer " + _token, + "Content-Type: application/json" + ] + var body := JSON.stringify(args) + + # Set state *before* making the request + _pending_request_type = RequestType.REDUCER_CALL + var error := _http_request.request(url, headers, HTTPClient.METHOD_POST, body) + if error != OK: + printerr("SpacetimeDBRestAPI: Error initiating reducer call request: ", error) + # Reset state on immediate failure + _pending_request_type = RequestType.NONE + emit_signal("reducer_call_failed", error, "Failed to initiate request") + +func _handle_reducer_response(result_code: int, response_code: int, headers: PackedStringArray, body: PackedByteArray): + # (Logic for handling reducer response - remains the same as before) + if result_code != HTTPRequest.RESULT_SUCCESS or response_code >= 400: + printerr("SpacetimeDBRestAPI: Reducer call failed. Result: %d, Code: %d" % [result_code, response_code]) + printerr("SpacetimeDBRestAPI: Response body: ", body.get_string_from_utf8()) + emit_signal("reducer_call_failed", response_code, body.get_string_from_utf8()) + return + + var body_text := body.get_string_from_utf8() + var json = JSON.parse_string(body_text) + if json == null: + printerr("SpacetimeDBRestAPI: Failed to parse reducer response JSON: ", body_text) + emit_signal("reducer_call_failed", response_code, "Invalid JSON response") + return + + emit_signal("reducer_call_completed", json) + + +# --- Request Completion Handler --- + +func _on_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray): + # Capture the type of request that was pending *before* resetting state + var request_type_that_completed := _pending_request_type + # Reset state immediately, allowing new requests + _pending_request_type = RequestType.NONE + + # Route the response based on the captured state + match request_type_that_completed: + RequestType.TOKEN: + #print("SpacetimeDBRestAPI: Handling completed request as TOKEN") # Debug line + _handle_token_response(result, response_code, headers, body) + RequestType.REDUCER_CALL: + #print("SpacetimeDBRestAPI: Handling completed request as REDUCER_CALL") # Debug line + _handle_reducer_response(result, response_code, headers, body) + RequestType.NONE: + # This might happen if the request failed immediately before the state was properly set, + # or if the signal fires unexpectedly after state reset (less likely). + push_warning("SpacetimeDBRestAPI: Received request completion signal but no request type was pending.") + _: + printerr("SpacetimeDBRestAPI: Internal error - completed request type was unknown: ", request_type_that_completed) diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_rest_api.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_rest_api.gd.uid new file mode 100644 index 00000000000..1d165aecf5c --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_rest_api.gd.uid @@ -0,0 +1 @@ +uid://c4myq3ututc4a diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_schema.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_schema.gd new file mode 100644 index 00000000000..2271eeb7b03 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_schema.gd @@ -0,0 +1,86 @@ +class_name SpacetimeDBSchema extends Resource + +var types: Dictionary[String, GDScript] = {} +var tables: Dictionary[String, GDScript] = {} + +var debug_mode: bool = false # Controls verbose debug printing + +func _init(p_module_name: String, p_schema_path: String = "res://spacetime_bindings/schema", p_debug_mode: bool = false) -> void: + debug_mode = p_debug_mode + + # Load table row schemas and spacetime types + _load_types("%s/types" % p_schema_path, p_module_name.to_snake_case()) + # Load core types if they are defined as Resources with scripts + _load_types("res://addons/SpacetimeDB/core_types/**") + +func _load_types(raw_path: String, prefix: String = "") -> void: + var path := raw_path + if path.ends_with("/**"): + path = path.left(-3) + + var dir := DirAccess.open(path) + if not DirAccess.dir_exists_absolute(path): + printerr("SpacetimeDBSchema: Schema directory does not exist: ", path) + return + + dir.list_dir_begin() + while true: + var file_name_raw := dir.get_next() + if file_name_raw == "": + break + + if dir.current_is_dir(): + var dir_name := file_name_raw + if dir_name != "." and dir_name != ".." and raw_path.ends_with("/**"): + var dir_path := path.path_join(dir_name) + _load_types(dir_path.path_join("/**"), prefix) + continue + + var file_name := file_name_raw + + # Handle potential remapping on export + if file_name.ends_with(".remap"): + file_name = file_name.replace(".remap", "") + if not file_name.ends_with(".gd"): + file_name += ".gd" + + if not file_name.ends_with(".gd"): + continue + + if prefix != "" and not file_name.begins_with(prefix): + continue + + var script_path := path.path_join(file_name) + if not ResourceLoader.exists(script_path): + printerr("SpacetimeDBSchema: Script file not found or inaccessible: ", script_path, " (Original name: ", file_name_raw, ")") + continue + + var script := ResourceLoader.load(script_path, "GDScript") as GDScript + + if script and script.can_instantiate(): + var instance = script.new() + if instance is Resource: # Ensure it's a resource to get metadata + var fallback_table_names: Array[String] = [file_name.get_basename().get_file()] + + var constants := script.get_script_constant_map() + var table_names: Array[String] + var is_table := false + if constants.has('table_names'): + is_table = true + table_names = constants['table_names'] as Array[String] + else: + table_names = fallback_table_names + + for table_name in table_names: + var lower_table_name := table_name.to_lower().replace("_", "") + if types.has(lower_table_name) and debug_mode: + push_warning("SpacetimeDBSchema: Overwriting schema for table '%s' (from %s)" % [table_name, script_path]) + + if is_table: + tables[lower_table_name] = script + types[lower_table_name] = script + + dir.list_dir_end() + +func get_type(type_name: String) -> GDScript: + return types.get(type_name) diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_schema.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_schema.gd.uid new file mode 100644 index 00000000000..c54a7231e2d --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_schema.gd.uid @@ -0,0 +1 @@ +uid://b2fngo1kbl8ar diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_subscription.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_subscription.gd new file mode 100644 index 00000000000..172d2e70951 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_subscription.gd @@ -0,0 +1,88 @@ +class_name SpacetimeDBSubscription extends Node + +var query_id: int = -1 +var queries: PackedStringArray +var error: Error = OK + +signal applied +signal end + +signal _applied_or_timeout(timeout: bool) +signal _ended_or_timeout(timeout: bool) + +var _client: SpacetimeDBClient +var _active := false +var _ended := false + +var active: bool: + get: + return _active +var ended: bool: + get: + return _ended + +static func create( + p_client: SpacetimeDBClient, + p_query_id: int, + p_queries: PackedStringArray +) -> SpacetimeDBSubscription: + var subscription := SpacetimeDBSubscription.new() + subscription._client = p_client + subscription.query_id = p_query_id + subscription.queries = p_queries + + subscription.applied.connect(func(): + subscription._active = true + subscription._ended = false + + subscription._applied_or_timeout.emit(false) + ) + subscription.end.connect(func(): + subscription._active = false + subscription._ended = true + + subscription._ended_or_timeout.emit(false) + ) + return subscription + +static func fail(error: Error) -> SpacetimeDBSubscription: + var subscription := SpacetimeDBSubscription.new() + subscription.error = error + subscription._ended = true + return subscription + +func wait_for_applied(timeout_sec: float = 5) -> Error: + if _active: + return OK + if _ended: + return ERR_DOES_NOT_EXIST + + get_tree().create_timer(timeout_sec).timeout.connect(_on_applied_timeout) + + var is_timeout: bool = await _applied_or_timeout + if is_timeout: + return ERR_TIMEOUT + return OK + +func wait_for_end(timeout_sec: float = 5) -> Error: + if _ended: + return OK + + get_tree().create_timer(timeout_sec).timeout.connect(_on_ended_timeout) + + var is_timeout: bool = await _ended_or_timeout + if is_timeout: + return ERR_TIMEOUT + return OK + +func unsubscribe() -> Error: + if _ended: + return ERR_DOES_NOT_EXIST + + return _client.unsubscribe(query_id) + +func _on_applied_timeout() -> void: + _applied_or_timeout.emit(true) + +func _on_ended_timeout() -> void: + _ended_or_timeout.emit(true) diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_subscription.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_subscription.gd.uid new file mode 100644 index 00000000000..e29e3a1d36f --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core/spacetimedb_subscription.gd.uid @@ -0,0 +1 @@ +uid://dbr1bo3cvar8d diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/call_reducer.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/call_reducer.gd new file mode 100644 index 00000000000..c1837735266 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/call_reducer.gd @@ -0,0 +1,14 @@ +class_name CallReducerMessage extends Resource + +@export var reducer_name: String +@export var args: PackedByteArray +@export var request_id: int # u32 +@export var flags: int # u8 + +func _init(p_reducer_name: String = "", p_args: PackedByteArray = PackedByteArray(), p_request_id: int = 0, p_flags: int = 0): + reducer_name = p_reducer_name + args = p_args + request_id = p_request_id + flags = p_flags + set_meta("bsatn_type_request_id", "u32") + set_meta("bsatn_type_flags", "u8") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/call_reducer.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/call_reducer.gd.uid new file mode 100644 index 00000000000..5f083dc4359 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/call_reducer.gd.uid @@ -0,0 +1 @@ +uid://g7vlbxpwr3im diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/one_off_query.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/one_off_query.gd new file mode 100644 index 00000000000..8b6e76fb722 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/one_off_query.gd @@ -0,0 +1,7 @@ +class_name OneOffQueryMessage extends Resource + +## The query string to execute once on the server. +@export var query: String + +func _init(p_query: String = ""): + query = p_query diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/one_off_query.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/one_off_query.gd.uid new file mode 100644 index 00000000000..8010187887f --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/one_off_query.gd.uid @@ -0,0 +1 @@ +uid://w4grr12osua diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/spacetimedb_client_message.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/spacetimedb_client_message.gd new file mode 100644 index 00000000000..f9cb5539f23 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/spacetimedb_client_message.gd @@ -0,0 +1,10 @@ +class_name SpacetimeDBClientMessage + +# Client Message Variant Tags (ensure these match server/protocol) +const CALL_REDUCER := 0x00 +const SUBSCRIBE := 0x01 # Legacy? Verify usage. +const ONEOFF_QUERY := 0x02 +const SUBSCRIBE_SINGLE := 0x03 +const SUBSCRIBE_MULTI := 0x04 +const UNSUBSCRIBE := 0x05 # Single? Verify usage. +const UNSUBSCRIBE_MULTI := 0x06 diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/spacetimedb_client_message.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/spacetimedb_client_message.gd.uid new file mode 100644 index 00000000000..9a34e9103b1 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/spacetimedb_client_message.gd.uid @@ -0,0 +1 @@ +uid://ceiuqompoq744 diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe.gd new file mode 100644 index 00000000000..692e64b3024 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe.gd @@ -0,0 +1,9 @@ +class_name SubscribeMessage extends Resource + +@export var queries: Array[String] + +func _init(p_queries: Array[String] = []): + # Ensure correct type upon initialization if needed + var typed_queries: Array[String] = [] + typed_queries.assign(p_queries) # Copy elements, ensuring type + queries = typed_queries diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe.gd.uid new file mode 100644 index 00000000000..49051f1a0d3 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe.gd.uid @@ -0,0 +1 @@ +uid://cama8a4vtmtsl diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_multi.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_multi.gd new file mode 100644 index 00000000000..494c97403ac --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_multi.gd @@ -0,0 +1,17 @@ +class_name SubscribeMultiMessage extends Resource + +## List of subscription query strings for this multi-subscription. +@export var queries: Array[String] + +## Client-generated request ID to identify this multi-subscription later. +@export var request_id: int # u32 +@export var query_id: QueryIdData + +func _init(p_queries: Array[String] = [], p_query_id: int = 0, p_request_id: int = 0): + var typed_queries: Array[String] = [] + typed_queries.assign(p_queries) + queries = typed_queries + request_id = p_request_id + query_id = QueryIdData.new(p_query_id) + # Add metadata for correct BSATN integer serialization + set_meta("bsatn_type_request_id", "u32") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_multi.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_multi.gd.uid new file mode 100644 index 00000000000..5c67b234685 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_multi.gd.uid @@ -0,0 +1 @@ +uid://e6qrmt4vxchp diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_single.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_single.gd new file mode 100644 index 00000000000..628a6a664f5 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_single.gd @@ -0,0 +1,13 @@ +class_name SubscribeSingleMessage extends Resource + +## The query string for the single subscription. +@export var query_string: String + +## Client-generated request ID to identify this subscription later (e.g., for unsubscribe). +@export var request_id: int # u32 + +func _init(p_query_string: String = "", p_request_id: int = 0): + query_string = p_query_string + request_id = p_request_id + # Add metadata for correct BSATN integer serialization + set_meta("bsatn_type_request_id", "u32") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_single.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_single.gd.uid new file mode 100644 index 00000000000..cbb0ed7e25b --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/subscribe_single.gd.uid @@ -0,0 +1 @@ +uid://bnudrwda6depd diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe.gd new file mode 100644 index 00000000000..a3cf39f38bb --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe.gd @@ -0,0 +1,12 @@ +class_name UnsubscribeMessage extends Resource + +## Client request ID used during the original subscription. +@export var request_id: int # u32 + +## Identifier of the query being unsubscribed from (as a Resource). +@export var query_id: QueryIdData + +func _init(p_request_id: int = 0, p_query_id_resource: QueryIdData = null): + request_id = p_request_id + query_id = p_query_id_resource if p_query_id_resource != null else QueryIdData.new() + set_meta("bsatn_type_request_id", "u32") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe.gd.uid new file mode 100644 index 00000000000..47a430bbac8 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe.gd.uid @@ -0,0 +1 @@ +uid://br6xrt1oc6x6o diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe_multi.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe_multi.gd new file mode 100644 index 00000000000..99cc8ec85e0 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe_multi.gd @@ -0,0 +1,10 @@ +class_name UnsubscribeMultiMessage extends Resource + +## Client request ID used during the original multi-subscription. +@export var request_id: int # u32 +@export var query_id: QueryIdData + +func _init(p_query_id: int = 0, p_request_id: int = 0): + request_id = p_request_id + query_id = QueryIdData.new(p_query_id) + set_meta("bsatn_type_request_id", "u32") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe_multi.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe_multi.gd.uid new file mode 100644 index 00000000000..df2d62581a8 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/client_message/unsubscribe_multi.gd.uid @@ -0,0 +1 @@ +uid://cmdrop63pswck diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/database_update_data.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/database_update_data.gd new file mode 100644 index 00000000000..0c1aad23651 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/database_update_data.gd @@ -0,0 +1,4 @@ +@tool +class_name DatabaseUpdateData extends Resource + +@export var tables: Array[TableUpdateData] diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/database_update_data.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/database_update_data.gd.uid new file mode 100644 index 00000000000..59be9c43cfc --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/database_update_data.gd.uid @@ -0,0 +1 @@ +uid://slhaqvx0tptk diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/option.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/option.gd new file mode 100644 index 00000000000..f28fabc5f9b --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/option.gd @@ -0,0 +1,76 @@ +@tool +class_name Option extends Resource + +@export var data: Array = [] : + set(value): + if value is Array: + if value.size() > 0: + _internal_data = value.slice(0, 1) + else: + _internal_data = [] + else: + push_error("Optional data must be an Array.") + _internal_data = [] + get(): + return _internal_data + +var _internal_data: Array = [] + +static func some(value: Variant) -> Option: + var result = Option.new() + result.set_some(value) + return result + +static func none() -> Option: + var result = Option.new() + result.set_none() + return result + +func is_some() -> bool: + return _internal_data.size() > 0 + +func is_none() -> bool: + return _internal_data.is_empty() + +func unwrap(): + if is_some(): + return _internal_data[0] + else: + push_error("Attempted to unwrap a None Optional value!") + return null + +func unwrap_or(default_value): + if is_some(): + return _internal_data[0] + else: + return default_value + +func unwrap_or_else(fn: Callable): + if is_some(): + return _internal_data[0] + else: + return fn.call() + +func expect(type: Variant.Type, err_msg: String = ""): + if is_some(): + if typeof(_internal_data[0]) != type: + err_msg = "Expected type %s, got %s" % [type, typeof(_internal_data[0])] if err_msg.is_empty() else err_msg + push_error(err_msg) + return null + return _internal_data[0] + else: + err_msg = "Expected type %s, got None" % type if err_msg.is_empty() else err_msg + push_error(err_msg) + return null + +func set_some(value): + self.data = [value] + +func set_none(): + self.data = [] + +func to_string() -> String: + if is_some(): + return "Some(%s [type: %s])" % [_internal_data[0], typeof(_internal_data[0])] + else: + return "None" diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/option.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/option.gd.uid new file mode 100644 index 00000000000..6f2689a330b --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/option.gd.uid @@ -0,0 +1 @@ +uid://b8icolrij2x8x diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/query_id_data.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/query_id_data.gd new file mode 100644 index 00000000000..ef68159bed2 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/query_id_data.gd @@ -0,0 +1,10 @@ +@tool +class_name QueryIdData extends Resource + +## The actual ID value. +@export var id: int # u32 + +func _init(p_id: int = 0): + id = p_id + # Add metadata for correct BSATN serialization + set_meta("bsatn_type_id", "u32") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/query_id_data.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/query_id_data.gd.uid new file mode 100644 index 00000000000..764f0789433 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/query_id_data.gd.uid @@ -0,0 +1 @@ +uid://d0s6vawihkv1o diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/rust_enum.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/rust_enum.gd new file mode 100644 index 00000000000..a7c3b4cef91 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/rust_enum.gd @@ -0,0 +1,4 @@ +class_name RustEnum extends Resource + +@export var value: int = 0 +var data: Variant diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/rust_enum.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/rust_enum.gd.uid new file mode 100644 index 00000000000..8454b0dc177 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/rust_enum.gd.uid @@ -0,0 +1 @@ +uid://bbya8yoar6mwc diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/identity_token.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/identity_token.gd new file mode 100644 index 00000000000..1142e3087dc --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/identity_token.gd @@ -0,0 +1,10 @@ +@tool +class_name IdentityTokenMessage extends Resource + +@export var identity: PackedByteArray +@export var token: String +@export var connection_id: PackedByteArray # 16 bytes + +func _init(): + set_meta("bsatn_type_identity", "identity") + set_meta("bsatn_type_connection_id", "connection_id") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/identity_token.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/identity_token.gd.uid new file mode 100644 index 00000000000..3993274dd6b --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/identity_token.gd.uid @@ -0,0 +1 @@ +uid://ccdfuxsuhr4pa diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/initial_subscription.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/initial_subscription.gd new file mode 100644 index 00000000000..f23c28d67c0 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/initial_subscription.gd @@ -0,0 +1,6 @@ +@tool +class_name InitialSubscriptionMessage extends Resource + +@export var database_update: DatabaseUpdateData +@export var request_id: int # u32 +@export var total_host_execution_duration_ns: int # i64 diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/initial_subscription.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/initial_subscription.gd.uid new file mode 100644 index 00000000000..80be9467ca3 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/initial_subscription.gd.uid @@ -0,0 +1 @@ +uid://fwiy35maldf5 diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/spacetimedb_server_message.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/spacetimedb_server_message.gd new file mode 100644 index 00000000000..44c32573558 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/spacetimedb_server_message.gd @@ -0,0 +1,28 @@ +class_name SpacetimeDBServerMessage + +# Server Message Tags (ensure these match protocol) +const INITIAL_SUBSCRIPTION := 0x00 +const TRANSACTION_UPDATE := 0x01 +const TRANSACTION_UPDATE_LIGHT := 0x02 # Not currently handled in parse_packet +const IDENTITY_TOKEN := 0x03 +const ONE_OFF_QUERY_RESPONSE := 0x04 +const SUBSCRIBE_APPLIED := 0x05 +const UNSUBSCRIBE_APPLIED := 0x06 +const SUBSCRIPTION_ERROR := 0x07 +const SUBSCRIBE_MULTI_APPLIED := 0x08 +const UNSUBSCRIBE_MULTI_APPLIED := 0x09 + +static func get_resource_path(msg_type: int) -> String: + match msg_type: + INITIAL_SUBSCRIPTION: return "res://addons/SpacetimeDB/core_types/server_message/initial_subscription.gd" + TRANSACTION_UPDATE: return "res://addons/SpacetimeDB/core_types/server_message/transaction_update.gd" + IDENTITY_TOKEN: return "res://addons/SpacetimeDB/core_types/server_message/identity_token.gd" + ONE_OFF_QUERY_RESPONSE: return "res://addons/SpacetimeDB/core_types/server_message/one_off_query_response.gd" # IMPLEMENT READER + SUBSCRIBE_APPLIED: return "res://addons/SpacetimeDB/core_types/server_message/subscribe_applied.gd" + UNSUBSCRIBE_APPLIED: return "res://addons/SpacetimeDB/core_types/server_message/unsubscribe_applied.gd" + SUBSCRIPTION_ERROR: return "res://addons/SpacetimeDB/core_types/server_message/subscription_error.gd" # Uses manual reader + SUBSCRIBE_MULTI_APPLIED: return "res://addons/SpacetimeDB/core_types/server_message/subscribe_multi_applied.gd" + UNSUBSCRIBE_MULTI_APPLIED: return "res://addons/SpacetimeDB/core_types/server_message/unsubscribe_multi_applied.gd" + # TRANSACTION_UPDATE_LIGHT (0x02) is not handled yet + _: + return "" diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/spacetimedb_server_message.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/spacetimedb_server_message.gd.uid new file mode 100644 index 00000000000..0da87644b36 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/spacetimedb_server_message.gd.uid @@ -0,0 +1 @@ +uid://djnsufaq852ml diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_applied.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_applied.gd new file mode 100644 index 00000000000..0db423a047b --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_applied.gd @@ -0,0 +1,11 @@ +@tool +class_name SubscribeAppliedMessage extends Resource + +@export var request_id: int # u32 +@export var total_host_execution_duration_micros: int # u64 +@export var query_id: QueryIdData # Nested Resource +@export var rows: SubscribeRowsData # Nested Resource + +func _init(): + set_meta("bsatn_type_request_id", "u32") + set_meta("bsatn_type_total_host_execution_duration_micros", "u64") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_applied.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_applied.gd.uid new file mode 100644 index 00000000000..fe8557e9b93 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_applied.gd.uid @@ -0,0 +1 @@ +uid://xh0bcd4gt3tw diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_multi_applied.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_multi_applied.gd new file mode 100644 index 00000000000..eb40db3e49a --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_multi_applied.gd @@ -0,0 +1,11 @@ +@tool +class_name SubscribeMultiAppliedMessage extends Resource + +@export var request_id: int # u32 +@export var total_host_execution_duration_micros: int # u64 +@export var query_id: QueryIdData # Nested Resource +@export var database_update: DatabaseUpdateData # Nested Resource + +func _init(): + set_meta("bsatn_type_request_id", "u32") + set_meta("bsatn_type_total_host_execution_duration_micros", "u64") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_multi_applied.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_multi_applied.gd.uid new file mode 100644 index 00000000000..a1c378927cf --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscribe_multi_applied.gd.uid @@ -0,0 +1 @@ +uid://bs32f5b3ogppf diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscription_error.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscription_error.gd new file mode 100644 index 00000000000..93dce42382d --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscription_error.gd @@ -0,0 +1,19 @@ +@tool +class_name SubscriptionErrorMessage extends Resource + +@export var total_host_execution_duration_micros: int # u64 +@export var request_id: int # u32 or -1 for None +@export var query_id: int # u32 or -1 for None +@export var table_id_resource: TableIdData # TableIdData or null for None +@export var error_message: String + +func _init(): + request_id = -1 # Default to None + query_id = -1 + table_id_resource = null # Default to None + set_meta("bsatn_type_total_host_execution_duration_micros", "u64") + +func has_request_id() -> bool: return request_id != -1 +func has_query_id() -> bool: return query_id != -1 +func has_table_id() -> bool: return table_id_resource != null +func get_table_id() -> TableIdData: return table_id_resource diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscription_error.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscription_error.gd.uid new file mode 100644 index 00000000000..409cf5134ea --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/subscription_error.gd.uid @@ -0,0 +1 @@ +uid://uqlurx304vou diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update.gd new file mode 100644 index 00000000000..4b44361d395 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update.gd @@ -0,0 +1,17 @@ +@tool +class_name TransactionUpdateMessage extends Resource + +@export var status: UpdateStatusData +@export var timestamp_ns: int # i64 (Timestamp) +@export var caller_identity: PackedByteArray # 32 bytes +@export var caller_connection_id: PackedByteArray # 16 bytes +@export var reducer_call: ReducerCallInfoData +@export var energy_quanta_used: int # u64 +@export var total_host_execution_duration_ns: int # i64 (TimeDuration) + +func _init(): + set_meta("bsatn_type_timestamp_ns", "i64") + set_meta("bsatn_type_caller_identity", "identity") + set_meta("bsatn_type_caller_connection_id", "connection_id") + set_meta("bsatn_type_energy_quanta_used", "u64") + set_meta("bsatn_type_energy_total_host_execution_duration_ns", "i64") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update.gd.uid new file mode 100644 index 00000000000..fc460b8cd6f --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update.gd.uid @@ -0,0 +1 @@ +uid://hq41blwxmiu3 diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/reducer_call_info_data.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/reducer_call_info_data.gd new file mode 100644 index 00000000000..e136f2bd296 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/reducer_call_info_data.gd @@ -0,0 +1,13 @@ +@tool +class_name ReducerCallInfoData extends Resource + +@export var reducer_name: String +@export var reducer_id: int # u32 +@export var args: PackedByteArray # Raw BSATN bytes for arguments +@export var request_id: int # u32 +@export var execution_time: int + +func _init(): + set_meta("bsatn_type_reducer_id", "u32") + set_meta("bsatn_type_request_id", "u32") + set_meta("bsatn_type_execution_time", "i64") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/reducer_call_info_data.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/reducer_call_info_data.gd.uid new file mode 100644 index 00000000000..79fc5a4bb09 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/reducer_call_info_data.gd.uid @@ -0,0 +1 @@ +uid://bkmye2sp53b3a diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/update_status_data.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/update_status_data.gd new file mode 100644 index 00000000000..198a5f986e4 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/update_status_data.gd @@ -0,0 +1,14 @@ +@tool +class_name UpdateStatusData extends Resource + +enum StatusType { + COMMITTED, + FAILED, + OUT_OF_ENERGY +} + +@export var status_type: StatusType = StatusType.COMMITTED +# Only valid if status_type is COMMITTED +@export var committed_update: DatabaseUpdateData +# Only valid if status_type is FAILED +@export var failure_message: String = "" diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/update_status_data.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/update_status_data.gd.uid new file mode 100644 index 00000000000..32f14d94260 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/transaction_update/update_status_data.gd.uid @@ -0,0 +1 @@ +uid://y75qir5pkw6r diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_applied.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_applied.gd new file mode 100644 index 00000000000..978d2941f12 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_applied.gd @@ -0,0 +1,11 @@ +@tool +class_name UnsubscribeAppliedMessage extends Resource + +@export var request_id: int # u32 +@export var total_host_execution_duration_micros: int # u64 +@export var query_id: QueryIdData # Nested Resource +@export var rows: SubscribeRowsData # Nested Resource + +func _init(): + set_meta("bsatn_type_request_id", "u32") + set_meta("bsatn_type_total_host_execution_duration_micros", "u64") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_applied.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_applied.gd.uid new file mode 100644 index 00000000000..777cf168d50 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_applied.gd.uid @@ -0,0 +1 @@ +uid://b53ufdxhdyrjh diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_multi_applied.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_multi_applied.gd new file mode 100644 index 00000000000..7b4296240a8 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_multi_applied.gd @@ -0,0 +1,11 @@ +@tool +class_name UnsubscribeMultiAppliedMessage extends Resource + +@export var request_id: int # u32 +@export var total_host_execution_duration_micros: int # u64 +@export var query_id: QueryIdData # Nested Resource +@export var database_update: DatabaseUpdateData # Nested Resource + +func _init(): + set_meta("bsatn_type_request_id", "u32") + set_meta("bsatn_type_total_host_execution_duration_micros", "u64") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_multi_applied.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_multi_applied.gd.uid new file mode 100644 index 00000000000..7f817defc55 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/server_message/unsubscribe_multi_applied.gd.uid @@ -0,0 +1 @@ +uid://dgufi3uignvkc diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/subscribe_rows_data.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/subscribe_rows_data.gd new file mode 100644 index 00000000000..b5c8a5b5c8e --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/subscribe_rows_data.gd @@ -0,0 +1,9 @@ +@tool +class_name SubscribeRowsData extends Resource + +@export var table_id: int # u32 (TableId is likely u32) +@export var table_name: String +@export var table_rows: TableUpdateData + +func _init(): + set_meta("bsatn_type_table_id", "u32") diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/subscribe_rows_data.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/subscribe_rows_data.gd.uid new file mode 100644 index 00000000000..cd004fd1908 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/subscribe_rows_data.gd.uid @@ -0,0 +1 @@ +uid://bekg555ewyqus diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_id_data.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_id_data.gd new file mode 100644 index 00000000000..0ae7c687c31 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_id_data.gd @@ -0,0 +1,9 @@ +@tool +class_name TableIdData extends Resource + +@export var pascal_case: String +@export var snake_case: String + +func _init(p_pascal: String = "", p_snake: String = ""): + pascal_case = p_pascal + snake_case = p_snake diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_id_data.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_id_data.gd.uid new file mode 100644 index 00000000000..9b2741c636f --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_id_data.gd.uid @@ -0,0 +1 @@ +uid://dcacuu7enibqq diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_update_data.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_update_data.gd new file mode 100644 index 00000000000..b8937a1a54d --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_update_data.gd @@ -0,0 +1,8 @@ +@tool +class_name TableUpdateData extends Resource + +@export var table_id: int # u32 +@export var table_name: String +@export var num_rows: int # u64 +@export var deletes: Array[Resource] # Array of specific table row resources (e.g., Message, User) +@export var inserts: Array[Resource] # Array of specific table row resources diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_update_data.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_update_data.gd.uid new file mode 100644 index 00000000000..b0819b32b85 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/core_types/table_update_data.gd.uid @@ -0,0 +1 @@ +uid://ba7nqeo6r71kf diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/icon.svg b/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/icon.svg new file mode 100644 index 00000000000..9a8ced96688 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/icon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/icon.svg.import b/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/icon.svg.import new file mode 100644 index 00000000000..33a9721550a --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dovuteam3ycm7" +path="res://.godot/imported/icon.svg-9e5e811a43a099927e54cd85be5646a6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/SpacetimeDB/nodes/row_receiver/icon.svg" +dest_files=["res://.godot/imported/icon.svg-9e5e811a43a099927e54cd85be5646a6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/row_receiver.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/row_receiver.gd new file mode 100644 index 00000000000..1f7c49b2042 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/row_receiver.gd @@ -0,0 +1,160 @@ +@tool +@icon("res://addons/SpacetimeDB/nodes/row_receiver/icon.svg") +class_name RowReceiver extends Node + +@export var debug_mode: bool = false +@export var table_to_receive: _ModuleTableType : set = on_set; +var selected_table_name: String : set = set_selected_table_name + +var _derived_table_names: Array[String] = [] + +signal insert(row: _ModuleTableType) +signal update(prev: _ModuleTableType, row: _ModuleTableType) +signal delete(row: _ModuleTableType) +signal transactions_completed + +var _current_db_instance = null + +func _print_log(log_message: String): + if debug_mode: + print("%s: %s" % [get_path(), log_message]) + +func _get_db(wait_for_init: bool = false) -> LocalDatabase: + if _current_db_instance == null or not is_instance_valid(_current_db_instance): + var constants := (table_to_receive.get_script() as GDScript).get_script_constant_map() + var module_name: String = constants.get("module_name", "").to_pascal_case() + _current_db_instance = SpacetimeDB[module_name].get_local_database() + + if _current_db_instance == null and wait_for_init: + _print_log("Waiting for db to be initialized...") + await SpacetimeDB[module_name].database_initialized + _current_db_instance = SpacetimeDB[module_name].get_local_database() + _print_log("Db initialized") + return _current_db_instance + +func on_set(schema: _ModuleTableType): + _derived_table_names.clear() + + if schema == null: + name = "Receiver [EMPTY]" + table_to_receive = schema + if selected_table_name != "": + set_selected_table_name("") + else: + var script_resource: Script = schema.get_script() + + if script_resource is Script: + var global_name: String = script_resource.get_global_name().replace("_gd", "") + if global_name == "_ModuleTableType": + push_error("_ModuleTableType is the base class for table types, not a reciever table. Selection is not changed.") + return + table_to_receive = schema + name = "Receiver [%s]" % global_name + + var constant_map = script_resource.get_script_constant_map() + if constant_map.has("table_names"): + var names_value = constant_map["table_names"] + if names_value is Array: + for item in names_value: + if item is String: + _derived_table_names.push_back(item) + else: + name = "Receiver [Unknown Schema Type]" + + var current_selection_still_valid = _derived_table_names.has(selected_table_name) + if not current_selection_still_valid: + if not _derived_table_names.is_empty(): + set_selected_table_name(_derived_table_names[0]) + else: + if selected_table_name != "": + set_selected_table_name("") + + if Engine.is_editor_hint(): + property_list_changed.emit() + +func set_selected_table_name(value: String): + if selected_table_name == value: + return + selected_table_name = value + +func _get_property_list() -> Array: + var properties: Array = [] + if not _derived_table_names.is_empty(): + var hint_string_for_enum = ",".join(_derived_table_names) + properties.append({ + "name": "selected_table_name", + "type": TYPE_STRING, + "hint": PROPERTY_HINT_ENUM, + "hint_string": hint_string_for_enum + }) + return properties + +func _ready() -> void: + if Engine.is_editor_hint(): + return + + if not table_to_receive: + push_error("The table_to_receive is not set on %s" % get_path()) + return + + var db := await _get_db(true) + _subscribe_to_table(db, selected_table_name) + +func _subscribe_to_table(db: LocalDatabase, table_name_sn: StringName): + if Engine.is_editor_hint() or table_name_sn == &"": + return + + _print_log("Subscribing to table: %s" % table_name_sn) + + if get_parent() and not get_parent().is_node_ready(): + _print_log("Waiting for parent before subscribing") + await get_parent().ready + + # Emit data that was inserted before we subscribed + var existing_data := await get_table_data() + if existing_data.size() > 0: + for row in existing_data: + _on_insert(row) + _on_transactions_completed() + + db.subscribe_to_inserts(table_name_sn, Callable(self, "_on_insert")) + db.subscribe_to_updates(table_name_sn, Callable(self, "_on_update")) + db.subscribe_to_deletes(table_name_sn, Callable(self, "_on_delete")) + db.subscribe_to_transactions_completed(table_name_sn, Callable(self, "_on_transactions_completed")) + + _print_log("Successfully subscribed to table: %s" % table_name_sn) + +func _unsubscribe_from_table(table_name_sn: StringName): + if Engine.is_editor_hint() or table_name_sn == &"": + return + + _print_log("Unsubscribing from table: %s" % table_name_sn) + + var db := await _get_db() + if not is_instance_valid(db): return + + db.unsubscribe_from_inserts(table_name_sn, Callable(self, "_on_insert")) + db.unsubscribe_from_updates(table_name_sn, Callable(self, "_on_update")) + db.unsubscribe_from_deletes(table_name_sn, Callable(self, "_on_delete")) + db.unsubscribe_from_transactions_completed(table_name_sn, Callable(self, "_on_transactions_completed")) + +func _on_insert(row: _ModuleTableType): + insert.emit(row) + +func _on_update(previous: _ModuleTableType, row: _ModuleTableType): + update.emit(previous, row) + +func _on_delete(row: _ModuleTableType): + delete.emit(row) + +func _on_transactions_completed(): + transactions_completed.emit() + +func _exit_tree() -> void: + _unsubscribe_from_table(selected_table_name) + +func get_table_data() -> Array[_ModuleTableType]: + var db := await _get_db() + if db: + return db.get_all_rows(selected_table_name) + return [] diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/row_receiver.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/row_receiver.gd.uid new file mode 100644 index 00000000000..f1531d5d4fc --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/nodes/row_receiver/row_receiver.gd.uid @@ -0,0 +1 @@ +uid://jvk6ou7i2d4s diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/plugin.cfg b/demo/Blackholio/client-godot/addons/SpacetimeDB/plugin.cfg new file mode 100644 index 00000000000..db47bdd058a --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="SpacetimeDB Client SDK" +description="Client library for SpacetimeDB using BSATN over WebSockets." +author="Flametime" +version="0.2.0" +script="spacetime.gd" diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/spacetime.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/spacetime.gd new file mode 100644 index 00000000000..712f5128656 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/spacetime.gd @@ -0,0 +1,210 @@ +@tool +class_name SpacetimePlugin extends EditorPlugin + +const LEGACY_DATA_PATH := "res://spacetime_data" +const BINDINGS_PATH := "res://spacetime_bindings" +const BINDINGS_SCHEMA_PATH := BINDINGS_PATH + "/schema" +const AUTOLOAD_NAME := "SpacetimeDB" +const AUTOLOAD_FILE_NAME := "spacetime_autoload.gd" +const AUTOLOAD_PATH := BINDINGS_SCHEMA_PATH + "/" + AUTOLOAD_FILE_NAME +const SAVE_PATH := BINDINGS_PATH + "/codegen_data.json" +const CONFIG_PATH := "res://addons/SpacetimeDB/plugin.cfg" +const UI_PANEL_NAME := "SpacetimeDB" +const UI_PATH := "res://addons/SpacetimeDB/ui/ui.tscn" + +var http_request := HTTPRequest.new() +var codegen_data: Dictionary +var ui: SpacetimePluginUI + +static var instance: SpacetimePlugin + +func _enter_tree(): + instance = self + + if not is_instance_valid(ui): + var scene = load(UI_PATH) + if scene: + ui = scene.instantiate() as SpacetimePluginUI + else: + printerr("SpacetimePlugin: Failed to load UI scene: ", UI_PATH) + return + + if is_instance_valid(ui): + add_control_to_bottom_panel(ui, UI_PANEL_NAME) + else: + printerr("SpacetimePlugin: UI panel is not valid after instantiation") + return + + ui.module_added.connect(_on_module_added) + ui.module_updated.connect(_on_module_updated) + ui.module_removed.connect(_on_module_removed) + ui.check_uri.connect(_on_check_uri) + ui.generate_schema.connect(_on_generate_schema) + ui.clear_logs() + + http_request.timeout = 4; + add_child(http_request) + + var config_file = ConfigFile.new() + config_file.load(CONFIG_PATH) + + var version: String = config_file.get_value("plugin", "version", "0.0.0") + var author: String = config_file.get_value("plugin", "author", "??") + + print_log("SpacetimeDB SDK v%s (c) 2025-present %s & Contributors" % [version, author]) + load_codegen_data() + +func add_module(name: String, fromLoad: bool = false): + ui.add_module(name) + + if not fromLoad: + codegen_data.modules.append(name) + save_codegen_data() + +func load_codegen_data() -> void: + var load_data = FileAccess.open(SAVE_PATH, FileAccess.READ) + if load_data: + print_log("Loading codegen data from %s" % [SAVE_PATH]) + codegen_data = JSON.parse_string(load_data.get_as_text()) + load_data.close() + ui.set_uri(codegen_data.uri) + + for module in codegen_data.modules.duplicate(): + add_module(module, true) + print_log("Loaded module: %s" % [module]) + else: + codegen_data = { + "uri": "http://127.0.0.1:3000", + "modules": [] + } + save_codegen_data() + +func save_codegen_data() -> void: + if not FileAccess.file_exists(BINDINGS_PATH): + DirAccess.make_dir_absolute(BINDINGS_PATH) + get_editor_interface().get_resource_filesystem().scan() + + var save_file = FileAccess.open(SAVE_PATH, FileAccess.WRITE) + if not save_file: + print_err("Failed to open codegen_data.json for writing") + return + save_file.store_string(JSON.stringify(codegen_data)) + save_file.close() + +func _on_module_added(name: String) -> void: + codegen_data.modules.append(name) + save_codegen_data() + +func _on_module_updated(index: int, name: String) -> void: + codegen_data.modules.set(index, name) + save_codegen_data() + +func _on_module_removed(index: int) -> void: + codegen_data.modules.remove_at(index) + save_codegen_data() + +func _on_check_uri(uri: String): + if codegen_data.uri != uri: + codegen_data.uri = uri + save_codegen_data() + + if uri.ends_with("/"): + uri = uri.left(-1) + uri += "/v1/ping" + + print_log("Pinging... " + uri) + http_request.request(uri) + + var result = await http_request.request_completed + if result[1] == 0: + print_err("Request timeout - " + uri) + else: + print_log("Response code: " + str(result[1])) + +func _on_generate_schema(uri: String, module_names: Array[String]): + if uri.ends_with("/"): + uri = uri.left(-1) + + print_log("Starting code generation...") + + print_log("Fetching module schemas...") + var module_schemas: Dictionary[String, String] = {} + var failed = false + for module_name in module_names: + var schema_uri := "%s/v1/database/%s/schema?version=9" % [uri, module_name] + http_request.request(schema_uri) + var result = await http_request.request_completed + if result[1] == 200: + var json = PackedByteArray(result[3]).get_string_from_utf8() + var snake_module_name = module_name.replace("-", "_") + module_schemas[snake_module_name] = json + print_log("Fetched schema for module: %s" % [module_name]) + continue + + if result[1] == 404: + print_err("Module not found - %s" % [schema_uri]) + elif result[1] == 0: + print_err("Request timeout - %s" % [schema_uri]) + else: + print_err("Failed to fetch module schema: %s - Response code %s" % [module_name, result[1]]) + failed = true + + if failed: + print_err("Code generation failed!") + return + + var codegen := SpacetimeCodegen.new(BINDINGS_SCHEMA_PATH) + var generated_files := codegen.generate_bindings(module_schemas) + + _cleanup_unused_classes(BINDINGS_SCHEMA_PATH, generated_files) + + if DirAccess.dir_exists_absolute(LEGACY_DATA_PATH): + print_log("Removing legacy data directory: %s" % LEGACY_DATA_PATH) + DirAccess.remove_absolute(LEGACY_DATA_PATH) + + var setting_name := "autoload/" + AUTOLOAD_NAME + if ProjectSettings.has_setting(setting_name): + var current_autoload: String = ProjectSettings.get_setting(setting_name) + if current_autoload != "*%s" % AUTOLOAD_PATH: + print_log("Removing old autoload path: %s" % current_autoload) + ProjectSettings.set_setting(setting_name, null) + + if not ProjectSettings.has_setting(setting_name): + add_autoload_singleton(AUTOLOAD_NAME, AUTOLOAD_PATH) + + get_editor_interface().get_resource_filesystem().scan() + print_log("Code generation complete!") + +func _cleanup_unused_classes(dir_path: String = "res://schema", files: Array[String] = []) -> void: + var dir = DirAccess.open(dir_path) + if not dir: return + print_log("File Cleanup: Scanning folder: " + dir_path) + for file in dir.get_files(): + if not file.ends_with(".gd"): continue + var full_path = "%s/%s" % [dir_path, file] + if not full_path in files: + print_log("Removing file: %s" % [full_path]) + DirAccess.remove_absolute(full_path) + if FileAccess.file_exists("%s.uid" % [full_path]): + DirAccess.remove_absolute("%s.uid" % [full_path]) + var subfolders = dir.get_directories() + for folder in subfolders: + _cleanup_unused_classes(dir_path + "/" + folder, files) + +static func clear_logs(): + instance.ui.clear_logs() + +static func print_log(text: Variant) -> void: + instance.ui.add_log(text) + +static func print_err(text: Variant) -> void: + instance.ui.add_err(text) + +func _exit_tree(): + ui.destroy() + ui = null + http_request.queue_free() + http_request = null + + if ProjectSettings.has_setting("autoload/" + AUTOLOAD_NAME): + remove_autoload_singleton(AUTOLOAD_NAME) diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/spacetime.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/spacetime.gd.uid new file mode 100644 index 00000000000..1b110b6cdbc --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/spacetime.gd.uid @@ -0,0 +1 @@ +uid://drph61tloqdwp diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/ActionCopy.svg b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/ActionCopy.svg new file mode 100644 index 00000000000..96e5b2bd59e --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/ActionCopy.svg @@ -0,0 +1 @@ + diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/ActionCopy.svg.import b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/ActionCopy.svg.import new file mode 100644 index 00000000000..a235a45216d --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/ActionCopy.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://db1mkfv85i0hu" +path="res://.godot/imported/ActionCopy.svg-1b4b08d89d015113caea2f74ba643d50.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/SpacetimeDB/ui/icons/ActionCopy.svg" +dest_files=["res://.godot/imported/ActionCopy.svg-1b4b08d89d015113caea2f74ba643d50.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Add.svg b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Add.svg new file mode 100644 index 00000000000..d860ce85bea --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Add.svg @@ -0,0 +1 @@ + diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Add.svg.import b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Add.svg.import new file mode 100644 index 00000000000..516116b6749 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Add.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://yyihsx4ptqm6" +path="res://.godot/imported/Add.svg-7b9a117630c0eacc31f9f27a9778b4f2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/SpacetimeDB/ui/icons/Add.svg" +dest_files=["res://.godot/imported/Add.svg-7b9a117630c0eacc31f9f27a9778b4f2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Clear.svg b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Clear.svg new file mode 100644 index 00000000000..06a3ca14cc9 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Clear.svg @@ -0,0 +1 @@ + diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Clear.svg.import b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Clear.svg.import new file mode 100644 index 00000000000..48785759bb3 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Clear.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bj322os6s3pwb" +path="res://.godot/imported/Clear.svg-d322c443515b8860b7679bb2b473e6cd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/SpacetimeDB/ui/icons/Clear.svg" +dest_files=["res://.godot/imported/Clear.svg-d322c443515b8860b7679bb2b473e6cd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Error.svg b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Error.svg new file mode 100644 index 00000000000..a52f7b6731a --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Error.svg @@ -0,0 +1 @@ + diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Error.svg.import b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Error.svg.import new file mode 100644 index 00000000000..51aea4b9a9d --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Error.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ctwdnauam1s5v" +path="res://.godot/imported/Error.svg-a378a59a06c1cd1e859e6645389bff1e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/SpacetimeDB/ui/icons/Error.svg" +dest_files=["res://.godot/imported/Error.svg-a378a59a06c1cd1e859e6645389bff1e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Remove.svg b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Remove.svg new file mode 100644 index 00000000000..7acc15cd4d9 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Remove.svg @@ -0,0 +1 @@ + diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Remove.svg.import b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Remove.svg.import new file mode 100644 index 00000000000..b65ecf35ec0 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/icons/Remove.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bbwidmvngko0a" +path="res://.godot/imported/Remove.svg-69f87f328dffe4a18d881c4624c50237.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/SpacetimeDB/ui/icons/Remove.svg" +dest_files=["res://.godot/imported/Remove.svg-69f87f328dffe4a18d881c4624c50237.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.gd b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.gd new file mode 100644 index 00000000000..4db022fc1c2 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.gd @@ -0,0 +1,148 @@ +@tool +class_name SpacetimePluginUI extends Control + +const ERROR_LOG_ICON := "res://addons/SpacetimeDB/ui/icons/Error.svg" + +signal module_added(name: String) +signal module_updated(index: int, name: String) +signal module_removed(index: int) +signal check_uri(uri: String) +signal generate_schema(uri: String, modules: Array[String]) + +var _uri_input: LineEdit +var _modules_container: VBoxContainer +var _logs_label: RichTextLabel +var _add_module_hint_label: RichTextLabel +var _new_module_name_input: LineEdit +var _new_module_button: Button +var _check_uri_button: Button +var _generate_button: Button +var _clear_logs_button: Button +var _copy_logs_button: Button + +func _enter_tree() -> void: + _uri_input = $"Main/BottomBar/ServerUri/UriInput" + _modules_container = $"Main/Content/Sidebar/Modules/ModulesList/VBox" + _logs_label = $"Main/Content/Logs" + _add_module_hint_label = $"Main/Content/Sidebar/Modules/AddModuleHint" + _new_module_name_input = $"Main/Content/Sidebar/NewModule/ModuleNameInput" + _new_module_button = $"Main/Content/Sidebar/NewModule/AddButton" + _check_uri_button = $"Main/BottomBar/CheckUri" + _generate_button = $"Main/Content/Sidebar/GenerateButton" + _clear_logs_button = $"Main/BottomBar/LogsControls/ClearLogsButton" + _copy_logs_button = $"Main/BottomBar/LogsControls/CopyLogsButton" + + _check_uri_button.pressed.connect(_on_check_uri) + _generate_button.pressed.connect(_on_generate_code) + _new_module_button.pressed.connect(_on_new_module) + _clear_logs_button.pressed.connect(_on_clear_logs) + _copy_logs_button.pressed.connect(_on_copy_selected_logs) + +func _input(event: InputEvent) -> void: + if not visible: + return + + if event is InputEventKey: + if event.pressed and event.keycode == KEY_C and event.ctrl_pressed: + copy_selected_logs() + elif event.pressed and event.keycode == KEY_K and event.ctrl_pressed and event.alt_pressed: + clear_logs() + +func set_uri(uri: String) -> void: + _uri_input.text = uri + +func add_module(name: String) -> void: + var new_module: Control = $"Prefabs/ModulePrefab".duplicate() as Control + var name_input: LineEdit = new_module.get_node("ModuleNameInput") as LineEdit + name_input.text = name + _modules_container.add_child(new_module) + + name_input.focus_exited.connect(func(): + var index = new_module.get_index() + module_updated.emit(index, name_input.text) + ) + + var remove_button: Button = new_module.get_node("RemoveButton") as Button + remove_button.button_down.connect(func(): + var index = new_module.get_index() + module_removed.emit(index) + _modules_container.remove_child(new_module) + new_module.queue_free() + + if _modules_container.get_child_count() == 0: + _add_module_hint_label.show() + _generate_button.disabled = true + ) + + new_module.show() + _add_module_hint_label.hide() + _generate_button.disabled = false + +func clear_logs(): + _logs_label.text = "" + +func copy_selected_logs(): + var selected_text = _logs_label.get_selected_text() + if selected_text: + DisplayServer.clipboard_set(selected_text) + +func add_log(text: Variant) -> void: + match typeof(text): + TYPE_STRING: + _logs_label.text += "%s\n" % [text] + TYPE_ARRAY: + for i in text as Array: + _logs_label.text += str(i) + " " + _logs_label.text += "\n" + _: + _logs_label.text += "%s\n" % [str(text)] + +func add_err(text: Variant) -> void: + match typeof(text): + TYPE_STRING: + _logs_label.text += "[img]%s[/img] [color=#FF786B][b]ERROR:[/b] %s[/color]\n" % [ERROR_LOG_ICON, text] + TYPE_ARRAY: + _logs_label.text += "[img]%s[/img] [color=#FF786B][b]ERROR:[/b] " % [ERROR_LOG_ICON] + for i in text as Array: + _logs_label.text += str(i) + " " + _logs_label.text += "[/color]\n" + _: + _logs_label.text += "[img]%s[/img] [color=#FF786B][b]ERROR:[/b] %s[/color]\n" % [ERROR_LOG_ICON, str(text)] + +func destroy() -> void: + if is_instance_valid(self): + SpacetimePlugin.instance.remove_control_from_bottom_panel(self) + queue_free() + _uri_input = null + _modules_container = null + _logs_label = null + _add_module_hint_label = null + _new_module_name_input = null + _new_module_button = null + _check_uri_button = null + _generate_button = null + _clear_logs_button = null + _copy_logs_button = null + +func _on_check_uri() -> void: + check_uri.emit(_uri_input.text) + +func _on_generate_code() -> void: + var modules: Array[String] = [] + for child in _modules_container.get_children(): + var module_name := (child.get_node("ModuleNameInput") as LineEdit).text + modules.append(module_name) + + generate_schema.emit(_uri_input.text, modules) + +func _on_new_module() -> void: + var name := _new_module_name_input.text + add_module(name) + module_added.emit(name) + _new_module_name_input.text = "" + +func _on_clear_logs() -> void: + clear_logs() + +func _on_copy_selected_logs() -> void: + copy_selected_logs() diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.gd.uid b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.gd.uid new file mode 100644 index 00000000000..aa699a60eff --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.gd.uid @@ -0,0 +1 @@ +uid://y32201k503ps diff --git a/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.tscn b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.tscn new file mode 100644 index 00000000000..6eaae27c5db --- /dev/null +++ b/demo/Blackholio/client-godot/addons/SpacetimeDB/ui/ui.tscn @@ -0,0 +1,248 @@ +[gd_scene load_steps=19 format=3 uid="uid://dcklym85vmdt3"] + +[ext_resource type="Script" uid="uid://y32201k503ps" path="res://addons/SpacetimeDB/ui/ui.gd" id="1_ej8d4"] +[ext_resource type="Texture2D" uid="uid://bbwidmvngko0a" path="res://addons/SpacetimeDB/ui/icons/Remove.svg" id="1_mgbt2"] +[ext_resource type="Texture2D" uid="uid://yyihsx4ptqm6" path="res://addons/SpacetimeDB/ui/icons/Add.svg" id="2_3cpcg"] +[ext_resource type="Texture2D" uid="uid://db1mkfv85i0hu" path="res://addons/SpacetimeDB/ui/icons/ActionCopy.svg" id="4_ej8d4"] +[ext_resource type="Texture2D" uid="uid://bj322os6s3pwb" path="res://addons/SpacetimeDB/ui/icons/Clear.svg" id="4_raufm"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_e5a4l"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ej8d4"] + +[sub_resource type="SystemFont" id="SystemFont_3cpcg"] +font_names = PackedStringArray("Consolas", "Monospace") +subpixel_positioning = 0 + +[sub_resource type="SystemFont" id="SystemFont_raufm"] +font_names = PackedStringArray("Consolas", "Monospace") +font_italic = true +subpixel_positioning = 0 + +[sub_resource type="SystemFont" id="SystemFont_ej8d4"] +font_names = PackedStringArray("Consolas", "Monospace") +font_italic = true +font_weight = 600 +subpixel_positioning = 0 + +[sub_resource type="SystemFont" id="SystemFont_d0lf3"] +font_names = PackedStringArray("Consolas", "Monospace") +font_weight = 600 +subpixel_positioning = 0 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_e5a4l"] +content_margin_left = 4.0 +content_margin_top = 6.0 +content_margin_right = 4.0 +bg_color = Color(0.129412, 0.14902, 0.180392, 1) +corner_radius_top_left = 2 +corner_radius_top_right = 2 +corner_radius_bottom_right = 2 +corner_radius_bottom_left = 2 +anti_aliasing = false + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_d0lf3"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_k511v"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_raufm"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_43xhi"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3cpcg"] + +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_qc7xu"] + +[node name="Control" type="Control"] +custom_minimum_size = Vector2(0, 250) +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_ej8d4") + +[node name="Prefabs" type="Node" parent="."] + +[node name="ModulePrefab" type="HBoxContainer" parent="Prefabs"] +visible = false +self_modulate = Color(1, 1, 1, 0) +custom_minimum_size = Vector2(0, 31) +offset_left = 902.0 +offset_top = 64.0 +offset_right = 1152.0 +offset_bottom = 102.0 +theme_override_constants/separation = 5 + +[node name="ModuleNameInput" type="LineEdit" parent="Prefabs/ModulePrefab"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "ModuleName" +placeholder_text = "Module" + +[node name="RemoveButton" type="Button" parent="Prefabs/ModulePrefab"] +custom_minimum_size = Vector2(26, 27) +layout_mode = 2 +size_flags_horizontal = 8 +size_flags_vertical = 4 +tooltip_text = "Remove Module" +theme_override_styles/focus = SubResource("StyleBoxEmpty_e5a4l") +theme_override_styles/normal = SubResource("StyleBoxEmpty_ej8d4") +icon = ExtResource("1_mgbt2") +icon_alignment = 1 + +[node name="Main" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 4 + +[node name="Content" type="HBoxContainer" parent="Main"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 4 + +[node name="Logs" type="RichTextLabel" parent="Main/Content"] +custom_minimum_size = Vector2(0, 9) +layout_mode = 2 +size_flags_horizontal = 3 +focus_mode = 2 +theme_override_constants/line_separation = 3 +theme_override_fonts/normal_font = SubResource("SystemFont_3cpcg") +theme_override_fonts/italics_font = SubResource("SystemFont_raufm") +theme_override_fonts/bold_italics_font = SubResource("SystemFont_ej8d4") +theme_override_fonts/bold_font = SubResource("SystemFont_d0lf3") +theme_override_styles/normal = SubResource("StyleBoxFlat_e5a4l") +bbcode_enabled = true +text = "SpacetimeDB SDK v0.1.0 (c) 2025-present flametime and contributors +[img]res://addons/SpacetimeDB/ui/icons/Error.svg[/img] [color=#FF786B][b]ERROR:[/b] Plugin failed to load[/color] +" +scroll_following = true +shortcut_keys_enabled = false +selection_enabled = true +deselect_on_focus_loss_enabled = false + +[node name="Sidebar" type="VBoxContainer" parent="Main/Content"] +custom_minimum_size = Vector2(250, 0) +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="ModulesLabel" type="Label" parent="Main/Content/Sidebar"] +layout_mode = 2 +text = "Modules" +horizontal_alignment = 1 + +[node name="Modules" type="Control" parent="Main/Content/Sidebar"] +self_modulate = Color(1, 1, 1, 0) +layout_mode = 2 +size_flags_vertical = 3 + +[node name="AddModuleHint" type="RichTextLabel" parent="Main/Content/Sidebar/Modules"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_styles/normal = SubResource("StyleBoxEmpty_d0lf3") +bbcode_enabled = true +text = "Add a module by entering the name below and clicking [img]res://addons/SpacetimeDB/ui/icons/Add.svg[/img]" +scroll_active = false +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="ModulesList" type="ScrollContainer" parent="Main/Content/Sidebar/Modules"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_vertical = 3 + +[node name="VBox" type="VBoxContainer" parent="Main/Content/Sidebar/Modules/ModulesList"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="NewModule" type="HBoxContainer" parent="Main/Content/Sidebar"] +self_modulate = Color(1, 1, 1, 0) +custom_minimum_size = Vector2(0, 35) +layout_mode = 2 +theme_override_constants/separation = 5 + +[node name="ModuleNameInput" type="LineEdit" parent="Main/Content/Sidebar/NewModule"] +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Enter module name" + +[node name="AddButton" type="Button" parent="Main/Content/Sidebar/NewModule"] +custom_minimum_size = Vector2(26, 27) +layout_mode = 2 +size_flags_horizontal = 8 +size_flags_vertical = 4 +tooltip_text = "Add Module" +theme_override_styles/focus = SubResource("StyleBoxEmpty_k511v") +theme_override_styles/normal = SubResource("StyleBoxEmpty_raufm") +icon = ExtResource("2_3cpcg") +icon_alignment = 1 + +[node name="GenerateButton" type="Button" parent="Main/Content/Sidebar"] +custom_minimum_size = Vector2(0, 31) +layout_mode = 2 +disabled = true +text = "Generate schema" + +[node name="BottomBar" type="HBoxContainer" parent="Main"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="LogsControls" type="BoxContainer" parent="Main/BottomBar"] +layout_mode = 2 +theme_override_constants/separation = 6 +alignment = 1 + +[node name="ClearLogsButton" type="Button" parent="Main/BottomBar/LogsControls"] +custom_minimum_size = Vector2(26, 27) +layout_mode = 2 +tooltip_text = "Clear Output (Ctrl+Alt+K)" +theme_override_styles/focus = SubResource("StyleBoxEmpty_43xhi") +theme_override_styles/normal = SubResource("StyleBoxEmpty_3cpcg") +icon = ExtResource("4_raufm") +icon_alignment = 1 + +[node name="CopyLogsButton" type="Button" parent="Main/BottomBar/LogsControls"] +custom_minimum_size = Vector2(26, 27) +layout_mode = 2 +tooltip_text = "Copy Selection (Ctrl+C)" +theme_override_styles/focus = SubResource("StyleBoxEmpty_qc7xu") +theme_override_styles/normal = SubResource("StyleBoxEmpty_3cpcg") +icon = ExtResource("4_ej8d4") +icon_alignment = 1 + +[node name="VSeparator" type="VSeparator" parent="Main/BottomBar"] +layout_mode = 2 + +[node name="ServerUri" type="HBoxContainer" parent="Main/BottomBar"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 0 + +[node name="Label" type="Label" parent="Main/BottomBar/ServerUri"] +layout_mode = 2 +text = "Server URI:" +vertical_alignment = 1 + +[node name="UriInput" type="LineEdit" parent="Main/BottomBar/ServerUri"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "http://127.0.0.1:3000" +placeholder_text = "Enter the server URI" + +[node name="CheckUri" type="Button" parent="Main/BottomBar"] +layout_mode = 2 +text = "Check URI" diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiController.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiController.cs new file mode 100644 index 00000000000..0c03fbd0927 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiController.cs @@ -0,0 +1,151 @@ +#if GODOT_PC +#nullable enable +using Godot; +using ImGuiNET; + +namespace ImGuiGodot; + +public partial class ImGuiController : Node +{ + private Window _window = null!; + public static ImGuiController Instance { get; private set; } = null!; + private ImGuiControllerHelper _helper = null!; + public Node Signaler { get; private set; } = null!; + private readonly StringName _signalName = "imgui_layout"; + + private sealed partial class ImGuiControllerHelper : Node + { + public override void _Ready() + { + Name = "ImGuiControllerHelper"; + ProcessPriority = int.MinValue; + ProcessMode = ProcessModeEnum.Always; + } + + public override void _Process(double delta) + { + Internal.State.Instance.InProcessFrame = true; + var vpSize = Internal.State.Instance.Layer.UpdateViewport(); + Internal.State.Instance.Update(delta, new(vpSize.X, vpSize.Y)); + } + } + + public override void _EnterTree() + { + Instance = this; + _window = GetWindow(); + + CheckContentScale(); + + string cfgPath = (string)ProjectSettings.GetSetting("addons/imgui/config", ""); + Resource? cfg = null; + if (ResourceLoader.Exists(cfgPath)) + { + cfg = ResourceLoader.Load(cfgPath); + float scale = (float)cfg.Get("Scale"); + bool cfgok = scale > 0.0f; + + if (!cfgok) + { + GD.PushError($"imgui-godot: config not a valid ImGuiConfig resource: {cfgPath}"); + cfg = null; + } + } + else if (cfgPath.Length > 0) + { + GD.PushError($"imgui-godot: config does not exist: {cfgPath}"); + } + + Internal.State.Init(cfg ?? (Resource)((GDScript)GD.Load( + "res://addons/imgui-godot/scripts/ImGuiConfig.gd")).New()); + + _helper = new ImGuiControllerHelper(); + AddChild(_helper); + + Signaler = GetParent(); + SetMainViewport(_window); + } + + public override void _Ready() + { + ProcessPriority = int.MaxValue; + ProcessMode = ProcessModeEnum.Always; + } + + public override void _ExitTree() + { + Internal.State.Instance.Dispose(); + } + + public override void _Process(double delta) + { + Signaler.EmitSignal(_signalName); + Internal.State.Instance.Render(); + Internal.State.Instance.InProcessFrame = false; + } + + public override void _Notification(int what) + { + Internal.Input.ProcessNotification(what); + } + + public void OnLayerExiting() + { + // an ImGuiLayer is being destroyed without calling SetMainViewport + if (Internal.State.Instance.Layer.GetViewport() != _window) + { + // revert to main window + SetMainViewport(_window); + } + } + + public void SetMainViewport(Viewport vp) + { + ImGuiLayer? oldLayer = Internal.State.Instance.Layer; + if (oldLayer != null) + { + oldLayer.TreeExiting -= OnLayerExiting; + oldLayer.QueueFree(); + } + + var newLayer = new ImGuiLayer(); + newLayer.TreeExiting += OnLayerExiting; + + if (vp is Window window) + { + Internal.State.Instance.Input = new Internal.Input(); + if (window == _window) + AddChild(newLayer); + else + window.AddChild(newLayer); + ImGui.GetIO().BackendFlags |= ImGuiBackendFlags.PlatformHasViewports + | ImGuiBackendFlags.HasMouseHoveredViewport; + } + else if (vp is SubViewport svp) + { + Internal.State.Instance.Input = new Internal.InputLocal(); + svp.AddChild(newLayer); + ImGui.GetIO().BackendFlags &= ~ImGuiBackendFlags.PlatformHasViewports; + ImGui.GetIO().BackendFlags &= ~ImGuiBackendFlags.HasMouseHoveredViewport; + } + else + { + throw new System.ArgumentException("secret third kind of viewport??", nameof(vp)); + } + Internal.State.Instance.Layer = newLayer; + } + + private void CheckContentScale() + { + if (_window.ContentScaleMode == Window.ContentScaleModeEnum.Viewport) + { + GD.PrintErr("imgui-godot: scale mode `viewport` is unsupported"); + } + } + + public static void WindowInputCallback(InputEvent evt) + { + Internal.State.Instance.Input.ProcessInput(evt); + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiExtensions.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiExtensions.cs new file mode 100644 index 00000000000..2e7120c33e8 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiExtensions.cs @@ -0,0 +1,68 @@ +#if GODOT_PC +using Godot; +using ImGuiNET; +using Vector3 = System.Numerics.Vector3; +using Vector4 = System.Numerics.Vector4; + +namespace ImGuiGodot; + +public static class ImGuiExtensions +{ + /// + /// Extension method to translate between and + /// + public static ImGuiKey ToImGuiKey(this Key key) + { + return Internal.Input.ConvertKey(key); + } + + /// + /// Extension method to translate between and + /// + public static ImGuiKey ToImGuiKey(this JoyButton button) + { + return Internal.Input.ConvertJoyButton(button); + } + + /// + /// Convert to ImGui color RGBA + /// + public static Vector4 ToVector4(this Color color) + { + return new Vector4(color.R, color.G, color.B, color.A); + } + + /// + /// Convert to ImGui color RGB + /// + public static Vector3 ToVector3(this Color color) + { + return new Vector3(color.R, color.G, color.B); + } + + /// + /// Convert RGB to + /// + public static Color ToColor(this Vector3 vec) + { + return new Color(vec.X, vec.Y, vec.Z); + } + + /// + /// Convert RGBA to + /// + public static Color ToColor(this Vector4 vec) + { + return new Color(vec.X, vec.Y, vec.Z, vec.W); + } + + /// + /// Set IniFilename, converting Godot path to native + /// + public static void SetIniFilename(this ImGuiIOPtr io, string fileName) + { + _ = io; + ImGuiGD.SetIniFilename(fileName); + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiGD.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiGD.cs new file mode 100644 index 00000000000..7466093fcf6 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiGD.cs @@ -0,0 +1,133 @@ +#if GODOT_PC +#nullable enable +using Godot; +using System; + +namespace ImGuiGodot; + +public static partial class ImGuiGD +{ + private static readonly Internal.IBackend _backend; + + /// + /// Deadzone for all axes + /// + public static float JoyAxisDeadZone + { + get => _backend.JoyAxisDeadZone; + set => _backend.JoyAxisDeadZone = value; + } + + /// + /// Setting this property will reload fonts and modify the ImGuiStyle. + /// Can only be set outside of a process frame (eg, use CallDeferred) + /// + public static float Scale + { + get => _backend.Scale; + set + { + if (_backend.Scale != value && value >= 0.25f) + { + _backend.Scale = value; + RebuildFontAtlas(); + } + } + } + + public static bool Visible + { + get => _backend.Visible; + set => _backend.Visible = value; + } + + static ImGuiGD() + { + _backend = ClassDB.ClassExists("ImGuiGD") + ? new Internal.BackendNative() + : new Internal.BackendNet(); + } + + public static IntPtr BindTexture(Texture2D tex) + { + return (IntPtr)tex.GetRid().Id; + } + + public static void ResetFonts() + { + _backend.ResetFonts(); + } + + public static void AddFont( + FontFile fontData, + int fontSize, + bool merge = false, + ushort[]? glyphRanges = null) + { + _backend.AddFont(fontData, fontSize, merge, glyphRanges); + } + + /// + /// Add a font using glyph ranges from ImGui.GetIO().Fonts.GetGlyphRanges*() + /// + /// pointer to an array of ushorts terminated by 0 + public static unsafe void AddFont(FontFile fontData, int fontSize, bool merge, nint glyphRanges) + { + ushort* p = (ushort*)glyphRanges; + int len = 1; + while (p[len++] != 0) ; + ushort[] gr = new ushort[len]; + for (int i = 0; i < len; ++i) + gr[i] = p[i]; + _backend.AddFont(fontData, fontSize, merge, gr); + } + + public static void AddFontDefault() + { + _backend.AddFontDefault(); + } + + public static void RebuildFontAtlas() + { + _backend.RebuildFontAtlas(); + } + + public static void Connect(Callable callable) + { + _backend.Connect(callable); + } + + public static void Connect(Action action) + { + Connect(Callable.From(action)); + } + + /// + /// Changes the main viewport to either a new + /// or a . + /// + public static void SetMainViewport(Viewport vp) + { + _backend.SetMainViewport(vp); + } + + /// + /// Must call from a tool script before doing anything else + /// + public static bool ToolInit() + { + if (_backend is Internal.BackendNative nbe) + { + nbe.ToolInit(); + return true; + } + + return false; + } + + public static void SetIniFilename(string filename) + { + _backend.SetIniFilename(filename); + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiLayer.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiLayer.cs new file mode 100644 index 00000000000..331b412fbba --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiLayer.cs @@ -0,0 +1,116 @@ +using Godot; +#if GODOT_PC +#nullable enable + +namespace ImGuiGodot; + +public partial class ImGuiLayer : CanvasLayer +{ + private Rid _subViewportRid; + private Vector2I _subViewportSize = Vector2I.Zero; + private Rid _canvasItem; + private Transform2D _finalTransform = Transform2D.Identity; + private bool _visible = true; + private Viewport _parentViewport = null!; + + public override void _EnterTree() + { + Name = "ImGuiLayer"; + Layer = Internal.State.Instance.LayerNum; + + _parentViewport = GetViewport(); + _subViewportRid = AddLayerSubViewport(this); + _canvasItem = RenderingServer.CanvasItemCreate(); + RenderingServer.CanvasItemSetParent(_canvasItem, GetCanvas()); + + Internal.State.Instance.Renderer.InitViewport(_subViewportRid); + Internal.State.Instance.Viewports.SetMainWindow(GetWindow(), _subViewportRid); + } + + public override void _Ready() + { + VisibilityChanged += OnChangeVisibility; + OnChangeVisibility(); + } + + public override void _ExitTree() + { + RenderingServer.FreeRid(_canvasItem); + RenderingServer.FreeRid(_subViewportRid); + } + + private void OnChangeVisibility() + { + _visible = Visible; + if (_visible) + { + SetProcessInput(true); + } + else + { + SetProcessInput(false); + Internal.State.Instance.Renderer.OnHide(); + _subViewportSize = Vector2I.Zero; + RenderingServer.CanvasItemClear(_canvasItem); + } + } + + public override void _Input(InputEvent @event) + { + if (Internal.State.Instance.Input.ProcessInput(@event)) + { + _parentViewport.SetInputAsHandled(); + } + } + + public Vector2I UpdateViewport() + { + Vector2I vpSize = _parentViewport is Window w ? w.Size + : (_parentViewport as SubViewport)?.Size + ?? throw new System.InvalidOperationException(); + + if (_visible) + { + var ft = _parentViewport.GetFinalTransform(); + if (_subViewportSize != vpSize || _finalTransform != ft) + { + // this is more or less how SubViewportContainer works + _subViewportSize = vpSize; + _finalTransform = ft; + RenderingServer.ViewportSetSize( + _subViewportRid, + _subViewportSize.X, + _subViewportSize.Y); + Rid vptex = RenderingServer.ViewportGetTexture(_subViewportRid); + RenderingServer.CanvasItemClear(_canvasItem); + RenderingServer.CanvasItemSetTransform(_canvasItem, ft.AffineInverse()); + RenderingServer.CanvasItemAddTextureRect( + _canvasItem, + new(0, 0, _subViewportSize.X, _subViewportSize.Y), + vptex); + } + } + + return vpSize; + } + + private static Rid AddLayerSubViewport(Node parent) + { + Rid svp = RenderingServer.ViewportCreate(); + RenderingServer.ViewportSetTransparentBackground(svp, true); + RenderingServer.ViewportSetUpdateMode(svp, RenderingServer.ViewportUpdateMode.Always); + RenderingServer.ViewportSetClearMode(svp, RenderingServer.ViewportClearMode.Always); + RenderingServer.ViewportSetActive(svp, true); + RenderingServer.ViewportSetParentViewport(svp, parent.GetWindow().GetViewportRid()); + return svp; + } +} +#else +namespace ImGuiNET +{ +} + +namespace ImGuiGodot +{ +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiSync.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiSync.cs new file mode 100644 index 00000000000..4c26f0f04d2 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/ImGuiSync.cs @@ -0,0 +1,36 @@ +using Godot; +#if GODOT_PC +using ImGuiNET; +using System.Runtime.InteropServices; +using System; + +namespace ImGuiGodot; + +public partial class ImGuiSync : GodotObject +{ + public static readonly StringName GetImGuiPtrs = "GetImGuiPtrs"; + + public static void SyncPtrs() + { + GodotObject gd = Engine.GetSingleton("ImGuiGD"); + long[] ptrs = (long[])gd.Call(GetImGuiPtrs, + ImGui.GetVersion(), + Marshal.SizeOf(), + Marshal.SizeOf(), + sizeof(ushort), + sizeof(ushort) + ); + + if (ptrs.Length != 3) + { + throw new NotSupportedException("ImGui version mismatch"); + } + + checked + { + ImGui.SetCurrentContext((IntPtr)ptrs[0]); + ImGui.SetAllocatorFunctions((IntPtr)ptrs[1], (IntPtr)ptrs[2]); + } + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/BackendNative.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/BackendNative.cs new file mode 100644 index 00000000000..e02b2f40dd5 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/BackendNative.cs @@ -0,0 +1,104 @@ +#if GODOT_PC +#nullable enable +using Godot; + +namespace ImGuiGodot.Internal; + +internal sealed class BackendNative : IBackend +{ + private readonly GodotObject _gd = Engine.GetSingleton("ImGuiGD"); + + private sealed class MethodName + { + public static readonly StringName AddFont = "AddFont"; + public static readonly StringName AddFontDefault = "AddFontDefault"; + public static readonly StringName Connect = "Connect"; + public static readonly StringName RebuildFontAtlas = "RebuildFontAtlas"; + public static readonly StringName ResetFonts = "ResetFonts"; + public static readonly StringName SetMainViewport = "SetMainViewport"; + public static readonly StringName SubViewport = "SubViewport"; + public static readonly StringName ToolInit = "ToolInit"; + public static readonly StringName SetIniFilename = "SetIniFilename"; + } + + private sealed class PropertyName + { + public static readonly StringName JoyAxisDeadZone = "JoyAxisDeadZone"; + public static readonly StringName Scale = "Scale"; + public static readonly StringName Visible = "Visible"; + } + + public float JoyAxisDeadZone + { + get => (float)_gd.Get(PropertyName.JoyAxisDeadZone); + set => _gd.Set(PropertyName.JoyAxisDeadZone, value); + } + + public float Scale + { + get => (float)_gd.Get(PropertyName.Scale); + set => _gd.Set(PropertyName.Scale, value); + } + + public bool Visible + { + get => (bool)_gd.Get(PropertyName.Visible); + set => _gd.Set(PropertyName.Visible, value); + } + + public void AddFont(FontFile fontData, int fontSize, bool merge, ushort[]? glyphRanges) + { + if (glyphRanges != null) + { + int[] gr = new int[glyphRanges.Length]; + for (int i = 0; i < glyphRanges.Length; ++i) + gr[i] = glyphRanges[i]; + _gd.Call(MethodName.AddFont, fontData, fontSize, merge, gr); + } + else + { + _gd.Call(MethodName.AddFont, fontData, fontSize, merge); + } + } + + public void AddFontDefault() + { + _gd.Call(MethodName.AddFontDefault); + } + + public void Connect(Callable callable) + { + _gd.Call(MethodName.Connect, callable); + } + + public void RebuildFontAtlas() + { + _gd.Call(MethodName.RebuildFontAtlas); + } + + public void ResetFonts() + { + _gd.Call(MethodName.ResetFonts); + } + public void SetMainViewport(Viewport vp) + { + _gd.Call(MethodName.SetMainViewport, vp); + } + + public bool SubViewportWidget(SubViewport svp) + { + return (bool)_gd.Call(MethodName.SubViewport, svp); + } + + public void ToolInit() + { + _gd.Call(MethodName.ToolInit); + ImGuiSync.SyncPtrs(); + } + + public void SetIniFilename(string filename) + { + _gd.Call(MethodName.SetIniFilename, filename); + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/BackendNet.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/BackendNet.cs new file mode 100644 index 00000000000..713d2d59a90 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/BackendNet.cs @@ -0,0 +1,90 @@ +#if GODOT_PC +#nullable enable +using Godot; +using ImGuiNET; +using System; +using Vector2 = System.Numerics.Vector2; + +namespace ImGuiGodot.Internal; + +internal sealed class BackendNet : IBackend +{ + public float JoyAxisDeadZone + { + get => State.Instance.JoyAxisDeadZone; + set => State.Instance.JoyAxisDeadZone = value; + } + + public float Scale + { + get => State.Instance.Scale; + set => State.Instance.Scale = value; + } + + public bool Visible + { + get => State.Instance.Layer.Visible; + set => State.Instance.Layer.Visible = value; + } + + public void AddFont(FontFile fontData, int fontSize, bool merge, ushort[]? glyphRanges) + { + State.Instance.Fonts.AddFont(fontData, fontSize, merge, glyphRanges); + } + + public void AddFontDefault() + { + State.Instance.Fonts.AddFont(null, 13, false, null); + } + + public void Connect(Callable callable) + { + ImGuiController.Instance?.Signaler.Connect("imgui_layout", callable); + } + + public void RebuildFontAtlas() + { + if (State.Instance.InProcessFrame) + throw new InvalidOperationException("fonts can't be changed during process"); + + bool scaleToDpi = (bool)ProjectSettings.GetSetting("display/window/dpi/allow_hidpi"); + int dpiFactor = Math.Max(1, DisplayServer.ScreenGetDpi() / 96); + State.Instance.Fonts.RebuildFontAtlas(scaleToDpi ? dpiFactor * Scale : Scale); + } + + public void ResetFonts() + { + State.Instance.Fonts.ResetFonts(); + } + + public void SetIniFilename(string filename) + { + State.Instance.SetIniFilename(filename); + } + + public void SetMainViewport(Viewport vp) + { + ImGuiController.Instance.SetMainViewport(vp); + } + + public bool SubViewportWidget(SubViewport svp) + { + Vector2 vpSize = new(svp.Size.X, svp.Size.Y); + var pos = ImGui.GetCursorScreenPos(); + var pos_max = new Vector2(pos.X + vpSize.X, pos.Y + vpSize.Y); + ImGui.GetWindowDrawList().AddImage((IntPtr)svp.GetTexture().GetRid().Id, pos, pos_max); + + ImGui.PushID(svp.NativeInstance); + ImGui.InvisibleButton("godot_subviewport", vpSize); + ImGui.PopID(); + + if (ImGui.IsItemHovered()) + { + State.Instance.Input.CurrentSubViewport = svp; + State.Instance.Input.CurrentSubViewportPos = pos; + return true; + } + return false; + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/CanvasRenderer.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/CanvasRenderer.cs new file mode 100644 index 00000000000..0498c114cdb --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/CanvasRenderer.cs @@ -0,0 +1,222 @@ +#if GODOT_PC +using Godot; +using ImGuiNET; +using System; +using System.Collections.Generic; + +namespace ImGuiGodot.Internal; + +internal sealed class CanvasRenderer : IRenderer +{ + private sealed class ViewportData + { + public Rid Canvas { set; get; } + public Rid RootCanvasItem { set; get; } + } + + private readonly Dictionary> _canvasItemPools = []; + private readonly Dictionary _vpData = []; + + public string Name => "godot4_net_canvas"; + + public void InitViewport(Rid vprid) + { + Rid canvas = RenderingServer.CanvasCreate(); + Rid canvasItem = RenderingServer.CanvasItemCreate(); + RenderingServer.ViewportAttachCanvas(vprid, canvas); + RenderingServer.CanvasItemSetParent(canvasItem, canvas); + + _vpData[vprid] = new ViewportData() + { + Canvas = canvas, + RootCanvasItem = canvasItem, + }; + } + + public void Render() + { + var pio = ImGui.GetPlatformIO(); + for (int vpidx = 0; vpidx < pio.Viewports.Size; vpidx++) + { + var vp = pio.Viewports[vpidx]; + Rid vprid = Util.ConstructRid((ulong)vp.RendererUserData); + + RenderOne(vprid, vp.DrawData); + } + } + + private void RenderOne(Rid vprid, ImDrawDataPtr drawData) + { + ViewportData vd = _vpData[vprid]; + Rid parent = vd.RootCanvasItem; + + if (!_canvasItemPools.ContainsKey(parent)) + _canvasItemPools[parent] = []; + + var children = _canvasItemPools[parent]; + + // allocate our CanvasItem pool as needed + int neededNodes = 0; + for (int i = 0; i < drawData.CmdLists.Size; ++i) + { + var cmdBuf = drawData.CmdLists[i].CmdBuffer; + neededNodes += cmdBuf.Size; + for (int j = 0; j < cmdBuf.Size; ++j) + { + if (cmdBuf[j].ElemCount == 0) + --neededNodes; + } + } + + while (children.Count < neededNodes) + { + Rid newChild = RenderingServer.CanvasItemCreate(); + RenderingServer.CanvasItemSetParent(newChild, parent); + RenderingServer.CanvasItemSetDrawIndex(newChild, children.Count); + children.Add(newChild); + } + + // trim unused nodes + while (children.Count > neededNodes) + { + int idx = children.Count - 1; + RenderingServer.FreeRid(children[idx]); + children.RemoveAt(idx); + } + + // render + drawData.ScaleClipRects(ImGui.GetIO().DisplayFramebufferScale); + int nodeN = 0; + + for (int n = 0; n < drawData.CmdLists.Size; ++n) + { + ImDrawListPtr cmdList = drawData.CmdLists[n]; + + int nVert = cmdList.VtxBuffer.Size; + + var vertices = new Vector2[nVert]; + var colors = new Color[nVert]; + var uvs = new Vector2[nVert]; + + for (int i = 0; i < cmdList.VtxBuffer.Size; ++i) + { + var v = cmdList.VtxBuffer[i]; + vertices[i] = new(v.pos.X, v.pos.Y); + // need to reverse the color bytes + uint rgba = v.col; + float r = (rgba & 0xFFu) / 255f; + rgba >>= 8; + float g = (rgba & 0xFFu) / 255f; + rgba >>= 8; + float b = (rgba & 0xFFu) / 255f; + rgba >>= 8; + float a = (rgba & 0xFFu) / 255f; + colors[i] = new(r, g, b, a); + uvs[i] = new(v.uv.X, v.uv.Y); + } + + for (int cmdi = 0; cmdi < cmdList.CmdBuffer.Size; ++cmdi) + { + ImDrawCmdPtr drawCmd = cmdList.CmdBuffer[cmdi]; + + if (drawCmd.ElemCount == 0) + { + continue; + } + + var indices = new int[drawCmd.ElemCount]; + uint idxOffset = drawCmd.IdxOffset; + for (uint i = idxOffset, j = 0; i < idxOffset + drawCmd.ElemCount; ++i, ++j) + { + indices[j] = cmdList.IdxBuffer[(int)i]; + } + + Vector2[] cmdvertices = vertices; + Color[] cmdcolors = colors; + Vector2[] cmduvs = uvs; + if (drawCmd.VtxOffset > 0) + { + // this implementation of RendererHasVtxOffset is awful, + // but we can't do much better without using RenderingDevice directly + var localSize = cmdList.VtxBuffer.Size - drawCmd.VtxOffset; + cmdvertices = new Vector2[localSize]; + cmdcolors = new Color[localSize]; + cmduvs = new Vector2[localSize]; + Array.Copy(vertices, drawCmd.VtxOffset, cmdvertices, 0, localSize); + Array.Copy(colors, drawCmd.VtxOffset, cmdcolors, 0, localSize); + Array.Copy(uvs, drawCmd.VtxOffset, cmduvs, 0, localSize); + } + + Rid child = children[nodeN++]; + + Rid texrid = Util.ConstructRid((ulong)drawCmd.GetTexID()); + RenderingServer.CanvasItemClear(child); + Transform2D xform = Transform2D.Identity; + if (drawData.DisplayPos != System.Numerics.Vector2.Zero) + { + xform = xform.Translated(drawData.DisplayPos.ToVector2I()).Inverse(); + } + RenderingServer.CanvasItemSetTransform(child, xform); + RenderingServer.CanvasItemSetClip(child, true); + RenderingServer.CanvasItemSetCustomRect(child, true, new Rect2( + drawCmd.ClipRect.X, + drawCmd.ClipRect.Y, + drawCmd.ClipRect.Z - drawCmd.ClipRect.X, + drawCmd.ClipRect.W - drawCmd.ClipRect.Y) + ); + + RenderingServer.CanvasItemAddTriangleArray( + child, + indices, + cmdvertices, + cmdcolors, + cmduvs, + null, + null, + texrid, + -1); + } + } + } + + public void CloseViewport(Rid vprid) + { + ViewportData vd = _vpData[vprid]; + ClearCanvasItems(vd.RootCanvasItem); + RenderingServer.FreeRid(vd.RootCanvasItem); + RenderingServer.FreeRid(vd.Canvas); + } + + public void OnHide() + { + ClearCanvasItems(); + } + + public void Dispose() + { + ClearCanvasItems(); + foreach (ViewportData vd in _vpData.Values) + { + RenderingServer.FreeRid(vd.RootCanvasItem); + RenderingServer.FreeRid(vd.Canvas); + } + } + + private void ClearCanvasItems(Rid rootci) + { + foreach (Rid ci in _canvasItemPools[rootci]) + { + RenderingServer.FreeRid(ci); + } + } + + private void ClearCanvasItems() + { + foreach (Rid parent in _canvasItemPools.Keys) + { + ClearCanvasItems(parent); + } + _canvasItemPools.Clear(); + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/DummyRenderer.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/DummyRenderer.cs new file mode 100644 index 00000000000..88b95b683b2 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/DummyRenderer.cs @@ -0,0 +1,30 @@ +#if GODOT_PC +using Godot; + +namespace ImGuiGodot.Internal; + +internal sealed class DummyRenderer : IRenderer +{ + public string Name => "godot4_net_dummy"; + + public void InitViewport(Rid vprid) + { + } + + public void CloseViewport(Rid vprid) + { + } + + public void OnHide() + { + } + + public void Render() + { + } + + public void Dispose() + { + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Fonts.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Fonts.cs new file mode 100644 index 00000000000..21e364addea --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Fonts.cs @@ -0,0 +1,198 @@ +#if GODOT_PC +#nullable enable +using Godot; +using ImGuiNET; +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace ImGuiGodot.Internal; + +internal sealed class Fonts +{ + private Texture2D? _fontTexture; + + private sealed class FontParams + { + public FontFile? Font { get; init; } + public int FontSize { get; init; } + public bool Merge { get; init; } + public ushort[]? Ranges { get; init; } + } + private readonly List _fontConfiguration = []; + + public Fonts() + { + _fontConfiguration.Clear(); + } + + public void ResetFonts() + { + var io = ImGui.GetIO(); + io.Fonts.Clear(); + unsafe { io.NativePtr->FontDefault = null; } + _fontConfiguration.Clear(); + } + + public void AddFont(FontFile? fontData, int fontSize, bool merge, ushort[]? ranges) + { + _fontConfiguration.Add( + new FontParams + { + Font = fontData, + FontSize = fontSize, + Merge = merge, + Ranges = ranges + }); + } + + private static unsafe void AddFontToAtlas(FontParams fp, float scale) + { + var io = ImGui.GetIO(); + int fontSize = (int)(fp.FontSize * scale); + ImFontConfig* fc = ImGuiNative.ImFontConfig_ImFontConfig(); + + if (fp.Merge) + { + fc->MergeMode = 1; + } + + if (fp.Font == null) + { + // default font + var fcptr = new ImFontConfigPtr(fc) + { + SizePixels = fontSize, + OversampleH = 1, + OversampleV = 1, + PixelSnapH = true + }; + io.Fonts.AddFontDefault(fc); + } + else + { + string name = $"{System.IO.Path.GetFileName(fp.Font.ResourcePath)}, {fontSize}px"; + for (int i = 0; i < name.Length && i < 40; ++i) + { + fc->Name[i] = Convert.ToByte(name[i]); + } + + int len = fp.Font.Data.Length; + // let ImGui manage this memory + IntPtr p = ImGui.MemAlloc((uint)len); + Marshal.Copy(fp.Font.Data, 0, p, len); + if (fp.Ranges == null) + { + ImVector ranges = GetRanges(fp.Font); + io.Fonts.AddFontFromMemoryTTF(p, len, fontSize, fc, ranges.Data); + } + else + { + fixed (ushort* pranges = fp.Ranges) + { + io.Fonts.AddFontFromMemoryTTF(p, len, fontSize, fc, (nint)pranges); + } + } + } + + if (fp.Merge) + io.Fonts.Build(); + + ImGuiNative.ImFontConfig_destroy(fc); + } + + private static unsafe ImVector GetRanges(Font font) + { + var builder = new ImFontGlyphRangesBuilderPtr( + ImGuiNative.ImFontGlyphRangesBuilder_ImFontGlyphRangesBuilder()); + builder.AddText(font.GetSupportedChars()); + builder.BuildRanges(out ImVector vec); + builder.Destroy(); + return vec; + } + + private static unsafe void ResetStyle() + { + ImGuiStylePtr defaultStyle = new(ImGuiNative.ImGuiStyle_ImGuiStyle()); + ImGuiStylePtr style = ImGui.GetStyle(); + + style.WindowPadding = defaultStyle.WindowPadding; + style.WindowRounding = defaultStyle.WindowRounding; + style.WindowMinSize = defaultStyle.WindowMinSize; + style.ChildRounding = defaultStyle.ChildRounding; + style.PopupRounding = defaultStyle.PopupRounding; + style.FramePadding = defaultStyle.FramePadding; + style.FrameRounding = defaultStyle.FrameRounding; + style.ItemSpacing = defaultStyle.ItemSpacing; + style.ItemInnerSpacing = defaultStyle.ItemInnerSpacing; + style.CellPadding = defaultStyle.CellPadding; + style.TouchExtraPadding = defaultStyle.TouchExtraPadding; + style.IndentSpacing = defaultStyle.IndentSpacing; + style.ColumnsMinSpacing = defaultStyle.ColumnsMinSpacing; + style.ScrollbarSize = defaultStyle.ScrollbarSize; + style.ScrollbarRounding = defaultStyle.ScrollbarRounding; + style.GrabMinSize = defaultStyle.GrabMinSize; + style.GrabRounding = defaultStyle.GrabRounding; + style.LogSliderDeadzone = defaultStyle.LogSliderDeadzone; + style.TabRounding = defaultStyle.TabRounding; + style.TabMinWidthForCloseButton = defaultStyle.TabMinWidthForCloseButton; + style.SeparatorTextPadding = defaultStyle.SeparatorTextPadding; + style.DockingSeparatorSize = defaultStyle.DockingSeparatorSize; + style.DisplayWindowPadding = defaultStyle.DisplayWindowPadding; + style.DisplaySafeAreaPadding = defaultStyle.DisplaySafeAreaPadding; + style.MouseCursorScale = defaultStyle.MouseCursorScale; + + defaultStyle.Destroy(); + } + + public unsafe void RebuildFontAtlas(float scale) + { + var io = ImGui.GetIO(); + int fontIndex = -1; + + // save current font index + if (io.NativePtr->FontDefault != null) + { + for (int i = 0; i < io.Fonts.Fonts.Size; ++i) + { + if (io.Fonts.Fonts[i].NativePtr == io.FontDefault.NativePtr) + { + fontIndex = i; + break; + } + } + io.NativePtr->FontDefault = null; + } + io.Fonts.Clear(); + + foreach (var fontParams in _fontConfiguration) + { + AddFontToAtlas(fontParams, scale); + } + + io.Fonts.GetTexDataAsRGBA32( + out byte* pixelData, + out int width, + out int height, + out int bytesPerPixel); + + byte[] pixels = new byte[width * height * bytesPerPixel]; + Marshal.Copy((IntPtr)pixelData, pixels, 0, pixels.Length); + + var img = Image.CreateFromData(width, height, false, Image.Format.Rgba8, pixels); + + _fontTexture = ImageTexture.CreateFromImage(img); + io.Fonts.SetTexID((IntPtr)_fontTexture.GetRid().Id); + io.Fonts.ClearTexData(); + + // maintain selected font when rescaling + if (fontIndex != -1 && fontIndex < io.Fonts.Fonts.Size) + { + io.NativePtr->FontDefault = io.Fonts.Fonts[fontIndex].NativePtr; + } + + ResetStyle(); + ImGui.GetStyle().ScaleAllSizes(scale); + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/IBackend.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/IBackend.cs new file mode 100644 index 00000000000..6103259b55d --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/IBackend.cs @@ -0,0 +1,21 @@ +#if GODOT_PC +#nullable enable +using Godot; + +namespace ImGuiGodot.Internal; + +internal interface IBackend +{ + bool Visible { get; set; } + float JoyAxisDeadZone { get; set; } + float Scale { get; set; } + void ResetFonts(); + void AddFont(FontFile fontData, int fontSize, bool merge, ushort[]? glyphRanges); + void AddFontDefault(); + void RebuildFontAtlas(); + void Connect(Callable callable); + void SetMainViewport(Viewport vp); + bool SubViewportWidget(SubViewport svp); + void SetIniFilename(string filename); +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/IRenderer.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/IRenderer.cs new file mode 100644 index 00000000000..a4627e94273 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/IRenderer.cs @@ -0,0 +1,15 @@ +#if GODOT_PC +using Godot; +using System; + +namespace ImGuiGodot.Internal; + +internal interface IRenderer : IDisposable +{ + string Name { get; } + void InitViewport(Rid vprid); + void CloseViewport(Rid vprid); + void Render(); + void OnHide(); +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Input.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Input.cs new file mode 100644 index 00000000000..668081a4f9c --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Input.cs @@ -0,0 +1,438 @@ +#if GODOT_PC +#nullable enable +using Godot; +using ImGuiNET; +using System; +using CursorShape = Godot.DisplayServer.CursorShape; + +namespace ImGuiGodot.Internal; + +internal class Input +{ + internal SubViewport? PreviousSubViewport { get; set; } + internal SubViewport? CurrentSubViewport { get; set; } + internal System.Numerics.Vector2 CurrentSubViewportPos { get; set; } + private Vector2 _mouseWheel = Vector2.Zero; + private ImGuiMouseCursor _currentCursor = ImGuiMouseCursor.None; + private readonly bool _hasMouse = DisplayServer.HasFeature(DisplayServer.Feature.Mouse); + private bool _takingTextInput = false; + + protected virtual void UpdateMousePos(ImGuiIOPtr io) + { + var mousePos = DisplayServer.MouseGetPosition(); + + if (io.ConfigFlags.HasFlag(ImGuiConfigFlags.ViewportsEnable)) + { + if (io.WantSetMousePos) + { + // WarpMouse is relative to the current focused window + foreach (int w in DisplayServer.GetWindowList()) + { + if (DisplayServer.WindowIsFocused(w)) + { + var winPos = DisplayServer.WindowGetPosition(w); + Godot.Input + .WarpMouse(new(io.MousePos.X - winPos.X, io.MousePos.Y - winPos.Y)); + break; + } + } + } + else + { + io.AddMousePosEvent(mousePos.X, mousePos.Y); + uint viewportID = 0; + int windowID = DisplayServer.GetWindowAtScreenPosition(mousePos); + if (windowID != -1) + { + unsafe + { + var vp = ImGui.FindViewportByPlatformHandle(windowID); + if (vp.NativePtr != null) + { + viewportID = vp.ID; + } + } + } + io.AddMouseViewportEvent(viewportID); + } + } + else + { + if (io.WantSetMousePos) + { + Godot.Input.WarpMouse(new(io.MousePos.X, io.MousePos.Y)); + } + else + { + var winPos = State.Instance.Layer.GetWindow().Position; + io.AddMousePosEvent(mousePos.X - winPos.X, mousePos.Y - winPos.Y); + } + } + } + + private void UpdateMouse(ImGuiIOPtr io) + { + UpdateMousePos(io); + + // scrolling works better if we allow no more than one event per frame + if (_mouseWheel != Vector2.Zero) + { +#pragma warning disable IDE0004 // Remove Unnecessary Cast + io.AddMouseWheelEvent((float)_mouseWheel.X, (float)_mouseWheel.Y); +#pragma warning restore IDE0004 // Remove Unnecessary Cast + _mouseWheel = Vector2.Zero; + } + + if (io.WantCaptureMouse && !io.ConfigFlags.HasFlag(ImGuiConfigFlags.NoMouseCursorChange)) + { + var newCursor = ImGui.GetMouseCursor(); + if (newCursor != _currentCursor) + { + DisplayServer.CursorSetShape(ConvertCursorShape(newCursor)); + _currentCursor = newCursor; + } + } + else + { + _currentCursor = ImGuiMouseCursor.None; + } + } + + public void Update(ImGuiIOPtr io) + { + if (_hasMouse) + UpdateMouse(io); + + PreviousSubViewport = CurrentSubViewport; + CurrentSubViewport = null; + } + + protected void ProcessSubViewportWidget(InputEvent evt) + { + if (CurrentSubViewport != null) + { + if (CurrentSubViewport != PreviousSubViewport) + CurrentSubViewport.Notification((int)Node.NotificationVpMouseEnter); + + var vpEvent = evt.Duplicate() as InputEvent; + if (vpEvent is InputEventMouse mouseEvent) + { + var io = ImGui.GetIO(); + var mousePos = DisplayServer.MouseGetPosition(); + var windowPos = Vector2I.Zero; + if (!io.ConfigFlags.HasFlag(ImGuiConfigFlags.ViewportsEnable)) + windowPos = State.Instance.Layer.GetWindow().Position; + + mouseEvent.Position = new Vector2( + mousePos.X - windowPos.X - CurrentSubViewportPos.X, + mousePos.Y - windowPos.Y - CurrentSubViewportPos.Y) + .Clamp(Vector2.Zero, CurrentSubViewport.Size); + } + CurrentSubViewport.PushInput(vpEvent, true); + } + else + { + PreviousSubViewport?.Notification((int)Node.NotificationVpMouseExit); + } + } + + protected bool HandleEvent(InputEvent evt) + { + var io = ImGui.GetIO(); + bool consumed = false; + + if (io.WantTextInput && !_takingTextInput) + { + // avoid IME issues if a text input control was focused + State.Instance.Layer.GetViewport().GuiReleaseFocus(); + + // TODO: show virtual keyboard? + } + _takingTextInput = io.WantTextInput; + + if (evt is InputEventMouseMotion mm) + { + consumed = io.WantCaptureMouse; + mm.Dispose(); + } + else if (evt is InputEventMouseButton mb) + { + switch (mb.ButtonIndex) + { + case MouseButton.Left: + io.AddMouseButtonEvent((int)ImGuiMouseButton.Left, mb.Pressed); + break; + case MouseButton.Right: + io.AddMouseButtonEvent((int)ImGuiMouseButton.Right, mb.Pressed); + break; + case MouseButton.Middle: + io.AddMouseButtonEvent((int)ImGuiMouseButton.Middle, mb.Pressed); + break; + case MouseButton.Xbutton1: + io.AddMouseButtonEvent((int)ImGuiMouseButton.Middle + 1, mb.Pressed); + break; + case MouseButton.Xbutton2: + io.AddMouseButtonEvent((int)ImGuiMouseButton.Middle + 2, mb.Pressed); + break; + case MouseButton.WheelUp: + _mouseWheel.Y = mb.Factor; + break; + case MouseButton.WheelDown: + _mouseWheel.Y = -mb.Factor; + break; + case MouseButton.WheelLeft: + _mouseWheel.X = -mb.Factor; + break; + case MouseButton.WheelRight: + _mouseWheel.X = mb.Factor; + break; + } + consumed = io.WantCaptureMouse; + mb.Dispose(); + } + else if (evt is InputEventKey k) + { + UpdateKeyMods(io); + ImGuiKey igk = ConvertKey(k.Keycode); + bool pressed = k.Pressed; + long unicode = k.Unicode; + + if (igk != ImGuiKey.None) + { + io.AddKeyEvent(igk, pressed); + } + + if (pressed && unicode != 0 && io.WantTextInput) + { + io.AddInputCharacterUTF16((ushort)unicode); + } + + consumed = io.WantCaptureKeyboard || io.WantTextInput; + k.Dispose(); + } + else if (evt is InputEventPanGesture pg) + { + _mouseWheel = new(-pg.Delta.X, -pg.Delta.Y); + consumed = io.WantCaptureMouse; + pg.Dispose(); + } + else if (io.ConfigFlags.HasFlag(ImGuiConfigFlags.NavEnableGamepad)) + { + if (evt is InputEventJoypadButton jb) + { + ImGuiKey igk = ConvertJoyButton(jb.ButtonIndex); + if (igk != ImGuiKey.None) + { + io.AddKeyEvent(igk, jb.Pressed); + consumed = true; + } + jb.Dispose(); + } + else if (evt is InputEventJoypadMotion jm) + { + bool pressed = true; + float v = jm.AxisValue; + if (Math.Abs(v) < State.Instance.JoyAxisDeadZone) + { + v = 0f; + pressed = false; + } + switch (jm.Axis) + { + case JoyAxis.LeftX: + io.AddKeyAnalogEvent(ImGuiKey.GamepadLStickRight, pressed, v); + break; + case JoyAxis.LeftY: + io.AddKeyAnalogEvent(ImGuiKey.GamepadLStickDown, pressed, v); + break; + case JoyAxis.RightX: + io.AddKeyAnalogEvent(ImGuiKey.GamepadRStickRight, pressed, v); + break; + case JoyAxis.RightY: + io.AddKeyAnalogEvent(ImGuiKey.GamepadRStickDown, pressed, v); + break; + case JoyAxis.TriggerLeft: + io.AddKeyAnalogEvent(ImGuiKey.GamepadL2, pressed, v); + break; + case JoyAxis.TriggerRight: + io.AddKeyAnalogEvent(ImGuiKey.GamepadR2, pressed, v); + break; + } + consumed = true; + jm.Dispose(); + } + } + + return consumed; + } + + public virtual bool ProcessInput(InputEvent evt) + { + ProcessSubViewportWidget(evt); + return HandleEvent(evt); + } + + public static void ProcessNotification(long what) + { + switch (what) + { + case MainLoop.NotificationApplicationFocusIn: + ImGui.GetIO().AddFocusEvent(true); + break; + case MainLoop.NotificationApplicationFocusOut: + ImGui.GetIO().AddFocusEvent(false); + break; + case MainLoop.NotificationOsImeUpdate: + // workaround for Godot suppressing key up events during IME + ImGui.GetIO().ClearInputKeys(); + break; + } + } + + private static void UpdateKeyMods(ImGuiIOPtr io) + { + io.AddKeyEvent(ImGuiKey.ModCtrl, Godot.Input.IsKeyPressed(Key.Ctrl)); + io.AddKeyEvent(ImGuiKey.ModShift, Godot.Input.IsKeyPressed(Key.Shift)); + io.AddKeyEvent(ImGuiKey.ModAlt, Godot.Input.IsKeyPressed(Key.Alt)); + io.AddKeyEvent(ImGuiKey.ModSuper, Godot.Input.IsKeyPressed(Key.Meta)); + } + + private static CursorShape ConvertCursorShape(ImGuiMouseCursor cur) => cur switch + { + ImGuiMouseCursor.Arrow => CursorShape.Arrow, + ImGuiMouseCursor.TextInput => CursorShape.Ibeam, + ImGuiMouseCursor.ResizeAll => CursorShape.Move, + ImGuiMouseCursor.ResizeNS => CursorShape.Vsize, + ImGuiMouseCursor.ResizeEW => CursorShape.Hsize, + ImGuiMouseCursor.ResizeNESW => CursorShape.Bdiagsize, + ImGuiMouseCursor.ResizeNWSE => CursorShape.Fdiagsize, + ImGuiMouseCursor.Hand => CursorShape.PointingHand, + ImGuiMouseCursor.NotAllowed => CursorShape.Forbidden, + _ => CursorShape.Arrow, + }; + + public static ImGuiKey ConvertJoyButton(JoyButton btn) => btn switch + { + JoyButton.Start => ImGuiKey.GamepadStart, + JoyButton.Back => ImGuiKey.GamepadBack, + JoyButton.Y => ImGuiKey.GamepadFaceUp, + JoyButton.A => ImGuiKey.GamepadFaceDown, + JoyButton.X => ImGuiKey.GamepadFaceLeft, + JoyButton.B => ImGuiKey.GamepadFaceRight, + JoyButton.DpadUp => ImGuiKey.GamepadDpadUp, + JoyButton.DpadDown => ImGuiKey.GamepadDpadDown, + JoyButton.DpadLeft => ImGuiKey.GamepadDpadLeft, + JoyButton.DpadRight => ImGuiKey.GamepadDpadRight, + JoyButton.LeftShoulder => ImGuiKey.GamepadL1, + JoyButton.RightShoulder => ImGuiKey.GamepadR1, + JoyButton.LeftStick => ImGuiKey.GamepadL3, + JoyButton.RightStick => ImGuiKey.GamepadR3, + _ => ImGuiKey.None + }; + + public static ImGuiKey ConvertKey(Key k) => k switch + { + Key.Tab => ImGuiKey.Tab, + Key.Left => ImGuiKey.LeftArrow, + Key.Right => ImGuiKey.RightArrow, + Key.Up => ImGuiKey.UpArrow, + Key.Down => ImGuiKey.DownArrow, + Key.Pageup => ImGuiKey.PageUp, + Key.Pagedown => ImGuiKey.PageDown, + Key.Home => ImGuiKey.Home, + Key.End => ImGuiKey.End, + Key.Insert => ImGuiKey.Insert, + Key.Delete => ImGuiKey.Delete, + Key.Backspace => ImGuiKey.Backspace, + Key.Space => ImGuiKey.Space, + Key.Enter => ImGuiKey.Enter, + Key.Escape => ImGuiKey.Escape, + Key.Ctrl => ImGuiKey.LeftCtrl, + Key.Shift => ImGuiKey.LeftShift, + Key.Alt => ImGuiKey.LeftAlt, + Key.Meta => ImGuiKey.LeftSuper, + Key.Menu => ImGuiKey.Menu, + Key.Key0 => ImGuiKey._0, + Key.Key1 => ImGuiKey._1, + Key.Key2 => ImGuiKey._2, + Key.Key3 => ImGuiKey._3, + Key.Key4 => ImGuiKey._4, + Key.Key5 => ImGuiKey._5, + Key.Key6 => ImGuiKey._6, + Key.Key7 => ImGuiKey._7, + Key.Key8 => ImGuiKey._8, + Key.Key9 => ImGuiKey._9, + Key.Apostrophe => ImGuiKey.Apostrophe, + Key.Comma => ImGuiKey.Comma, + Key.Minus => ImGuiKey.Minus, + Key.Period => ImGuiKey.Period, + Key.Slash => ImGuiKey.Slash, + Key.Semicolon => ImGuiKey.Semicolon, + Key.Equal => ImGuiKey.Equal, + Key.Bracketleft => ImGuiKey.LeftBracket, + Key.Backslash => ImGuiKey.Backslash, + Key.Bracketright => ImGuiKey.RightBracket, + Key.Quoteleft => ImGuiKey.GraveAccent, + Key.Capslock => ImGuiKey.CapsLock, + Key.Scrolllock => ImGuiKey.ScrollLock, + Key.Numlock => ImGuiKey.NumLock, + Key.Print => ImGuiKey.PrintScreen, + Key.Pause => ImGuiKey.Pause, + Key.Kp0 => ImGuiKey.Keypad0, + Key.Kp1 => ImGuiKey.Keypad1, + Key.Kp2 => ImGuiKey.Keypad2, + Key.Kp3 => ImGuiKey.Keypad3, + Key.Kp4 => ImGuiKey.Keypad4, + Key.Kp5 => ImGuiKey.Keypad5, + Key.Kp6 => ImGuiKey.Keypad6, + Key.Kp7 => ImGuiKey.Keypad7, + Key.Kp8 => ImGuiKey.Keypad8, + Key.Kp9 => ImGuiKey.Keypad9, + Key.KpPeriod => ImGuiKey.KeypadDecimal, + Key.KpDivide => ImGuiKey.KeypadDivide, + Key.KpMultiply => ImGuiKey.KeypadMultiply, + Key.KpSubtract => ImGuiKey.KeypadSubtract, + Key.KpAdd => ImGuiKey.KeypadAdd, + Key.KpEnter => ImGuiKey.KeypadEnter, + Key.A => ImGuiKey.A, + Key.B => ImGuiKey.B, + Key.C => ImGuiKey.C, + Key.D => ImGuiKey.D, + Key.E => ImGuiKey.E, + Key.F => ImGuiKey.F, + Key.G => ImGuiKey.G, + Key.H => ImGuiKey.H, + Key.I => ImGuiKey.I, + Key.J => ImGuiKey.J, + Key.K => ImGuiKey.K, + Key.L => ImGuiKey.L, + Key.M => ImGuiKey.M, + Key.N => ImGuiKey.N, + Key.O => ImGuiKey.O, + Key.P => ImGuiKey.P, + Key.Q => ImGuiKey.Q, + Key.R => ImGuiKey.R, + Key.S => ImGuiKey.S, + Key.T => ImGuiKey.T, + Key.U => ImGuiKey.U, + Key.V => ImGuiKey.V, + Key.W => ImGuiKey.W, + Key.X => ImGuiKey.X, + Key.Y => ImGuiKey.Y, + Key.Z => ImGuiKey.Z, + Key.F1 => ImGuiKey.F1, + Key.F2 => ImGuiKey.F2, + Key.F3 => ImGuiKey.F3, + Key.F4 => ImGuiKey.F4, + Key.F5 => ImGuiKey.F5, + Key.F6 => ImGuiKey.F6, + Key.F7 => ImGuiKey.F7, + Key.F8 => ImGuiKey.F8, + Key.F9 => ImGuiKey.F9, + Key.F10 => ImGuiKey.F10, + Key.F11 => ImGuiKey.F11, + Key.F12 => ImGuiKey.F12, + _ => ImGuiKey.None + }; +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/InputLocal.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/InputLocal.cs new file mode 100644 index 00000000000..99add50ef92 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/InputLocal.cs @@ -0,0 +1,31 @@ +#if GODOT_PC +using Godot; +using ImGuiNET; + +namespace ImGuiGodot.Internal; + +internal sealed class InputLocal : Input +{ + protected override void UpdateMousePos(ImGuiIOPtr io) + { + // do not use global mouse position + } + + public override bool ProcessInput(InputEvent evt) + { + // no support for SubViewport widgets + + if (evt is InputEventMouseMotion mm) + { + var io = ImGui.GetIO(); + var mousePos = mm.Position; +#pragma warning disable IDE0004 // Remove Unnecessary Cast + io.AddMousePosEvent((float)mousePos.X, (float)mousePos.Y); +#pragma warning restore IDE0004 // Remove Unnecessary Cast + mm.Dispose(); + return io.WantCaptureMouse; + } + return HandleEvent(evt); + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/RdRenderer.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/RdRenderer.cs new file mode 100644 index 00000000000..95037533460 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/RdRenderer.cs @@ -0,0 +1,434 @@ +#if GODOT_PC +using Godot; +using ImGuiNET; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace ImGuiGodot.Internal; + +internal sealed class RdRendererException(string message) : ApplicationException(message) +{ +} + +internal class RdRenderer : IRenderer +{ + protected readonly RenderingDevice RD; + private readonly Color[] _clearColors = [new(0f, 0f, 0f, 0f)]; + private readonly Rid _shader; + private readonly Rid _pipeline; + private readonly Rid _sampler; + private readonly long _vtxFormat; + private readonly Dictionary _framebuffers = []; + private readonly float[] _scale = new float[2]; + private readonly float[] _translate = new float[2]; + private readonly byte[] _pcbuf = new byte[16]; + private readonly ArrayPool _bufPool = ArrayPool.Create(); + + private Rid _idxBuffer; + /// + /// size in indices + /// + private int _idxBufferSize = 0; + private Rid _vtxBuffer; + /// + /// size in vertices + /// + private int _vtxBufferSize = 0; + + private readonly Dictionary _uniformSets = new(8); + private readonly HashSet _usedTextures = new(8); + + private readonly Rect2 _zeroRect = new(new(0f, 0f), new(0f, 0f)); +#if !GODOT4_4_OR_GREATER + private readonly Godot.Collections.Array _storageTextures = []; +#endif + private readonly Godot.Collections.Array _srcBuffers = []; + private readonly long[] _vtxOffsets = new long[3]; + private readonly Godot.Collections.Array _uniformArray = []; + + public string Name => "godot4_net_rd"; + + public RdRenderer() + { + RD = RenderingServer.GetRenderingDevice(); + if (RD is null) + throw new RdRendererException("failed to get RenderingDevice"); + + // set up everything to match the official Vulkan backend as closely as possible + + using var shaderFile = ResourceLoader.Load( + "res://addons/imgui-godot/data/ImGuiShader.glsl"); + _shader = RD.ShaderCreateFromSpirV(shaderFile.GetSpirV()); + if (!_shader.IsValid) + throw new RdRendererException("failed to create shader"); + + // create vertex format + uint vtxStride = (uint)Marshal.SizeOf(); + + using RDVertexAttribute attrPoints = new() + { + Location = 0, + Format = RenderingDevice.DataFormat.R32G32Sfloat, + Stride = vtxStride, + Offset = 0 + }; + + using RDVertexAttribute attrUvs = new() + { + Location = 1, + Format = RenderingDevice.DataFormat.R32G32Sfloat, + Stride = vtxStride, + Offset = sizeof(float) * 2 + }; + + using RDVertexAttribute attrColors = new() + { + Location = 2, + Format = RenderingDevice.DataFormat.R8G8B8A8Unorm, + Stride = vtxStride, + Offset = sizeof(float) * 4 + }; + + var vattrs = new Godot.Collections.Array() { + attrPoints, + attrUvs, + attrColors }; + _vtxFormat = RD.VertexFormatCreate(vattrs); + + // blend state + using var bsa = new RDPipelineColorBlendStateAttachment + { + EnableBlend = true, + + SrcColorBlendFactor = RenderingDevice.BlendFactor.SrcAlpha, + DstColorBlendFactor = RenderingDevice.BlendFactor.OneMinusSrcAlpha, + ColorBlendOp = RenderingDevice.BlendOperation.Add, + + SrcAlphaBlendFactor = RenderingDevice.BlendFactor.One, + DstAlphaBlendFactor = RenderingDevice.BlendFactor.OneMinusSrcAlpha, + AlphaBlendOp = RenderingDevice.BlendOperation.Add, + }; + + using var blendData = new RDPipelineColorBlendState + { + BlendConstant = new Color(0, 0, 0, 0), + }; + blendData.Attachments.Add(bsa); + + // rasterization state + using var rasterizationState = new RDPipelineRasterizationState + { + FrontFace = RenderingDevice.PolygonFrontFace.CounterClockwise + }; + + using var af = new RDAttachmentFormat() + { + Format = RenderingDevice.DataFormat.R8G8B8A8Unorm, + Samples = RenderingDevice.TextureSamples.Samples1, + UsageFlags = (uint)RenderingDevice.TextureUsageBits.ColorAttachmentBit, + }; + + long fbFormat = RD.FramebufferFormatCreate([af]); + + // pipeline + _pipeline = RD.RenderPipelineCreate( + _shader, + fbFormat, + _vtxFormat, + RenderingDevice.RenderPrimitive.Triangles, + rasterizationState, + new RDPipelineMultisampleState(), + new RDPipelineDepthStencilState(), + blendData); + + if (!_pipeline.IsValid) + throw new RdRendererException("failed to create pipeline"); + + // sampler used for all textures + using var samplerState = new RDSamplerState + { + MinFilter = RenderingDevice.SamplerFilter.Linear, + MagFilter = RenderingDevice.SamplerFilter.Linear, + MipFilter = RenderingDevice.SamplerFilter.Linear, + RepeatU = RenderingDevice.SamplerRepeatMode.Repeat, + RepeatV = RenderingDevice.SamplerRepeatMode.Repeat, + RepeatW = RenderingDevice.SamplerRepeatMode.Repeat + }; + _sampler = RD.SamplerCreate(samplerState); + if (!_sampler.IsValid) + throw new RdRendererException("failed to create sampler"); + + _srcBuffers.Resize(3); + _uniformArray.Resize(1); + } + + public void InitViewport(Rid vprid) + { + RenderingServer.ViewportSetClearMode(vprid, RenderingServer.ViewportClearMode.Never); + } + + public void CloseViewport(Rid vprid) + { + } + + private void SetupBuffers(ImDrawDataPtr drawData) + { + int vertSize = Marshal.SizeOf(); + int globalIdxOffset = 0; + int globalVtxOffset = 0; + + int idxBufSize = drawData.TotalIdxCount * sizeof(ushort); + byte[] idxBuf = _bufPool.Rent(idxBufSize); + + int vertBufSize = drawData.TotalVtxCount * vertSize; + byte[] vertBuf = _bufPool.Rent(vertBufSize); + + for (int i = 0; i < drawData.CmdLists.Size; ++i) + { + ImDrawListPtr cmdList = drawData.CmdLists[i]; + + int vertBytes = cmdList.VtxBuffer.Size * vertSize; + Marshal.Copy(cmdList.VtxBuffer.Data, vertBuf, globalVtxOffset, vertBytes); + globalVtxOffset += vertBytes; + + int idxBytes = cmdList.IdxBuffer.Size * sizeof(ushort); + Marshal.Copy(cmdList.IdxBuffer.Data, idxBuf, globalIdxOffset, idxBytes); + globalIdxOffset += idxBytes; + + // create a uniform set for each texture + for (int cmdi = 0; cmdi < cmdList.CmdBuffer.Size; ++cmdi) + { + ImDrawCmdPtr drawCmd = cmdList.CmdBuffer[cmdi]; + IntPtr texid = drawCmd.GetTexID(); + if (texid == IntPtr.Zero) + continue; + Rid texrid = Util.ConstructRid((ulong)texid); + if (!RD.TextureIsValid(texrid)) + continue; + + _usedTextures.Add(texid); + if (!_uniformSets.ContainsKey(texid)) + { + using RDUniform uniform = new() + { + Binding = 0, + UniformType = RenderingDevice.UniformType.SamplerWithTexture + }; + uniform.AddId(_sampler); + uniform.AddId(texrid); + _uniformArray[0] = uniform; + _uniformSets[texid] = RD.UniformSetCreate(_uniformArray, _shader, 0); + } + } + } + RD.BufferUpdate(_idxBuffer, 0, (uint)idxBufSize, idxBuf); + _bufPool.Return(idxBuf); + RD.BufferUpdate(_vtxBuffer, 0, (uint)vertBufSize, vertBuf); + _bufPool.Return(vertBuf); + } + + protected static void ReplaceTextureRids(ImDrawDataPtr drawData) + { + for (int i = 0; i < drawData.CmdLists.Size; ++i) + { + ImDrawListPtr cmdList = drawData.CmdLists[i]; + for (int cmdi = 0; cmdi < cmdList.CmdBuffer.Size; ++cmdi) + { + ImDrawCmdPtr drawCmd = cmdList.CmdBuffer[cmdi]; + drawCmd.TextureId = (IntPtr)RenderingServer.TextureGetRdTexture( + Util.ConstructRid((ulong)drawCmd.TextureId)).Id; + } + } + } + + protected void FreeUnusedTextures() + { + // clean up unused textures + foreach (IntPtr texid in _uniformSets.Keys) + { + if (!_usedTextures.Contains(texid)) + { + RD.FreeRid(_uniformSets[texid]); + _uniformSets.Remove(texid); + } + } + _usedTextures.Clear(); + } + + public void Render() + { + var pio = ImGui.GetPlatformIO(); + for (int i = 0; i < pio.Viewports.Size; ++i) + { + var vp = pio.Viewports[i]; + if (!vp.Flags.HasFlag(ImGuiViewportFlags.IsMinimized)) + { + ReplaceTextureRids(vp.DrawData); + Rid vprid = Util.ConstructRid((ulong)vp.RendererUserData); + RenderOne(GetFramebuffer(vprid), vp.DrawData); + } + } + FreeUnusedTextures(); + } + + protected void RenderOne(Rid fb, ImDrawDataPtr drawData) + { +#if IMGUI_GODOT_DEV + RD.DrawCommandBeginLabel("ImGui", Colors.Purple); +#endif + + if (!fb.IsValid) + return; + + int vertSize = Marshal.SizeOf(); + + _scale[0] = 2.0f / drawData.DisplaySize.X; + _scale[1] = 2.0f / drawData.DisplaySize.Y; + + _translate[0] = -1.0f - (drawData.DisplayPos.X * _scale[0]); + _translate[1] = -1.0f - (drawData.DisplayPos.Y * _scale[1]); + + Buffer.BlockCopy(_scale, 0, _pcbuf, 0, 8); + Buffer.BlockCopy(_translate, 0, _pcbuf, 8, 8); + + // allocate merged index and vertex buffers + if (_idxBufferSize < drawData.TotalIdxCount) + { + if (_idxBuffer.IsValid) + RD.FreeRid(_idxBuffer); + _idxBuffer = RD.IndexBufferCreate( + (uint)drawData.TotalIdxCount, + RenderingDevice.IndexBufferFormat.Uint16); + _idxBufferSize = drawData.TotalIdxCount; + } + + if (_vtxBufferSize < drawData.TotalVtxCount) + { + if (_vtxBuffer.IsValid) + RD.FreeRid(_vtxBuffer); + _vtxBuffer = RD.VertexBufferCreate((uint)(drawData.TotalVtxCount * vertSize)); + _vtxBufferSize = drawData.TotalVtxCount; + } + + // check if our font texture is still valid + foreach (var (texid, uniformSetRid) in _uniformSets) + { + if (!RD.UniformSetIsValid(uniformSetRid)) + _uniformSets.Remove(texid); + } + + if (drawData.CmdListsCount > 0) + SetupBuffers(drawData); + + // draw +#if GODOT4_4_OR_GREATER + long dl = RD.DrawListBegin( + fb, + RenderingDevice.DrawFlags.ClearAll, + _clearColors, + 1f, + 0, + _zeroRect); +#else + const RenderingDevice.FinalAction finalAction = +#if GODOT4_3_OR_GREATER + RenderingDevice.FinalAction.Store; +#else + RenderingDevice.FinalAction.Read; +#endif + long dl = RD.DrawListBegin(fb, + RenderingDevice.InitialAction.Clear, finalAction, + RenderingDevice.InitialAction.Clear, finalAction, + _clearColors, 1f, 0, _zeroRect, _storageTextures); +#endif + + RD.DrawListBindRenderPipeline(dl, _pipeline); + RD.DrawListSetPushConstant(dl, _pcbuf, (uint)_pcbuf.Length); + + int globalIdxOffset = 0; + int globalVtxOffset = 0; + for (int i = 0; i < drawData.CmdLists.Size; ++i) + { + ImDrawListPtr cmdList = drawData.CmdLists[i]; + + for (int cmdi = 0; cmdi < cmdList.CmdBuffer.Size; ++cmdi) + { + ImDrawCmdPtr drawCmd = cmdList.CmdBuffer[cmdi]; + if (drawCmd.ElemCount == 0) + continue; + if (!_uniformSets.ContainsKey(drawCmd.GetTexID())) + continue; + + Rid idxArray = RD.IndexArrayCreate(_idxBuffer, + (uint)(drawCmd.IdxOffset + globalIdxOffset), + drawCmd.ElemCount); + + long voff = (drawCmd.VtxOffset + globalVtxOffset) * vertSize; + _srcBuffers[0] = _srcBuffers[1] = _srcBuffers[2] = _vtxBuffer; + _vtxOffsets[0] = _vtxOffsets[1] = _vtxOffsets[2] = voff; + Rid vtxArray = RD.VertexArrayCreate( + (uint)cmdList.VtxBuffer.Size, + _vtxFormat, + _srcBuffers, + _vtxOffsets); + + RD.DrawListBindUniformSet(dl, _uniformSets[drawCmd.GetTexID()], 0); + RD.DrawListBindIndexArray(dl, idxArray); + RD.DrawListBindVertexArray(dl, vtxArray); + + var clipRect = new Rect2( + drawCmd.ClipRect.X, + drawCmd.ClipRect.Y, + drawCmd.ClipRect.Z - drawCmd.ClipRect.X, + drawCmd.ClipRect.W - drawCmd.ClipRect.Y); + clipRect.Position -= drawData.DisplayPos.ToVector2I(); + RD.DrawListEnableScissor(dl, clipRect); + + RD.DrawListDraw(dl, true, 1); + + RD.FreeRid(idxArray); + RD.FreeRid(vtxArray); + } + globalIdxOffset += cmdList.IdxBuffer.Size; + globalVtxOffset += cmdList.VtxBuffer.Size; + } + RD.DrawListEnd(); +#if IMGUI_GODOT_DEV + RD.DrawCommandEndLabel(); +#endif + } + + public void OnHide() + { + } + + public void Dispose() + { + RD.FreeRid(_sampler); + RD.FreeRid(_shader); + if (_idxBuffer.IsValid) + RD.FreeRid(_idxBuffer); + if (_vtxBuffer.IsValid) + RD.FreeRid(_vtxBuffer); + } + + protected Rid GetFramebuffer(Rid vprid) + { + if (!vprid.IsValid) + return new Rid(); + + if (_framebuffers.TryGetValue(vprid, out Rid fb)) + { + if (RD.FramebufferIsValid(fb)) + return fb; + } + + Rid vptex = RenderingServer.TextureGetRdTexture(RenderingServer.ViewportGetTexture(vprid)); + fb = RD.FramebufferCreate([vptex]); + _framebuffers[vprid] = fb; + return fb; + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/RdRendererThreadSafe.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/RdRendererThreadSafe.cs new file mode 100644 index 00000000000..c77a799a091 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/RdRendererThreadSafe.cs @@ -0,0 +1,162 @@ +#if GODOT_PC +#nullable enable +using Godot; +using ImGuiNET; +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +using SharedList = ImGuiGodot.Internal.DisposableList; + +namespace ImGuiGodot.Internal; + +internal sealed class ClonedDrawData : IDisposable +{ + public ImDrawDataPtr Data { get; private set; } + + public unsafe ClonedDrawData(ImDrawDataPtr inp) + { + // deep swap is difficult because ImGui still owns the draw lists + // TODO: revisit when Godot's threaded renderer is stable + + long ddsize = Marshal.SizeOf(); + + // start with a shallow copy + Data = new(ImGui.MemAlloc((uint)ddsize)); + Buffer.MemoryCopy(inp.NativePtr, Data.NativePtr, ddsize, ddsize); + + // clone the draw data + int numLists = inp.CmdLists.Size; + IntPtr cmdListPtrs = ImGui.MemAlloc((uint)(Marshal.SizeOf() * numLists)); + Data.NativePtr->CmdLists = new ImVector(numLists, numLists, cmdListPtrs); + for (int i = 0; i < inp.CmdLists.Size; ++i) + { + Data.CmdLists[i] = (IntPtr)inp.CmdLists[i].CloneOutput().NativePtr; + } + } + + public unsafe void Dispose() + { + if (Data.NativePtr == null) + return; + + for (int i = 0; i < Data.CmdListsCount; ++i) + { + Data.CmdLists[i].Destroy(); + } + Data.Destroy(); + Data = new(null); + } +} + +internal sealed class DisposableList : List>, IDisposable where U : IDisposable +{ + public DisposableList() { } + public DisposableList(int capacity) : base(capacity) { } + + public void Dispose() + { + foreach (var (_, u) in this) + { + u.Dispose(); + } + Clear(); + } +} + +internal sealed class RdRendererThreadSafe : RdRenderer, IRenderer +{ + public new string Name => "godot4_net_rd_mt"; + +#if GODOT4_3_OR_GREATER + public new void Render() + { + var pio = ImGui.GetPlatformIO(); + var newData = new SharedList(pio.Viewports.Size); + + for (int i = 0; i < pio.Viewports.Size; ++i) + { + var vp = pio.Viewports[i]; + if (vp.Flags.HasFlag(ImGuiViewportFlags.IsMinimized)) + continue; + + Rid vprid = Util.ConstructRid((ulong)vp.RendererUserData); + newData.Add(new(vprid, new(vp.DrawData))); + } + + RenderingServer.CallOnRenderThread(Callable.From(() => DrawOnRenderThread(newData))); + } + + private void DrawOnRenderThread(SharedList dataArray) + { + foreach (var (vprid, clone) in dataArray) + { + Rid fb = GetFramebuffer(vprid); + if (RD.FramebufferIsValid(fb)) + { + ReplaceTextureRids(clone.Data); + RenderOne(fb, clone.Data); + } + } + + FreeUnusedTextures(); + dataArray.Dispose(); + } +#else + private SharedList? _dataToDraw; + + public RdRendererThreadSafe() + { + // draw on the renderer thread to avoid conflicts + RenderingServer.FramePreDraw += OnFramePreDraw; + } + + ~RdRendererThreadSafe() + { + RenderingServer.FramePreDraw -= OnFramePreDraw; + } + + public new void Render() + { + var pio = ImGui.GetPlatformIO(); + var newData = new SharedList(pio.Viewports.Size); + + for (int i = 0; i < pio.Viewports.Size; ++i) + { + var vp = pio.Viewports[i]; + if (vp.Flags.HasFlag(ImGuiViewportFlags.IsMinimized)) + continue; + + ReplaceTextureRids(vp.DrawData); + Rid vprid = Util.ConstructRid((ulong)vp.RendererUserData); + newData.Add(new(GetFramebuffer(vprid), new(vp.DrawData))); + } + + // if a frame was skipped, free old data + var oldData = System.Threading.Interlocked.Exchange(ref _dataToDraw, newData); + oldData?.Dispose(); + } + + private SharedList TakeSharedData() + { + var rv = System.Threading.Interlocked.Exchange(ref _dataToDraw, null); + return rv ?? []; + } + + private void OnFramePreDraw() + { + // take ownership of shared data + using SharedList dataArray = TakeSharedData(); + + foreach (var (fb, clone) in dataArray) + { + if (RD.FramebufferIsValid(fb)) + RenderOne(fb, clone.Data); + } + + FreeUnusedTextures(); + } +#endif +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/State.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/State.cs new file mode 100644 index 00000000000..999177ccbc6 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/State.cs @@ -0,0 +1,239 @@ +#if GODOT_PC +using Godot; +using ImGuiNET; +using System; +using System.Runtime.InteropServices; + +namespace ImGuiGodot.Internal; + +internal sealed class State : IDisposable +{ + private enum RendererType + { + Dummy, + Canvas, + RenderingDevice + } + + private static readonly IntPtr _backendName = Marshal.StringToCoTaskMemAnsi("godot4_net"); + private static IntPtr _rendererName = IntPtr.Zero; + private static nint _clipBuf = 0; + private IntPtr _iniFilenameBuffer = IntPtr.Zero; + + internal Viewports Viewports { get; } + internal Fonts Fonts { get; } + internal Input Input { get; set; } + internal IRenderer Renderer { get; } + + internal float Scale { get; set; } = 1.0f; + internal float JoyAxisDeadZone { get; set; } = 0.15f; + internal int LayerNum { get; private set; } = 128; + internal ImGuiLayer Layer { get; set; } = null!; + internal bool InProcessFrame { get; set; } + + internal static State Instance { get; set; } = null!; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void PlatformSetImeDataFn( + nint ctx, + ImGuiViewportPtr vp, + ImGuiPlatformImeDataPtr data); + private static readonly PlatformSetImeDataFn _setImeData = SetImeData; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void SetClipboardTextFn(nint ud, nint text); + private static readonly SetClipboardTextFn _setClipboardText = SetClipboardText; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate nint GetClipboardTextFn(nint ud); + private static readonly GetClipboardTextFn _getClipboardText = GetClipboardText; + + public State(IRenderer renderer) + { + Renderer = renderer; + Input = new Input(); + Fonts = new Fonts(); + + if (ImGui.GetCurrentContext() != IntPtr.Zero) + { + ImGui.DestroyContext(); + } + + var context = ImGui.CreateContext(); + ImGui.SetCurrentContext(context); + + var io = ImGui.GetIO(); + io.BackendFlags = + ImGuiBackendFlags.HasGamepad | + ImGuiBackendFlags.HasSetMousePos | + ImGuiBackendFlags.HasMouseCursors | + ImGuiBackendFlags.RendererHasVtxOffset | + ImGuiBackendFlags.RendererHasViewports; + + if (_rendererName == IntPtr.Zero) + { + _rendererName = Marshal.StringToCoTaskMemAnsi(Renderer.Name); + } + + unsafe + { + io.NativePtr->BackendPlatformName = (byte*)_backendName; + io.NativePtr->BackendRendererName = (byte*)_rendererName; + + var pio = ImGui.GetPlatformIO().NativePtr; + pio->Platform_SetImeDataFn = Marshal.GetFunctionPointerForDelegate(_setImeData); + pio->Platform_SetClipboardTextFn = Marshal.GetFunctionPointerForDelegate( + _setClipboardText); + pio->Platform_GetClipboardTextFn = Marshal.GetFunctionPointerForDelegate( + _getClipboardText); + } + + Viewports = new Viewports(); + } + + public void Dispose() + { + if (ImGui.GetCurrentContext() != IntPtr.Zero) + ImGui.DestroyContext(); + Renderer.Dispose(); + } + + public static void Init(Resource cfg) + { + if (IntPtr.Size != sizeof(ulong)) + throw new PlatformNotSupportedException("imgui-godot requires 64-bit pointers"); + + RendererType rendererType = Enum.Parse((string)cfg.Get("Renderer")); + + if (DisplayServer.GetName() == "headless") + rendererType = RendererType.Dummy; + + // fall back to Canvas in OpenGL compatibility mode + if (rendererType == RendererType.RenderingDevice + && RenderingServer.GetRenderingDevice() == null) + { + rendererType = RendererType.Canvas; + } + + // there's no way to get the actual current thread model, eg if --render-thread is used + int threadModel = (int)ProjectSettings.GetSetting("rendering/driver/threads/thread_model"); + + IRenderer renderer; + try + { + renderer = rendererType switch + { + RendererType.Dummy => new DummyRenderer(), + RendererType.Canvas => new CanvasRenderer(), + RendererType.RenderingDevice => threadModel == 2 + ? new RdRendererThreadSafe() + : new RdRenderer(), + _ => throw new ArgumentException("Invalid renderer", nameof(cfg)) + }; + } + catch (Exception e) + { + if (rendererType == RendererType.RenderingDevice) + { + GD.PushWarning($"imgui-godot: falling back to Canvas renderer ({e.Message})"); + renderer = new CanvasRenderer(); + } + else + { + GD.PushError("imgui-godot: failed to init renderer"); + renderer = new DummyRenderer(); + } + } + + Instance = new(renderer) + { + Scale = (float)cfg.Get("Scale"), + LayerNum = (int)cfg.Get("Layer") + }; + + ImGui.GetIO().SetIniFilename((string)cfg.Get("IniFilename")); + + var fonts = (Godot.Collections.Array)cfg.Get("Fonts"); + + for (int i = 0; i < fonts.Count; ++i) + { + var fontres = (Resource)fonts[i]; + var fontData = (FontFile)fontres.Get("FontData"); + int fontSize = (int)fontres.Get("FontSize"); + bool merge = (bool)fontres.Get("Merge"); + if (i == 0) + ImGuiGD.AddFont(fontData, fontSize); + else + ImGuiGD.AddFont(fontData, fontSize, merge); + } + if ((bool)cfg.Get("AddDefaultFont")) + ImGuiGD.AddFontDefault(); + ImGuiGD.RebuildFontAtlas(); + } + + public unsafe void SetIniFilename(string fileName) + { + var io = ImGui.GetIO(); + io.NativePtr->IniFilename = null; + + if (_iniFilenameBuffer != IntPtr.Zero) + { + Marshal.FreeCoTaskMem(_iniFilenameBuffer); + _iniFilenameBuffer = IntPtr.Zero; + } + + if (fileName?.Length > 0) + { + fileName = ProjectSettings.GlobalizePath(fileName); + _iniFilenameBuffer = Marshal.StringToCoTaskMemUTF8(fileName); + io.NativePtr->IniFilename = (byte*)_iniFilenameBuffer; + } + } + + public void Update(double delta, System.Numerics.Vector2 displaySize) + { + var io = ImGui.GetIO(); + io.DisplaySize = displaySize; + io.DeltaTime = (float)delta; + + Input.Update(io); + + ImGui.NewFrame(); + } + + public void Render() + { + ImGui.Render(); + ImGui.UpdatePlatformWindows(); + Renderer.Render(); + } + + private static void SetImeData(nint ctx, ImGuiViewportPtr vp, ImGuiPlatformImeDataPtr data) + { + int windowID = (int)vp.PlatformHandle; + + DisplayServer.WindowSetImeActive(data.WantVisible, windowID); + if (data.WantVisible) + { + Vector2I pos = new( + (int)(data.InputPos.X - vp.Pos.X), + (int)(data.InputPos.Y - vp.Pos.Y + data.InputLineHeight) + ); + DisplayServer.WindowSetImePosition(pos, windowID); + } + } + + private static void SetClipboardText(nint ud, nint text) + { + DisplayServer.ClipboardSet(Marshal.PtrToStringUTF8(text)); + } + + private static nint GetClipboardText(nint ud) + { + if (_clipBuf != 0) + Marshal.FreeCoTaskMem(_clipBuf); + _clipBuf = Marshal.StringToCoTaskMemUTF8(DisplayServer.ClipboardGet()); + return _clipBuf; + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Util.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Util.cs new file mode 100644 index 00000000000..8a204aa5c0c --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Util.cs @@ -0,0 +1,10 @@ +using Godot; +using System.Runtime.CompilerServices; + +namespace ImGuiGodot.Internal; + +internal static class Util +{ + [UnsafeAccessor(UnsafeAccessorKind.Constructor)] + public static extern Rid ConstructRid(ulong id); +} diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Viewports.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Viewports.cs new file mode 100644 index 00000000000..68e1f1e7856 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Internal/Viewports.cs @@ -0,0 +1,353 @@ +#if GODOT_PC +using Godot; +using ImGuiNET; +using System; +using System.Runtime.InteropServices; +using Vector2 = System.Numerics.Vector2; + +namespace ImGuiGodot.Internal; + +internal sealed class GodotImGuiWindow : IDisposable +{ + private readonly GCHandle _gcHandle; + private readonly ImGuiViewportPtr _vp; + private readonly Window _window; + private readonly bool _isOwnedWindow = false; + + /// + /// sub window + /// + public GodotImGuiWindow(ImGuiViewportPtr vp) + { + _gcHandle = GCHandle.Alloc(this); + _vp = vp; + _vp.PlatformUserData = (IntPtr)_gcHandle; + _isOwnedWindow = true; + + Rect2I winRect = new(_vp.Pos.ToVector2I(), _vp.Size.ToVector2I()); + + Window mainWindow = ImGuiController.Instance.GetWindow(); + if (mainWindow.GuiEmbedSubwindows) + { + if ((bool)ProjectSettings.GetSetting("display/window/subwindows/embed_subwindows")) + { + GD.PushWarning( + "ImGui Viewports: 'display/window/subwindows/embed_subwindows' needs to be disabled"); + } + mainWindow.GuiEmbedSubwindows = false; + } + + _window = new Window() + { + Borderless = true, + Position = winRect.Position, + Size = winRect.Size, + Transparent = true, + TransparentBg = true, + AlwaysOnTop = vp.Flags.HasFlag(ImGuiViewportFlags.TopMost), + Unfocusable = vp.Flags.HasFlag(ImGuiViewportFlags.NoFocusOnClick) + }; + + _window.CloseRequested += () => _vp.PlatformRequestClose = true; + _window.SizeChanged += () => _vp.PlatformRequestResize = true; + _window.WindowInput += ImGuiController.WindowInputCallback; + + ImGuiController.Instance.AddChild(_window); + + // need to do this after AddChild + _window.Transparent = true; + + // it's our window, so just draw directly to the root viewport + var vprid = _window.GetViewportRid(); + _vp.RendererUserData = (IntPtr)vprid.Id; + _vp.PlatformHandle = _window.GetWindowId(); + + State.Instance.Renderer.InitViewport(vprid); + RenderingServer.ViewportSetTransparentBackground(_window.GetViewportRid(), true); + } + + /// + /// main window + /// + public GodotImGuiWindow(ImGuiViewportPtr vp, Window gw, Rid mainSubViewport) + { + _gcHandle = GCHandle.Alloc(this); + _vp = vp; + _vp.PlatformUserData = (IntPtr)_gcHandle; + _window = gw; + _vp.RendererUserData = (IntPtr)mainSubViewport.Id; + } + + public void Dispose() + { + if (_gcHandle.IsAllocated) + { + if (_isOwnedWindow) + { + State.Instance.Renderer + .CloseViewport(Util.ConstructRid((ulong)_vp.RendererUserData)); + _window.GetParent().RemoveChild(_window); + _window.Free(); + } + _gcHandle.Free(); + } + } + + public void ShowWindow() + { + _window.Show(); + } + + public void SetWindowPos(Vector2I pos) + { + _window.Position = pos; + } + + public Vector2I GetWindowPos() + { + return _window.Position; + } + + public void SetWindowSize(Vector2I size) + { + _window.Size = size; + } + + public Vector2I GetWindowSize() + { + return _window.Size; + } + + public void SetWindowFocus() + { + _window.GrabFocus(); + } + + public bool GetWindowFocus() + { + return _window.HasFocus(); + } + + public bool GetWindowMinimized() + { + return _window.Mode.HasFlag(Window.ModeEnum.Minimized); + } + + public void SetWindowTitle(string title) + { + _window.Title = title; + } +} + +internal static class ViewportsExts +{ + internal static Vector2 ToImVec2(this Vector2I v) + { + return new Vector2(v.X, v.Y); + } + + internal static Vector2I ToVector2I(this Vector2 v) + { + return new Vector2I((int)v.X, (int)v.Y); + } +} + +internal sealed partial class Viewports +{ + [LibraryImport("cimgui")] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + private static unsafe partial void ImGuiPlatformIO_Set_Platform_GetWindowPos( + ImGuiPlatformIO* platform_io, + IntPtr funcPtr); + [LibraryImport("cimgui")] + [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + private static unsafe partial void ImGuiPlatformIO_Set_Platform_GetWindowSize( + ImGuiPlatformIO* platform_io, + IntPtr funcPtr); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_CreateWindow(ImGuiViewportPtr vp); + private static readonly Platform_CreateWindow _createWindow = Godot_CreateWindow; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_DestroyWindow(ImGuiViewportPtr vp); + private static readonly Platform_DestroyWindow _destroyWindow = Godot_DestroyWindow; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_ShowWindow(ImGuiViewportPtr vp); + private static readonly Platform_ShowWindow _showWindow = Godot_ShowWindow; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_SetWindowPos(ImGuiViewportPtr vp, Vector2 pos); + private static readonly Platform_SetWindowPos _setWindowPos = Godot_SetWindowPos; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_GetWindowPos(ImGuiViewportPtr vp, out Vector2 pos); + private static readonly Platform_GetWindowPos _getWindowPos = Godot_GetWindowPos; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_SetWindowSize(ImGuiViewportPtr vp, Vector2 pos); + private static readonly Platform_SetWindowSize _setWindowSize = Godot_SetWindowSize; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_GetWindowSize(ImGuiViewportPtr vp, out Vector2 size); + private static readonly Platform_GetWindowSize _getWindowSize = Godot_GetWindowSize; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_SetWindowFocus(ImGuiViewportPtr vp); + private static readonly Platform_SetWindowFocus _setWindowFocus = Godot_SetWindowFocus; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool Platform_GetWindowFocus(ImGuiViewportPtr vp); + private static readonly Platform_GetWindowFocus _getWindowFocus = Godot_GetWindowFocus; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool Platform_GetWindowMinimized(ImGuiViewportPtr vp); + private static readonly Platform_GetWindowMinimized _getWindowMinimized + = Godot_GetWindowMinimized; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void Platform_SetWindowTitle(ImGuiViewportPtr vp, string title); + private static readonly Platform_SetWindowTitle _setWindowTitle = Godot_SetWindowTitle; + + private GodotImGuiWindow _mainWindow = null!; + + private static void UpdateMonitors() + { + var pio = ImGui.GetPlatformIO(); + int screenCount = DisplayServer.GetScreenCount(); + + // workaround for lack of ImVector constructor + unsafe + { + int bytes = screenCount * sizeof(ImGuiPlatformMonitor); + if (pio.NativePtr->Monitors.Data != IntPtr.Zero) + ImGui.MemFree(pio.NativePtr->Monitors.Data); + *&pio.NativePtr->Monitors.Data = ImGui.MemAlloc((uint)bytes); + *&pio.NativePtr->Monitors.Capacity = screenCount; + *&pio.NativePtr->Monitors.Size = screenCount; + } + + for (int i = 0; i < screenCount; ++i) + { + var monitor = pio.Monitors[i]; + monitor.MainPos = DisplayServer.ScreenGetPosition(i).ToImVec2(); + monitor.MainSize = DisplayServer.ScreenGetSize(i).ToImVec2(); + monitor.DpiScale = DisplayServer.ScreenGetScale(i); + + var r = DisplayServer.ScreenGetUsableRect(i); + monitor.WorkPos = r.Position.ToImVec2(); + monitor.WorkSize = r.Size.ToImVec2(); + } + + // TODO: add monitor if headless + } + + private static unsafe void InitPlatformInterface() + { + var pio = ImGui.GetPlatformIO().NativePtr; + + pio->Platform_CreateWindow = Marshal.GetFunctionPointerForDelegate(_createWindow); + pio->Platform_DestroyWindow = Marshal.GetFunctionPointerForDelegate(_destroyWindow); + pio->Platform_ShowWindow = Marshal.GetFunctionPointerForDelegate(_showWindow); + pio->Platform_SetWindowPos = Marshal.GetFunctionPointerForDelegate(_setWindowPos); + //pio->Platform_GetWindowPos = Marshal.GetFunctionPointerForDelegate(_getWindowPos); + pio->Platform_SetWindowSize = Marshal.GetFunctionPointerForDelegate(_setWindowSize); + //pio->Platform_GetWindowSize = Marshal.GetFunctionPointerForDelegate(_getWindowSize); + pio->Platform_SetWindowFocus = Marshal.GetFunctionPointerForDelegate(_setWindowFocus); + pio->Platform_GetWindowFocus = Marshal.GetFunctionPointerForDelegate(_getWindowFocus); + pio->Platform_GetWindowMinimized = Marshal.GetFunctionPointerForDelegate( + _getWindowMinimized); + pio->Platform_SetWindowTitle = Marshal.GetFunctionPointerForDelegate(_setWindowTitle); + + ImGuiPlatformIO_Set_Platform_GetWindowPos( + pio, + Marshal.GetFunctionPointerForDelegate(_getWindowPos)); + ImGuiPlatformIO_Set_Platform_GetWindowSize( + pio, + Marshal.GetFunctionPointerForDelegate(_getWindowSize)); + } + + public Viewports() + { + InitPlatformInterface(); + UpdateMonitors(); + } + + public void SetMainWindow(Window window, Rid mainSubViewport) + { + _mainWindow?.Dispose(); + _mainWindow = new GodotImGuiWindow(ImGui.GetMainViewport(), window, mainSubViewport); + } + + private static void Godot_CreateWindow(ImGuiViewportPtr vp) + { + _ = new GodotImGuiWindow(vp); + } + + private static void Godot_DestroyWindow(ImGuiViewportPtr vp) + { + if (vp.PlatformUserData != IntPtr.Zero) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + window.Dispose(); + vp.PlatformUserData = IntPtr.Zero; + vp.RendererUserData = IntPtr.Zero; + } + } + + private static void Godot_ShowWindow(ImGuiViewportPtr vp) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + window.ShowWindow(); + } + + private static void Godot_SetWindowPos(ImGuiViewportPtr vp, Vector2 pos) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + window.SetWindowPos(pos.ToVector2I()); + } + + private static void Godot_GetWindowPos(ImGuiViewportPtr vp, out Vector2 pos) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + pos = window.GetWindowPos().ToImVec2(); + } + + private static void Godot_SetWindowSize(ImGuiViewportPtr vp, Vector2 size) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + window.SetWindowSize(size.ToVector2I()); + } + + private static void Godot_GetWindowSize(ImGuiViewportPtr vp, out Vector2 size) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + size = window.GetWindowSize().ToImVec2(); + } + + private static void Godot_SetWindowFocus(ImGuiViewportPtr vp) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + window.SetWindowFocus(); + } + + private static bool Godot_GetWindowFocus(ImGuiViewportPtr vp) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + return window.GetWindowFocus(); + } + + private static bool Godot_GetWindowMinimized(ImGuiViewportPtr vp) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + return window.GetWindowMinimized(); + } + + private static void Godot_SetWindowTitle(ImGuiViewportPtr vp, string title) + { + var window = (GodotImGuiWindow)GCHandle.FromIntPtr(vp.PlatformUserData).Target!; + window.SetWindowTitle(title); + } +} +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Widgets.cs b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Widgets.cs new file mode 100644 index 00000000000..bb638e7fce4 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/ImGuiGodot/Widgets.cs @@ -0,0 +1,239 @@ +using Godot; +using ImGuiNET; +using System; +using Vector2 = System.Numerics.Vector2; +using Vector4 = System.Numerics.Vector4; + +namespace ImGuiGodot; + +public static partial class ImGuiGD +{ + /// + /// Display an interactable SubViewport + /// + /// + /// Be sure to change the SubViewport's to + /// + /// + /// True if active (mouse hovered) + /// + public static bool SubViewport(SubViewport svp) + { + return _backend.SubViewportWidget(svp); + } + + public static void Image(Texture2D tex, Vector2 size) + { + Image(tex, size, Vector2.Zero, Vector2.One, Vector4.One, Vector4.Zero); + } + + public static void Image(Texture2D tex, Vector2 size, Vector2 uv0) + { + Image(tex, size, uv0, Vector2.One, Vector4.One, Vector4.Zero); + } + + public static void Image(Texture2D tex, Vector2 size, Vector2 uv0, Vector2 uv1) + { + Image(tex, size, uv0, uv1, Vector4.One, Vector4.Zero); + } + + public static void Image( + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1, + Vector4 tint_col) + { + Image(tex, size, uv0, uv1, tint_col, Vector4.Zero); + } + + public static void Image( + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1, + Vector4 tint_col, + Vector4 border_col) + { + ImGuiNative.igImage((IntPtr)tex.GetRid().Id, size, uv0, uv1, tint_col, border_col); + } + + public static void Image(AtlasTexture tex, Vector2 size) + { + Image(tex, size, Vector4.One, Vector4.Zero); + } + + public static void Image(AtlasTexture tex, Vector2 size, Vector4 tint_col) + { + Image(tex, size, tint_col, Vector4.Zero); + } + + public static void Image(AtlasTexture tex, Vector2 size, Vector4 tint_col, Vector4 border_col) + { + (Vector2 uv0, Vector2 uv1) = GetAtlasUVs(tex); + ImGuiNative.igImage((IntPtr)tex.GetRid().Id, size, uv0, uv1, tint_col, border_col); + } + + public static bool ImageButton(string str_id, Texture2D tex, Vector2 size) + { + return ImageButton(str_id, tex, size, Vector2.Zero, Vector2.One, Vector4.Zero, Vector4.One); + } + + public static bool ImageButton(string str_id, Texture2D tex, Vector2 size, Vector2 uv0) + { + return ImageButton(str_id, tex, size, uv0, Vector2.One, Vector4.Zero, Vector4.One); + } + + public static bool ImageButton( + string str_id, + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1) + { + return ImageButton(str_id, tex, size, uv0, uv1, Vector4.Zero, Vector4.One); + } + + public static bool ImageButton( + string str_id, + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1, + Vector4 bg_col) + { + return ImageButton(str_id, tex, size, uv0, uv1, bg_col, Vector4.One); + } + + public static bool ImageButton( + string str_id, + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1, + Vector4 bg_col, + Vector4 tint_col) + { + return ImGui.ImageButton(str_id, (IntPtr)tex.GetRid().Id, size, uv0, uv1, bg_col, tint_col); + } + + public static bool ImageButton(string str_id, AtlasTexture tex, Vector2 size) + { + return ImageButton(str_id, tex, size, Vector4.Zero, Vector4.One); + } + + public static bool ImageButton(string str_id, AtlasTexture tex, Vector2 size, Vector4 bg_col) + { + return ImageButton(str_id, tex, size, bg_col, Vector4.One); + } + + public static bool ImageButton( + string str_id, + AtlasTexture tex, + Vector2 size, + Vector4 bg_col, + Vector4 tint_col) + { + var (uv0, uv1) = GetAtlasUVs(tex); + return ImGui.ImageButton(str_id, (IntPtr)tex.GetRid().Id, size, uv0, uv1, bg_col, tint_col); + } + + private static (Vector2 uv0, Vector2 uv1) GetAtlasUVs(AtlasTexture tex) + { + Godot.Vector2 atlasSize = tex.Atlas.GetSize(); + Godot.Vector2 guv0 = tex.Region.Position / atlasSize; + Godot.Vector2 guv1 = tex.Region.End / atlasSize; +#pragma warning disable IDE0004 // Remove Unnecessary Cast + return (new((float)guv0.X, (float)guv0.Y), new((float)guv1.X, (float)guv1.Y)); +#pragma warning restore IDE0004 // Remove Unnecessary Cast + } +} + +/// +/// for backward compatibility +/// +/// +/// will eventually add [Obsolete("Use ImGuiGD instead")] +/// +public static class Widgets +{ + public static bool SubViewport(SubViewport svp) => ImGuiGD.SubViewport(svp); + + public static void Image(Texture2D tex, Vector2 size) => ImGuiGD.Image(tex, size); + + public static void Image(Texture2D tex, Vector2 size, Vector2 uv0) + => ImGuiGD.Image(tex, size, uv0); + + public static void Image(Texture2D tex, Vector2 size, Vector2 uv0, Vector2 uv1) + => ImGuiGD.Image(tex, size, uv0, uv1); + + public static void Image( + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1, + Vector4 tint_col) => ImGuiGD.Image(tex, size, uv0, uv1, tint_col); + + public static void Image( + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1, + Vector4 tint_col, + Vector4 border_col) => ImGuiGD.Image(tex, size, uv0, uv1, tint_col, border_col); + + public static void Image(AtlasTexture tex, Vector2 size) => ImGuiGD.Image(tex, size); + + public static void Image(AtlasTexture tex, Vector2 size, Vector4 tint_col) + => ImGuiGD.Image(tex, size, tint_col); + + public static void Image(AtlasTexture tex, Vector2 size, Vector4 tint_col, Vector4 border_col) + => ImGuiGD.Image(tex, size, tint_col, border_col); + + public static bool ImageButton(string str_id, Texture2D tex, Vector2 size) + => ImGuiGD.ImageButton(str_id, tex, size); + + public static bool ImageButton(string str_id, Texture2D tex, Vector2 size, Vector2 uv0) + => ImGuiGD.ImageButton(str_id, tex, size, uv0); + + public static bool ImageButton( + string str_id, + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1) => ImGuiGD.ImageButton(str_id, tex, size, uv0, uv1); + + public static bool ImageButton( + string str_id, + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1, + Vector4 bg_col) => ImGuiGD.ImageButton(str_id, tex, size, uv0, uv1, bg_col); + + public static bool ImageButton( + string str_id, + Texture2D tex, + Vector2 size, + Vector2 uv0, + Vector2 uv1, + Vector4 bg_col, + Vector4 tint_col) => ImGuiGD.ImageButton(str_id, tex, size, uv0, uv1, bg_col, tint_col); + + public static bool ImageButton(string str_id, AtlasTexture tex, Vector2 size) + => ImGuiGD.ImageButton(str_id, tex, size); + + public static bool ImageButton(string str_id, AtlasTexture tex, Vector2 size, Vector4 bg_col) + => ImGuiGD.ImageButton(str_id, tex, size, bg_col); + + public static bool ImageButton( + string str_id, + AtlasTexture tex, + Vector2 size, + Vector4 bg_col, + Vector4 tint_col) => ImGuiGD.ImageButton(str_id, tex, size, bg_col, tint_col); +} + +#if NET10_0_OR_GREATER +// TODO: implicit extension GodotWidgets for ImGui +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.linux.debug.x86_64.so b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.linux.debug.x86_64.so new file mode 100644 index 00000000000..435ac388cef Binary files /dev/null and b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.linux.debug.x86_64.so differ diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.linux.release.x86_64.so b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.linux.release.x86_64.so new file mode 100644 index 00000000000..39a3e7f0e58 Binary files /dev/null and b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.linux.release.x86_64.so differ diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.macos.debug.framework/libimgui-godot-native.macos.debug b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.macos.debug.framework/libimgui-godot-native.macos.debug new file mode 100644 index 00000000000..5440da90608 Binary files /dev/null and b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.macos.debug.framework/libimgui-godot-native.macos.debug differ diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.macos.release.framework/libimgui-godot-native.macos.release b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.macos.release.framework/libimgui-godot-native.macos.release new file mode 100644 index 00000000000..d4ad81a6d2e Binary files /dev/null and b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.macos.release.framework/libimgui-godot-native.macos.release differ diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.windows.debug.x86_64.dll b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.windows.debug.x86_64.dll new file mode 100644 index 00000000000..3cf6a0c9a76 Binary files /dev/null and b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.windows.debug.x86_64.dll differ diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.windows.release.x86_64.dll b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.windows.release.x86_64.dll new file mode 100644 index 00000000000..b6e25126f53 Binary files /dev/null and b/demo/Blackholio/client-godot/addons/imgui-godot/bin/libimgui-godot-native.windows.release.x86_64.dll differ diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/bin/~libimgui-godot-native.windows.debug.x86_64.dll b/demo/Blackholio/client-godot/addons/imgui-godot/bin/~libimgui-godot-native.windows.debug.x86_64.dll new file mode 100644 index 00000000000..3cf6a0c9a76 Binary files /dev/null and b/demo/Blackholio/client-godot/addons/imgui-godot/bin/~libimgui-godot-native.windows.debug.x86_64.dll differ diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiRoot.tscn b/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiRoot.tscn new file mode 100644 index 00000000000..aee823c3348 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiRoot.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://dugmpnsxaagba"] + +[ext_resource type="Script" path="res://addons/imgui-godot/scripts/ImGuiRoot.gd" id="1_lney5"] + +[node name="ImGuiRoot" type="Node"] +script = ExtResource("1_lney5") diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiShader.glsl b/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiShader.glsl new file mode 100644 index 00000000000..2082d5f83c8 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiShader.glsl @@ -0,0 +1,29 @@ +// shader source borrowed from imgui_impl_vulkan.cpp + +#[vertex] +#version 450 core +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; +layout(location = 2) in vec4 aColor; +layout(push_constant) uniform uPushConstant { vec2 uScale; vec2 uTranslate; } pc; + +out gl_PerVertex { vec4 gl_Position; }; +layout(location = 0) out struct { vec4 Color; vec2 UV; } Out; + +void main() +{ + Out.Color = aColor; + Out.UV = aUV; + gl_Position = vec4(aPos * pc.uScale + pc.uTranslate, 0, 1); +} + +#[fragment] +#version 450 core +layout(location = 0) out vec4 fColor; +layout(set=0, binding=0) uniform sampler2D sTexture; +layout(location = 0) in struct { vec4 Color; vec2 UV; } In; + +void main() +{ + fColor = In.Color * texture(sTexture, In.UV.st); +} diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiShader.glsl.import b/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiShader.glsl.import new file mode 100644 index 00000000000..e75dbc5d9b4 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/data/ImGuiShader.glsl.import @@ -0,0 +1,13 @@ +[remap] + +importer="glsl" +type="RDShaderFile" +uid="uid://belucuvjtb04o" +valid=false + +[deps] + +source_file="res://addons/imgui-godot/data/ImGuiShader.glsl" + +[params] + diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/imgui-godot-native.gdextension b/demo/Blackholio/client-godot/addons/imgui-godot/imgui-godot-native.gdextension new file mode 100644 index 00000000000..08c14dbfbb7 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/imgui-godot-native.gdextension @@ -0,0 +1,13 @@ +[configuration] + +entry_symbol = "ign_init" +compatibility_minimum = 4.2 + +[libraries] + +macos.debug = "bin/libimgui-godot-native.macos.debug.framework" +macos.release = "bin/libimgui-godot-native.macos.release.framework" +windows.debug.x86_64 = "bin/libimgui-godot-native.windows.debug.x86_64.dll" +windows.release.x86_64 = "bin/libimgui-godot-native.windows.release.x86_64.dll" +linux.debug.x86_64 = "bin/libimgui-godot-native.linux.debug.x86_64.so" +linux.release.x86_64 = "bin/libimgui-godot-native.linux.release.x86_64.so" diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/imgui-godot-native.gdextension.uid b/demo/Blackholio/client-godot/addons/imgui-godot/imgui-godot-native.gdextension.uid new file mode 100644 index 00000000000..022a87e09fe --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/imgui-godot-native.gdextension.uid @@ -0,0 +1 @@ +uid://bj1e02tu2vpqw diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/include/.gdignore b/demo/Blackholio/client-godot/addons/imgui-godot/include/.gdignore new file mode 100644 index 00000000000..e69de29bb2d diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/include/imconfig-godot.h b/demo/Blackholio/client-godot/addons/imgui-godot/include/imconfig-godot.h new file mode 100644 index 00000000000..6a032c0c26e --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/include/imconfig-godot.h @@ -0,0 +1,63 @@ +#pragma once + +#define IMGUI_DISABLE_OBSOLETE_FUNCTIONS // match ImGui.NET + +#if __has_include("godot_cpp/godot.hpp") // GDExtension +#include +#include +#include +#include +using godot::Color; +using godot::Vector2; +using godot::Vector2i; +using godot::Vector4; + +#if defined(DEBUG_ENABLED) && defined(IGN_EXPORT) +#ifndef IM_ASSERT +#include +#define IM_ASSERT(_EXPR) \ + do \ + { \ + if (!(_EXPR)) \ + godot::UtilityFunctions::push_error(godot::vformat("IM_ASSERT %s (%s:%d)", #_EXPR, __FILE__, __LINE__)); \ + } while (0) +#endif +#endif +#else // module +#include "core/math/color.h" +#include "core/math/vector2.h" +#include "core/math/vector2i.h" +#include "core/math/vector4.h" +#endif + +#define IM_VEC2_CLASS_EXTRA \ + constexpr ImVec2(const Vector2& f) : x(f.x), y(f.y) \ + { \ + } \ + operator Vector2() const \ + { \ + return Vector2(x, y); \ + } \ + constexpr ImVec2(const Vector2i& f) : x(static_cast(f.x)), y(static_cast(f.y)) \ + { \ + } \ + operator Vector2i() const \ + { \ + return Vector2i(static_cast(x), static_cast(y)); \ + } + +#define IM_VEC4_CLASS_EXTRA \ + constexpr ImVec4(const Vector4& f) : x(f.x), y(f.y), z(f.z), w(f.w) \ + { \ + } \ + operator Vector4() const \ + { \ + return Vector4(x, y, z, w); \ + } \ + constexpr ImVec4(const Color& c) : x(c.r), y(c.g), z(c.b), w(c.a) \ + { \ + } \ + operator Color() const \ + { \ + return Color(x, y, z, w); \ + } diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/include/imgui-godot.h b/demo/Blackholio/client-godot/addons/imgui-godot/include/imgui-godot.h new file mode 100644 index 00000000000..5a9b1a58ba6 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/include/imgui-godot.h @@ -0,0 +1,778 @@ +#pragma once +#include + +#ifndef IMGUI_HAS_VIEWPORT +#error use ImGui docking branch +#endif + +#if __has_include("godot_cpp/godot.hpp") +#define IGN_GDEXT +// GDExtension +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#else +// module +#include "core/config/engine.h" +#include "core/input/input_enums.h" +#include "core/os/keyboard.h" +#include "core/variant/callable.h" +#include "scene/main/viewport.h" +#include "scene/main/window.h" +#include "scene/resources/atlas_texture.h" +#include "scene/resources/texture.h" +#endif + +static_assert(sizeof(void*) == 8); +static_assert(sizeof(ImDrawIdx) == 2); +static_assert(sizeof(ImWchar) == 2); + +namespace ImGui::Godot { + +#if defined(IGN_GDEXT) +using godot::AtlasTexture; +using godot::Callable; +using godot::ClassDB; +using godot::Color; +using godot::Engine; +using godot::FontFile; +using godot::JoyButton; +using godot::Key; +using godot::Object; +using godot::PackedInt32Array; +using godot::Ref; +using godot::RID; +using godot::String; +using godot::StringName; +using godot::Texture2D; +using godot::TypedArray; +using godot::Vector2; +using godot::Viewport; +#endif + +static_assert(sizeof(RID) == 8); +#ifndef IGN_EXPORT +// C++ user interface +namespace detail { +inline static Object* ImGuiGD = nullptr; + +inline bool GET_IMGUIGD() +{ + if (ImGuiGD) + return true; +#ifdef IGN_GDEXT + ImGuiGD = Engine::get_singleton()->get_singleton("ImGuiGD"); +#else + ImGuiGD = Engine::get_singleton()->get_singleton_object("ImGuiGD"); +#endif + return ImGuiGD != nullptr; +} + +inline static void GetAtlasUVs(AtlasTexture* tex, ImVec2& uv0, ImVec2& uv1) +{ + ERR_FAIL_COND(!tex); + Vector2 atlasSize = tex->get_atlas()->get_size(); + uv0 = tex->get_region().get_position() / atlasSize; + uv1 = tex->get_region().get_end() / atlasSize; +} +} // namespace detail + +inline void AddFont(const Ref& fontFile, int fontSize, bool merge = false, ImWchar* glyphRanges = nullptr) +{ + ERR_FAIL_COND(!detail::GET_IMGUIGD()); + static const StringName sn("AddFont"); + PackedInt32Array gr; + if (glyphRanges) + { + do + { + gr.append(*glyphRanges); + } while (*++glyphRanges != 0); + } + detail::ImGuiGD->call(sn, fontSize, merge, gr); +} + +inline void Connect(const Callable& callable) +{ + ERR_FAIL_COND(!detail::GET_IMGUIGD()); + static const StringName sn("Connect"); + detail::ImGuiGD->call(sn, callable); +} + +inline void RebuildFontAtlas() +{ + ERR_FAIL_COND(!detail::GET_IMGUIGD()); + static const StringName sn("RebuildFontAtlas"); + detail::ImGuiGD->call(sn); +} + +inline void ResetFonts() +{ + ERR_FAIL_COND(!detail::GET_IMGUIGD()); + static const StringName sn("ResetFonts"); + detail::ImGuiGD->call(sn); +} + +inline void SetJoyAxisDeadZone(real_t deadZone) +{ + ERR_FAIL_COND(!detail::GET_IMGUIGD()); + static const StringName sn("JoyAxisDeadZone"); + detail::ImGuiGD->set(sn, deadZone); +} + +inline void SetVisible(bool vis) +{ + ERR_FAIL_COND(!detail::GET_IMGUIGD()); + static const StringName sn("Visible"); + detail::ImGuiGD->set(sn, vis); +} + +inline void SetMainViewport(Viewport* vp) +{ + ERR_FAIL_COND(!detail::GET_IMGUIGD()); + static const StringName sn("SetMainViewport"); + detail::ImGuiGD->call(sn, vp); +} + +inline bool ToolInit() +{ + ERR_FAIL_COND_V(!detail::GET_IMGUIGD(), false); + static const StringName sn("ToolInit"); + return detail::ImGuiGD->call(sn); +} + +inline void SetIniFilename(String fn) +{ + ERR_FAIL_COND(!detail::GET_IMGUIGD()); + static const StringName sn("SetIniFilename"); + detail::ImGuiGD->call(sn, fn); +} + +inline void SyncImGuiPtrs() +{ + Object* obj = ClassDB::instantiate("ImGuiSync"); + ERR_FAIL_COND(!obj); + + static const StringName sn("GetImGuiPtrs"); + TypedArray ptrs = obj->call(sn, + String(ImGui::GetVersion()), + (int32_t)sizeof(ImGuiIO), + (int32_t)sizeof(ImDrawVert), + (int32_t)sizeof(ImDrawIdx), + (int32_t)sizeof(ImWchar)); + + ERR_FAIL_COND(ptrs.size() != 3); + + ImGui::SetCurrentContext(reinterpret_cast((int64_t)ptrs[0])); + ImGuiMemAllocFunc alloc_func = reinterpret_cast((int64_t)ptrs[1]); + ImGuiMemFreeFunc free_func = reinterpret_cast((int64_t)ptrs[2]); + ImGui::SetAllocatorFunctions(alloc_func, free_func, nullptr); + memdelete(obj); +} + +inline ImTextureID BindTexture(Texture2D* tex) +{ + ERR_FAIL_COND_V(!tex, 0); + return reinterpret_cast(tex->get_rid().get_id()); +} +#endif + +#ifdef IGN_GDEXT // GDExtension +inline ImGuiKey ToImGuiKey(Key key) +{ + switch (key) + { + case Key::KEY_ESCAPE: + return ImGuiKey_Escape; + case Key::KEY_TAB: + return ImGuiKey_Tab; + case Key::KEY_BACKSPACE: + return ImGuiKey_Backspace; + case Key::KEY_ENTER: + return ImGuiKey_Enter; + case Key::KEY_KP_ENTER: + return ImGuiKey_KeypadEnter; + case Key::KEY_INSERT: + return ImGuiKey_Insert; + case Key::KEY_DELETE: + return ImGuiKey_Delete; + case Key::KEY_PAUSE: + return ImGuiKey_Pause; + case Key::KEY_PRINT: + return ImGuiKey_PrintScreen; + case Key::KEY_HOME: + return ImGuiKey_Home; + case Key::KEY_END: + return ImGuiKey_End; + case Key::KEY_LEFT: + return ImGuiKey_LeftArrow; + case Key::KEY_UP: + return ImGuiKey_UpArrow; + case Key::KEY_RIGHT: + return ImGuiKey_RightArrow; + case Key::KEY_DOWN: + return ImGuiKey_DownArrow; + case Key::KEY_PAGEUP: + return ImGuiKey_PageUp; + case Key::KEY_PAGEDOWN: + return ImGuiKey_PageDown; + case Key::KEY_SHIFT: + return ImGuiKey_LeftShift; + case Key::KEY_CTRL: + return ImGuiKey_LeftCtrl; + case Key::KEY_META: + return ImGuiKey_LeftSuper; + case Key::KEY_ALT: + return ImGuiKey_LeftAlt; + case Key::KEY_CAPSLOCK: + return ImGuiKey_CapsLock; + case Key::KEY_NUMLOCK: + return ImGuiKey_NumLock; + case Key::KEY_SCROLLLOCK: + return ImGuiKey_ScrollLock; + case Key::KEY_F1: + return ImGuiKey_F1; + case Key::KEY_F2: + return ImGuiKey_F2; + case Key::KEY_F3: + return ImGuiKey_F3; + case Key::KEY_F4: + return ImGuiKey_F4; + case Key::KEY_F5: + return ImGuiKey_F5; + case Key::KEY_F6: + return ImGuiKey_F6; + case Key::KEY_F7: + return ImGuiKey_F7; + case Key::KEY_F8: + return ImGuiKey_F8; + case Key::KEY_F9: + return ImGuiKey_F9; + case Key::KEY_F10: + return ImGuiKey_F10; + case Key::KEY_F11: + return ImGuiKey_F11; + case Key::KEY_F12: + return ImGuiKey_F12; + case Key::KEY_KP_MULTIPLY: + return ImGuiKey_KeypadMultiply; + case Key::KEY_KP_DIVIDE: + return ImGuiKey_KeypadDivide; + case Key::KEY_KP_SUBTRACT: + return ImGuiKey_KeypadSubtract; + case Key::KEY_KP_PERIOD: + return ImGuiKey_KeypadDecimal; + case Key::KEY_KP_ADD: + return ImGuiKey_KeypadAdd; + case Key::KEY_KP_0: + return ImGuiKey_Keypad0; + case Key::KEY_KP_1: + return ImGuiKey_Keypad1; + case Key::KEY_KP_2: + return ImGuiKey_Keypad2; + case Key::KEY_KP_3: + return ImGuiKey_Keypad3; + case Key::KEY_KP_4: + return ImGuiKey_Keypad4; + case Key::KEY_KP_5: + return ImGuiKey_Keypad5; + case Key::KEY_KP_6: + return ImGuiKey_Keypad6; + case Key::KEY_KP_7: + return ImGuiKey_Keypad7; + case Key::KEY_KP_8: + return ImGuiKey_Keypad8; + case Key::KEY_KP_9: + return ImGuiKey_Keypad9; + case Key::KEY_MENU: + return ImGuiKey_Menu; + case Key::KEY_SPACE: + return ImGuiKey_Space; + case Key::KEY_APOSTROPHE: + return ImGuiKey_Apostrophe; + case Key::KEY_COMMA: + return ImGuiKey_Comma; + case Key::KEY_MINUS: + return ImGuiKey_Minus; + case Key::KEY_PERIOD: + return ImGuiKey_Period; + case Key::KEY_SLASH: + return ImGuiKey_Slash; + case Key::KEY_0: + return ImGuiKey_0; + case Key::KEY_1: + return ImGuiKey_1; + case Key::KEY_2: + return ImGuiKey_2; + case Key::KEY_3: + return ImGuiKey_3; + case Key::KEY_4: + return ImGuiKey_4; + case Key::KEY_5: + return ImGuiKey_5; + case Key::KEY_6: + return ImGuiKey_6; + case Key::KEY_7: + return ImGuiKey_7; + case Key::KEY_8: + return ImGuiKey_8; + case Key::KEY_9: + return ImGuiKey_9; + case Key::KEY_SEMICOLON: + return ImGuiKey_Semicolon; + case Key::KEY_EQUAL: + return ImGuiKey_Equal; + case Key::KEY_A: + return ImGuiKey_A; + case Key::KEY_B: + return ImGuiKey_B; + case Key::KEY_C: + return ImGuiKey_C; + case Key::KEY_D: + return ImGuiKey_D; + case Key::KEY_E: + return ImGuiKey_E; + case Key::KEY_F: + return ImGuiKey_F; + case Key::KEY_G: + return ImGuiKey_G; + case Key::KEY_H: + return ImGuiKey_H; + case Key::KEY_I: + return ImGuiKey_I; + case Key::KEY_J: + return ImGuiKey_J; + case Key::KEY_K: + return ImGuiKey_K; + case Key::KEY_L: + return ImGuiKey_L; + case Key::KEY_M: + return ImGuiKey_M; + case Key::KEY_N: + return ImGuiKey_N; + case Key::KEY_O: + return ImGuiKey_O; + case Key::KEY_P: + return ImGuiKey_P; + case Key::KEY_Q: + return ImGuiKey_Q; + case Key::KEY_R: + return ImGuiKey_R; + case Key::KEY_S: + return ImGuiKey_S; + case Key::KEY_T: + return ImGuiKey_T; + case Key::KEY_U: + return ImGuiKey_U; + case Key::KEY_V: + return ImGuiKey_V; + case Key::KEY_W: + return ImGuiKey_W; + case Key::KEY_X: + return ImGuiKey_X; + case Key::KEY_Y: + return ImGuiKey_Y; + case Key::KEY_Z: + return ImGuiKey_Z; + case Key::KEY_BRACKETLEFT: + return ImGuiKey_LeftBracket; + case Key::KEY_BACKSLASH: + return ImGuiKey_Backslash; + case Key::KEY_BRACKETRIGHT: + return ImGuiKey_RightBracket; + case Key::KEY_QUOTELEFT: + return ImGuiKey_GraveAccent; + default: + return ImGuiKey_None; + }; +} + +inline ImGuiKey ToImGuiKey(JoyButton btn) +{ + switch (btn) + { + case JoyButton::JOY_BUTTON_A: + return ImGuiKey_GamepadFaceDown; + case JoyButton::JOY_BUTTON_B: + return ImGuiKey_GamepadFaceRight; + case JoyButton::JOY_BUTTON_X: + return ImGuiKey_GamepadFaceLeft; + case JoyButton::JOY_BUTTON_Y: + return ImGuiKey_GamepadFaceUp; + case JoyButton::JOY_BUTTON_BACK: + return ImGuiKey_GamepadBack; + case JoyButton::JOY_BUTTON_START: + return ImGuiKey_GamepadStart; + case JoyButton::JOY_BUTTON_LEFT_STICK: + return ImGuiKey_GamepadL3; + case JoyButton::JOY_BUTTON_RIGHT_STICK: + return ImGuiKey_GamepadR3; + case JoyButton::JOY_BUTTON_LEFT_SHOULDER: + return ImGuiKey_GamepadL1; + case JoyButton::JOY_BUTTON_RIGHT_SHOULDER: + return ImGuiKey_GamepadR1; + case JoyButton::JOY_BUTTON_DPAD_UP: + return ImGuiKey_GamepadDpadUp; + case JoyButton::JOY_BUTTON_DPAD_DOWN: + return ImGuiKey_GamepadDpadDown; + case JoyButton::JOY_BUTTON_DPAD_LEFT: + return ImGuiKey_GamepadDpadLeft; + case JoyButton::JOY_BUTTON_DPAD_RIGHT: + return ImGuiKey_GamepadDpadRight; + default: + return ImGuiKey_None; + }; +} +#else // module +inline ImGuiKey ToImGuiKey(Key key) +{ + switch (key) + { + case Key::ESCAPE: + return ImGuiKey_Escape; + case Key::TAB: + return ImGuiKey_Tab; + case Key::BACKSPACE: + return ImGuiKey_Backspace; + case Key::ENTER: + return ImGuiKey_Enter; + case Key::KP_ENTER: + return ImGuiKey_KeypadEnter; + case Key::INSERT: + return ImGuiKey_Insert; + case Key::KEY_DELETE: + return ImGuiKey_Delete; + case Key::PAUSE: + return ImGuiKey_Pause; + case Key::PRINT: + return ImGuiKey_PrintScreen; + case Key::HOME: + return ImGuiKey_Home; + case Key::END: + return ImGuiKey_End; + case Key::LEFT: + return ImGuiKey_LeftArrow; + case Key::UP: + return ImGuiKey_UpArrow; + case Key::RIGHT: + return ImGuiKey_RightArrow; + case Key::DOWN: + return ImGuiKey_DownArrow; + case Key::PAGEUP: + return ImGuiKey_PageUp; + case Key::PAGEDOWN: + return ImGuiKey_PageDown; + case Key::SHIFT: + return ImGuiKey_LeftShift; + case Key::CTRL: + return ImGuiKey_LeftCtrl; + case Key::META: + return ImGuiKey_LeftSuper; + case Key::ALT: + return ImGuiKey_LeftAlt; + case Key::CAPSLOCK: + return ImGuiKey_CapsLock; + case Key::NUMLOCK: + return ImGuiKey_NumLock; + case Key::SCROLLLOCK: + return ImGuiKey_ScrollLock; + case Key::F1: + return ImGuiKey_F1; + case Key::F2: + return ImGuiKey_F2; + case Key::F3: + return ImGuiKey_F3; + case Key::F4: + return ImGuiKey_F4; + case Key::F5: + return ImGuiKey_F5; + case Key::F6: + return ImGuiKey_F6; + case Key::F7: + return ImGuiKey_F7; + case Key::F8: + return ImGuiKey_F8; + case Key::F9: + return ImGuiKey_F9; + case Key::F10: + return ImGuiKey_F10; + case Key::F11: + return ImGuiKey_F11; + case Key::F12: + return ImGuiKey_F12; + case Key::KP_MULTIPLY: + return ImGuiKey_KeypadMultiply; + case Key::KP_DIVIDE: + return ImGuiKey_KeypadDivide; + case Key::KP_SUBTRACT: + return ImGuiKey_KeypadSubtract; + case Key::KP_PERIOD: + return ImGuiKey_KeypadDecimal; + case Key::KP_ADD: + return ImGuiKey_KeypadAdd; + case Key::KP_0: + return ImGuiKey_Keypad0; + case Key::KP_1: + return ImGuiKey_Keypad1; + case Key::KP_2: + return ImGuiKey_Keypad2; + case Key::KP_3: + return ImGuiKey_Keypad3; + case Key::KP_4: + return ImGuiKey_Keypad4; + case Key::KP_5: + return ImGuiKey_Keypad5; + case Key::KP_6: + return ImGuiKey_Keypad6; + case Key::KP_7: + return ImGuiKey_Keypad7; + case Key::KP_8: + return ImGuiKey_Keypad8; + case Key::KP_9: + return ImGuiKey_Keypad9; + case Key::MENU: + return ImGuiKey_Menu; + case Key::SPACE: + return ImGuiKey_Space; + case Key::APOSTROPHE: + return ImGuiKey_Apostrophe; + case Key::COMMA: + return ImGuiKey_Comma; + case Key::MINUS: + return ImGuiKey_Minus; + case Key::PERIOD: + return ImGuiKey_Period; + case Key::SLASH: + return ImGuiKey_Slash; + case Key::KEY_0: + return ImGuiKey_0; + case Key::KEY_1: + return ImGuiKey_1; + case Key::KEY_2: + return ImGuiKey_2; + case Key::KEY_3: + return ImGuiKey_3; + case Key::KEY_4: + return ImGuiKey_4; + case Key::KEY_5: + return ImGuiKey_5; + case Key::KEY_6: + return ImGuiKey_6; + case Key::KEY_7: + return ImGuiKey_7; + case Key::KEY_8: + return ImGuiKey_8; + case Key::KEY_9: + return ImGuiKey_9; + case Key::SEMICOLON: + return ImGuiKey_Semicolon; + case Key::EQUAL: + return ImGuiKey_Equal; + case Key::A: + return ImGuiKey_A; + case Key::B: + return ImGuiKey_B; + case Key::C: + return ImGuiKey_C; + case Key::D: + return ImGuiKey_D; + case Key::E: + return ImGuiKey_E; + case Key::F: + return ImGuiKey_F; + case Key::G: + return ImGuiKey_G; + case Key::H: + return ImGuiKey_H; + case Key::I: + return ImGuiKey_I; + case Key::J: + return ImGuiKey_J; + case Key::K: + return ImGuiKey_K; + case Key::L: + return ImGuiKey_L; + case Key::M: + return ImGuiKey_M; + case Key::N: + return ImGuiKey_N; + case Key::O: + return ImGuiKey_O; + case Key::P: + return ImGuiKey_P; + case Key::Q: + return ImGuiKey_Q; + case Key::R: + return ImGuiKey_R; + case Key::S: + return ImGuiKey_S; + case Key::T: + return ImGuiKey_T; + case Key::U: + return ImGuiKey_U; + case Key::V: + return ImGuiKey_V; + case Key::W: + return ImGuiKey_W; + case Key::X: + return ImGuiKey_X; + case Key::Y: + return ImGuiKey_Y; + case Key::Z: + return ImGuiKey_Z; + case Key::BRACKETLEFT: + return ImGuiKey_LeftBracket; + case Key::BACKSLASH: + return ImGuiKey_Backslash; + case Key::BRACKETRIGHT: + return ImGuiKey_RightBracket; + case Key::QUOTELEFT: + return ImGuiKey_GraveAccent; + default: + return ImGuiKey_None; + }; +} + +inline ImGuiKey ToImGuiKey(JoyButton btn) +{ + switch (btn) + { + case JoyButton::A: + return ImGuiKey_GamepadFaceDown; + case JoyButton::B: + return ImGuiKey_GamepadFaceRight; + case JoyButton::X: + return ImGuiKey_GamepadFaceLeft; + case JoyButton::Y: + return ImGuiKey_GamepadFaceUp; + case JoyButton::BACK: + return ImGuiKey_GamepadBack; + case JoyButton::START: + return ImGuiKey_GamepadStart; + case JoyButton::LEFT_STICK: + return ImGuiKey_GamepadL3; + case JoyButton::RIGHT_STICK: + return ImGuiKey_GamepadR3; + case JoyButton::LEFT_SHOULDER: + return ImGuiKey_GamepadL1; + case JoyButton::RIGHT_SHOULDER: + return ImGuiKey_GamepadR1; + case JoyButton::DPAD_UP: + return ImGuiKey_GamepadDpadUp; + case JoyButton::DPAD_DOWN: + return ImGuiKey_GamepadDpadDown; + case JoyButton::DPAD_LEFT: + return ImGuiKey_GamepadDpadLeft; + case JoyButton::DPAD_RIGHT: + return ImGuiKey_GamepadDpadRight; + default: + return ImGuiKey_None; + }; +} +#endif +} // namespace ImGui::Godot + +#ifndef IGN_EXPORT +// widgets +namespace ImGui { +#if defined(IGN_GDEXT) +using godot::AtlasTexture; +using godot::Ref; +using godot::StringName; +using godot::SubViewport; +using godot::Texture2D; +#endif + +inline bool SubViewport(SubViewport* svp) +{ + ERR_FAIL_COND_V(!ImGui::Godot::detail::GET_IMGUIGD(), false); + static const StringName sn("SubViewport"); + return ImGui::Godot::detail::ImGuiGD->call(sn, svp); +} + +inline void Image(Texture2D* tex, const Vector2& size, const Vector2& uv0 = {0, 0}, const Vector2& uv1 = {1, 1}, + const Color& tint_col = {1, 1, 1, 1}, const Color& border_col = {0, 0, 0, 0}) +{ + ImGui::Image(ImGui::Godot::BindTexture(tex), size, uv0, uv1, tint_col, border_col); +} + +inline void Image(const Ref& tex, const Vector2& size, const Vector2& uv0 = {0, 0}, + const Vector2& uv1 = {1, 1}, const Color& tint_col = {1, 1, 1, 1}, + const Color& border_col = {0, 0, 0, 0}) +{ + ImGui::Image(ImGui::Godot::BindTexture(tex.ptr()), size, uv0, uv1, tint_col, border_col); +} + +inline void Image(AtlasTexture* tex, const Vector2& size, const Color& tint_col = {1, 1, 1, 1}, + const Color& border_col = {0, 0, 0, 0}) +{ + ImVec2 uv0, uv1; + ImGui::Godot::detail::GetAtlasUVs(tex, uv0, uv1); + ImGui::Image(ImGui::Godot::BindTexture(tex), size, uv0, uv1, tint_col, border_col); +} + +inline void Image(const Ref& tex, const Vector2& size, const Color& tint_col = {1, 1, 1, 1}, + const Color& border_col = {0, 0, 0, 0}) +{ + ImVec2 uv0, uv1; + ImGui::Godot::detail::GetAtlasUVs(tex.ptr(), uv0, uv1); + ImGui::Image(ImGui::Godot::BindTexture(tex.ptr()), size, uv0, uv1, tint_col, border_col); +} + +inline bool ImageButton(const char* str_id, Texture2D* tex, const Vector2& size, const Vector2& uv0 = {0, 0}, + const Vector2& uv1 = {1, 1}, const Color& bg_col = {0, 0, 0, 0}, + const Color& tint_col = {1, 1, 1, 1}) +{ + return ImGui::ImageButton(str_id, ImGui::Godot::BindTexture(tex), size, uv0, uv1, bg_col, tint_col); +} + +inline bool ImageButton(const char* str_id, const Ref& tex, const Vector2& size, const Vector2& uv0 = {0, 0}, + const Vector2& uv1 = {1, 1}, const Color& bg_col = {0, 0, 0, 0}, + const Color& tint_col = {1, 1, 1, 1}) +{ + return ImGui::ImageButton(str_id, ImGui::Godot::BindTexture(tex.ptr()), size, uv0, uv1, bg_col, tint_col); +} + +inline bool ImageButton(const char* str_id, AtlasTexture* tex, const Vector2& size, const Color& bg_col = {0, 0, 0, 0}, + const Color& tint_col = {1, 1, 1, 1}) +{ + ImVec2 uv0, uv1; + ImGui::Godot::detail::GetAtlasUVs(tex, uv0, uv1); + return ImGui::ImageButton(str_id, ImGui::Godot::BindTexture(tex), size, uv0, uv1, bg_col, tint_col); +} + +inline bool ImageButton(const char* str_id, const Ref& tex, const Vector2& size, + const Color& bg_col = {0, 0, 0, 0}, const Color& tint_col = {1, 1, 1, 1}) +{ + ImVec2 uv0, uv1; + ImGui::Godot::detail::GetAtlasUVs(tex.ptr(), uv0, uv1); + return ImGui::ImageButton(str_id, ImGui::Godot::BindTexture(tex.ptr()), size, uv0, uv1, bg_col, tint_col); +} +} // namespace ImGui +#endif + +#ifndef IGN_GDEXT +#ifdef _WIN32 +#define IGN_MOD_EXPORT __declspec(dllexport) +#else +#define IGN_MOD_EXPORT +#endif + +#define IMGUI_GODOT_MODULE_INIT() \ + extern "C" { \ + void IGN_MOD_EXPORT imgui_godot_module_init(uint32_t ver, ImGuiContext* ctx, ImGuiMemAllocFunc afunc, \ + ImGuiMemFreeFunc ffunc) \ + { \ + IM_ASSERT(ver == IMGUI_VERSION_NUM); \ + ImGui::SetCurrentContext(ctx); \ + ImGui::SetAllocatorFunctions(afunc, ffunc, nullptr); \ + } \ + } +#endif diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/include/imgui-version.txt b/demo/Blackholio/client-godot/addons/imgui-godot/include/imgui-version.txt new file mode 100644 index 00000000000..3ccffe53950 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/include/imgui-version.txt @@ -0,0 +1 @@ +v1.91.6-docking diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/plugin.cfg b/demo/Blackholio/client-godot/addons/imgui-godot/plugin.cfg new file mode 100644 index 00000000000..3c729216c54 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="imgui-godot" +description="Dear ImGui for Godot" +author="Patrick Dawson" +version="6.3.2" +script="scripts/ImGuiPlugin.gd" diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiConfig.gd b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiConfig.gd new file mode 100644 index 00000000000..b6894c79209 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiConfig.gd @@ -0,0 +1,37 @@ +@tool +class_name ImGuiConfig extends Resource + +@export_range(0.25, 4.0, 0.001, "or_greater") var Scale: float = 1.0 +@export var IniFilename: String = "user://imgui.ini" +@export_enum("RenderingDevice", "Canvas", "Dummy") var Renderer: String = "RenderingDevice" +@export_range(-128, 128) var Layer: int = 128 + +@export_category("Font Settings") +#@export var Fonts: Array[ImGuiFont] +@export var AddDefaultFont: bool = true + +# HACK: workaround for intermittent Godot bug +var _fonts: Array + +func _get_property_list() -> Array[Dictionary]: + return [ + { + "name": "Fonts", + "class_name": &"", + "type": TYPE_ARRAY, + "hint": PROPERTY_HINT_TYPE_STRING, + "hint_string": "24/17:ImGuiFont", + "usage": PROPERTY_USAGE_SCRIPT_VARIABLE | PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE + } + ] + +func _get(property: StringName) -> Variant: + if property == &"Fonts": + return _fonts + return null + +func _set(property: StringName, value: Variant) -> bool: + if property == &"Fonts": + _fonts = value + return true + return false diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiConfig.gd.uid b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiConfig.gd.uid new file mode 100644 index 00000000000..342e9e2f973 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiConfig.gd.uid @@ -0,0 +1 @@ +uid://cvwve3vdjfrdb diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiFont.gd b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiFont.gd new file mode 100644 index 00000000000..2fcd4a62789 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiFont.gd @@ -0,0 +1,5 @@ +class_name ImGuiFont extends Resource + +@export var FontData: FontFile +@export var FontSize: int = 16 +@export var Merge: bool = true diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiFont.gd.uid b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiFont.gd.uid new file mode 100644 index 00000000000..39d824931e8 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiFont.gd.uid @@ -0,0 +1 @@ +uid://cnudkbu84rurx diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiPlugin.gd b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiPlugin.gd new file mode 100644 index 00000000000..428509ca671 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiPlugin.gd @@ -0,0 +1,150 @@ +@tool +extends EditorPlugin + +var _exporter: ImGuiExporter +const imgui_root := "res://addons/imgui-godot/data/ImGuiRoot.tscn" + +func _enter_tree(): + Engine.register_singleton("ImGuiPlugin", self) + add_autoload_singleton("ImGuiRoot", imgui_root) + + # register export plugin + _exporter = ImGuiExporter.new() + _exporter.plugin = self + add_export_plugin(_exporter) + + # add project setting + var setting_name = "addons/imgui/config" + if not ProjectSettings.has_setting(setting_name): + ProjectSettings.set_setting(setting_name, String()) + ProjectSettings.add_property_info({ + "name": setting_name, + "type": TYPE_STRING, + "hint": PROPERTY_HINT_FILE, + "hint_string": "*.tres,*.res", + }) + ProjectSettings.set_initial_value(setting_name, String()) + ProjectSettings.set_as_basic(setting_name, true) + + # remove obsolete ImGuiLayer autoload + if ProjectSettings.has_setting("autoload/ImGuiLayer"): + remove_autoload_singleton("ImGuiLayer") + + # warn user if csproj will fail to build + if "C#" in ProjectSettings.get_setting("application/config/features"): + var projPath: String = ProjectSettings.get_setting("dotnet/project/solution_directory") + var fn: String = "%s.csproj" % ProjectSettings.get_setting("dotnet/project/assembly_name") + check_csproj(projPath.path_join(fn)) + +func check_csproj(fn): + var fi := FileAccess.open(fn, FileAccess.READ) + if !fi: + return + + var changesNeeded := "" + var data := fi.get_as_text() + var idx := data.find("net") + if idx != -1: + idx += len("net") + var idx_dot := data.find(".", idx) + var netVer := data.substr(idx, idx_dot - idx).to_int() + if netVer < 8: + changesNeeded += "- Set target framework to .NET 8 or later\n" + + if !data.contains(""): + changesNeeded += "- Allow unsafe blocks\n" + + if !data.contains(" String: + return "ImGui" + + func _get_export_options(platform: EditorExportPlatform) -> Array[Dictionary]: + var rv: Array[Dictionary] = [] + var desktop_platform := platform.get_os_name() in ["Windows", "macOS", "Linux"] + + rv.append({ + "option": { + "name": "imgui/debug", + "type": TYPE_BOOL, + }, + "default_value": desktop_platform, + }) + rv.append({ + "option": { + "name": "imgui/release", + "type": TYPE_BOOL, + }, + "default_value": false, + }) + return rv + + func _export_begin(features: PackedStringArray, is_debug: bool, path: String, flags: int) -> void: + extension_list_file = PackedByteArray() + gdext_file = PackedByteArray() + + if is_debug: + export_imgui = get_option("imgui/debug") + else: + export_imgui = get_option("imgui/release") + + if not export_imgui: + print("imgui-godot: not exporting (ignore 'failed to load GDExtension' error)") + + # disable autoload + if ProjectSettings.has_setting("autoload/ImGuiRoot"): + plugin.remove_autoload_singleton("ImGuiRoot") + + # prevent copying of GDExtension library (causes printed error) + var da := DirAccess.open("res://addons/imgui-godot") + if da.file_exists("imgui-godot-native.gdextension"): + gdext_file = FileAccess.get_file_as_bytes(gdext_resource) + da.remove("imgui-godot-native.gdextension") + + # prevent attempt to load .gdextension resource + extension_list_file = FileAccess.get_file_as_bytes("res://.godot/extension_list.cfg") + var extension_list := extension_list_file.get_string_from_utf8() + var idx := extension_list.find(gdext_resource) + if idx != -1: + var buf := extension_list.erase(idx, gdext_resource.length()) + var fi := FileAccess.open("res://.godot/extension_list.cfg", FileAccess.WRITE) + fi.store_string(buf) + fi.close() + + func _export_end() -> void: + if not export_imgui: + # restore autoload + plugin.add_autoload_singleton("ImGuiRoot", imgui_root) + + # restore GDExtension + if extension_list_file.size() > 0: + var fi := FileAccess.open("res://.godot/extension_list.cfg", FileAccess.WRITE) + fi.store_buffer(extension_list_file) + fi.close() + if gdext_file.size() > 0: + var fi := FileAccess.open(gdext_resource, FileAccess.WRITE) + fi.store_buffer(gdext_file) + fi.close() + + func _export_file(path: String, type: String, features: PackedStringArray) -> void: + if not export_imgui: + if path.contains("res://addons/imgui-godot"): + skip() diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiPlugin.gd.uid b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiPlugin.gd.uid new file mode 100644 index 00000000000..9d115179543 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiPlugin.gd.uid @@ -0,0 +1 @@ +uid://djdcuch4u2wa diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiRoot.gd b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiRoot.gd new file mode 100644 index 00000000000..a876a6579b0 --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiRoot.gd @@ -0,0 +1,24 @@ +extends Node + +signal imgui_layout + +const csharp_controller := "res://addons/imgui-godot/ImGuiGodot/ImGuiController.cs" +const csharp_sync := "res://addons/imgui-godot/ImGuiGodot/ImGuiSync.cs" + +func _enter_tree(): + var has_csharp := false + if ClassDB.class_exists("CSharpScript"): + var script := load(csharp_sync) + has_csharp = script.get_instance_base_type() == "Object" + + if ClassDB.class_exists("ImGuiController"): + # native + add_child(ClassDB.instantiate("ImGuiController")) + if has_csharp: + var obj: Object = load(csharp_sync).new() + obj.SyncPtrs() + obj.free() + else: + # C# only + if has_csharp: + add_child(load(csharp_controller).new()) diff --git a/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiRoot.gd.uid b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiRoot.gd.uid new file mode 100644 index 00000000000..50fa0adb58b --- /dev/null +++ b/demo/Blackholio/client-godot/addons/imgui-godot/scripts/ImGuiRoot.gd.uid @@ -0,0 +1 @@ +uid://0dg5m4plt155 diff --git a/demo/Blackholio/client-godot/assets/textures/StarBackground.png b/demo/Blackholio/client-godot/assets/textures/StarBackground.png new file mode 100644 index 00000000000..bc241e5b378 Binary files /dev/null and b/demo/Blackholio/client-godot/assets/textures/StarBackground.png differ diff --git a/demo/Blackholio/client-godot/assets/textures/StarBackground.png.import b/demo/Blackholio/client-godot/assets/textures/StarBackground.png.import new file mode 100644 index 00000000000..9776c5e099f --- /dev/null +++ b/demo/Blackholio/client-godot/assets/textures/StarBackground.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bic0r5ke12xsa" +path="res://.godot/imported/StarBackground.png-e51a119b1326f3c6ab6cdab8be41a78f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/textures/StarBackground.png" +dest_files=["res://.godot/imported/StarBackground.png-e51a119b1326f3c6ab6cdab8be41a78f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/demo/Blackholio/client-godot/autoload/GameManager.gd b/demo/Blackholio/client-godot/autoload/GameManager.gd new file mode 100644 index 00000000000..5e8b3f3ab5b --- /dev/null +++ b/demo/Blackholio/client-godot/autoload/GameManager.gd @@ -0,0 +1,7 @@ +extends Node + +signal died + +var local_player: PlayerController +var local_identity: String +var is_connected: bool = false diff --git a/demo/Blackholio/client-godot/autoload/GameManager.gd.uid b/demo/Blackholio/client-godot/autoload/GameManager.gd.uid new file mode 100644 index 00000000000..4f5f4afadf6 --- /dev/null +++ b/demo/Blackholio/client-godot/autoload/GameManager.gd.uid @@ -0,0 +1 @@ +uid://bmcp58bug2m76 diff --git a/demo/Blackholio/client-godot/icon.svg b/demo/Blackholio/client-godot/icon.svg new file mode 100644 index 00000000000..9d8b7fa14f0 --- /dev/null +++ b/demo/Blackholio/client-godot/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/demo/Blackholio/client-godot/icon.svg.import b/demo/Blackholio/client-godot/icon.svg.import new file mode 100644 index 00000000000..50eb29bfb0c --- /dev/null +++ b/demo/Blackholio/client-godot/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvjlb6dyhdx80" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/demo/Blackholio/client-godot/project.godot b/demo/Blackholio/client-godot/project.godot new file mode 100644 index 00000000000..aa7e26b05fc --- /dev/null +++ b/demo/Blackholio/client-godot/project.godot @@ -0,0 +1,49 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="client-godot" +run/main_scene="uid://bxuk66njrs835" +config/features=PackedStringArray("4.4", "GL Compatibility") +config/icon="res://icon.svg" + +[autoload] + +SpacetimeDB="*res://addons/SpacetimeDB/Core/SpacetimeDBClient.gd" +GameManager="*res://autoload/GameManager.gd" +ImGuiRoot="*res://addons/imgui-godot/data/ImGuiRoot.tscn" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/SpacetimeDB/plugin.cfg", "res://addons/imgui-godot/plugin.cfg") + +[input] + +split={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +] +} +lock_input={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null) +] +} +suicide={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) +] +} + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/demo/Blackholio/client-godot/scenes/camera_controller.gd b/demo/Blackholio/client-godot/scenes/camera_controller.gd new file mode 100644 index 00000000000..17336a2521b --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/camera_controller.gd @@ -0,0 +1,57 @@ +extends Camera2D + +@export var menu_camera: Camera2D +@export var menu: Control +@export var target_zoom: Vector2 = Vector2(5.0, 5.0) +@export var target_position: Vector2 = Vector2.ZERO + +@export var default_position: Vector2 = Vector2.ZERO +@export var default_zoom: float = 4.5 +@export var mass_multiplier: float = 0.0005 +@export var circle_multiplier: float = 0.001 + +var world_size: int = 1000: + set(new_value): + world_size = new_value + arena_center_transform = Vector2(world_size / 4, world_size / 4) + menu_camera.position = default_position + menu.position = default_position +var arena_center_transform := Vector2(world_size / 4, world_size / 4) + +func _process(delta: float): + zoom = lerp(zoom, target_zoom, delta * 2) + offset = lerp(offset, target_position, delta * 2) + + var local_player = GameManager.local_player + if (local_player == null || !GameManager.is_connected): + # Set the camera to be in middle of the arena if we are not connected or + # there is no local player + target_zoom = Vector2(1.0, 1.0) + target_position = Vector2.ZERO + return + + ImGui.Begin("Camera") + ImGui.Text("Zoom: %s" % target_zoom) + ImGui.Text("Mass: %s" % GameManager.local_player.total_mass()) + ImGui.Text("Circles: %s" % GameManager.local_player.number_of_owned_circles) + ImGui.End() + + var center_of_mass = local_player.center_of_mass() + if (center_of_mass): + # Set the camera to be the center of mass of the local player + # if the local player has one + target_position = Vector2(center_of_mass.x, center_of_mass.y) + target_zoom = Vector2.ONE * calculate_camera_zoom(local_player) + else: + target_zoom = Vector2(default_zoom, default_zoom) + target_position = Vector2.ZERO + menu_camera.zoom = Vector2.ONE * 2 + menu_camera.offset = offset + menu_camera.position = position + menu.position = Vector2.ZERO + menu.scale = Vector2(0.5, 0.5) + +func calculate_camera_zoom(local_player: PlayerController): + var final_zoom = default_zoom - local_player.total_mass() * mass_multiplier + final_zoom -= local_player.number_of_owned_circles * circle_multiplier + return final_zoom diff --git a/demo/Blackholio/client-godot/scenes/camera_controller.gd.uid b/demo/Blackholio/client-godot/scenes/camera_controller.gd.uid new file mode 100644 index 00000000000..a60bf9eb1c2 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/camera_controller.gd.uid @@ -0,0 +1 @@ +uid://chxu78vycm6x7 diff --git a/demo/Blackholio/client-godot/scenes/camera_controller.tscn b/demo/Blackholio/client-godot/scenes/camera_controller.tscn new file mode 100644 index 00000000000..43c61381134 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/camera_controller.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://buff80egtgxf0"] + +[ext_resource type="Script" uid="uid://chxu78vycm6x7" path="res://scenes/camera_controller.gd" id="1_nsieu"] + +[node name="CameraController" type="Camera2D"] +script = ExtResource("1_nsieu") diff --git a/demo/Blackholio/client-godot/scenes/circle_controller.gd b/demo/Blackholio/client-godot/scenes/circle_controller.gd new file mode 100644 index 00000000000..263a7c15088 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/circle_controller.gd @@ -0,0 +1,32 @@ +class_name CircleController extends EntityController + +@export var color_palette: Array[Color] = [ + Color(0.686, 0.624, 0.192), + Color(0.686, 0.455, 0.192), + Color(0.439, 0.184, 0.988), + Color(0.200, 0.357, 0.988), + Color(0.690, 0.212, 0.212), + Color(0.690, 0.427, 0.212), + Color(0.553, 0.169, 0.388), + Color(0.008, 0.737, 0.980), + Color(0.027, 0.196, 0.984), + Color(0.008, 0.110, 0.573) +] + +var player_owner: PlayerController + +func _draw(): + # if !player_owner: return + draw_circle(Vector2.ZERO, actual_scale.x * 0.5, color.darkened(0.2), false, 2.0) + draw_circle(Vector2.ZERO, actual_scale.x * 0.5, color) + +func spawn(circle: BlackholioCircle, input_owner: PlayerController): + spawn_entity(circle.entity_id) + color = color_palette[fmod(circle.player_id, color_palette.size())] + player_owner = input_owner + get_node("Label").text = input_owner.username + player_owner.on_circle_spawned(self) + +func on_delete(): + player_owner.on_circle_deleted(self) + queue_free() diff --git a/demo/Blackholio/client-godot/scenes/circle_controller.gd.uid b/demo/Blackholio/client-godot/scenes/circle_controller.gd.uid new file mode 100644 index 00000000000..13e2fc29dda --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/circle_controller.gd.uid @@ -0,0 +1 @@ +uid://gx3va23uy542 diff --git a/demo/Blackholio/client-godot/scenes/circle_controller.tscn b/demo/Blackholio/client-godot/scenes/circle_controller.tscn new file mode 100644 index 00000000000..9bcb30e7593 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/circle_controller.tscn @@ -0,0 +1,24 @@ +[gd_scene load_steps=2 format=3 uid="uid://bemmdx8plr0c8"] + +[ext_resource type="Script" uid="uid://gx3va23uy542" path="res://scenes/circle_controller.gd" id="1_7ru76"] + +[node name="CircleController" type="Node2D"] +script = ExtResource("1_7ru76") + +[node name="Label" type="Label" parent="."] +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -5.0 +offset_top = -14.0 +offset_right = -4.0 +offset_bottom = 9.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_colors/font_color = Color(0, 0, 0, 1) +theme_override_colors/font_shadow_color = Color(1, 1, 1, 1) +theme_override_font_sizes/font_size = 16 +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/demo/Blackholio/client-godot/scenes/enter_game_button.gd b/demo/Blackholio/client-godot/scenes/enter_game_button.gd new file mode 100644 index 00000000000..7f8bce779c2 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/enter_game_button.gd @@ -0,0 +1,23 @@ +extends Button + +@export var menu: Control +@export var camera: Camera2D +@export var menu_camera: Camera2D +@export var displayname_panel: Panel +@export var respawn_panel: Panel +@export var display_input: TextEdit + +func _ready(): + pressed.connect(func (): + if (!display_input.text): + display_input.text = "" + return + BlackholioModule.enter_game(display_input.text) + GameManager.local_player.username = display_input.text + menu.hide() + camera.enabled = true + menu_camera.enabled = false + displayname_panel.hide() + respawn_panel.show() + ) + diff --git a/demo/Blackholio/client-godot/scenes/enter_game_button.gd.uid b/demo/Blackholio/client-godot/scenes/enter_game_button.gd.uid new file mode 100644 index 00000000000..340e11c94ce --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/enter_game_button.gd.uid @@ -0,0 +1 @@ +uid://cj852krs1vdi0 diff --git a/demo/Blackholio/client-godot/scenes/food_controller.gd b/demo/Blackholio/client-godot/scenes/food_controller.gd new file mode 100644 index 00000000000..8816d1d2846 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/food_controller.gd @@ -0,0 +1,18 @@ +class_name FoodController extends EntityController + +@export var color_palette: Array[Color] = [ + Color(0.988, 0.678, 1.000), + Color(0.980, 0.573, 1.000), + Color(0.965, 0.471, 1.000), + Color(0.984, 0.788, 1.000), + Color(0.976, 0.722, 1.000), + Color(0.961, 0.647, 1.000) +] + +func _draw(): + # if !player_owner: return + draw_circle(Vector2.ZERO, actual_scale.x, color) + +func spawn(food: BlackholioFood): + super.spawn_entity(food.entity_id) + color = color_palette[fmod(food.entity_id, color_palette.size())] diff --git a/demo/Blackholio/client-godot/scenes/food_controller.gd.uid b/demo/Blackholio/client-godot/scenes/food_controller.gd.uid new file mode 100644 index 00000000000..adeff61dcac --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/food_controller.gd.uid @@ -0,0 +1 @@ +uid://djw43eh16f0tp diff --git a/demo/Blackholio/client-godot/scenes/food_controller.tscn b/demo/Blackholio/client-godot/scenes/food_controller.tscn new file mode 100644 index 00000000000..6ded08842a1 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/food_controller.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://c1sxf4qat647l"] + +[ext_resource type="Script" uid="uid://djw43eh16f0tp" path="res://scenes/food_controller.gd" id="1_fcw6m"] + +[node name="FoodController" type="Node2D"] +script = ExtResource("1_fcw6m") diff --git a/demo/Blackholio/client-godot/scenes/main.gd b/demo/Blackholio/client-godot/scenes/main.gd new file mode 100644 index 00000000000..b930845a46d --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/main.gd @@ -0,0 +1,140 @@ +extends Node2D + +@export var circle_row_receiver: RowReceiver +@export var entity_row_receiver: RowReceiver +@export var food_row_receiver: RowReceiver +@export var player_row_receiver: RowReceiver + +@onready var background: TextureRect = $Background + +var circle_scene: PackedScene = load("res://scenes/circle_controller.tscn") +var player_scene: PackedScene = load("res://scenes/player_controller.tscn") +var food_scene: PackedScene = load("res://scenes/food_controller.tscn") +var entities: Dictionary[int, EntityController] = {} +var players: Dictionary[int, PlayerController] = {} + +func _ready(): + # Connect to signals BEFORE connecting to the DB + SpacetimeDB.connected.connect(_on_spacetimedb_connected) + SpacetimeDB.disconnected.connect(_on_spacetimedb_disconnected) + SpacetimeDB.connection_error.connect(_on_spacetimedb_connection_error) + SpacetimeDB.identity_received.connect(_on_spacetimedb_identity_received) + SpacetimeDB.database_initialized.connect(_on_spacetimedb_database_initialized) + # SpacetimeDB.transaction_update_received.connect(_on_transaction_update) # For reducer results + + var options = SpacetimeDBConnectionOptions.new() + options.compression = SpacetimeDBConnection.CompressionPreference.NONE + options.one_time_token = true + options.debug_mode = false + options.inbound_buffer_size = 1024 * 1024 * 2 # 2MB + options.outbound_buffer_size = 1024 * 1024 * 2 # 2MB + + SpacetimeDB.connect_db( + "http://127.0.0.1:3000", # Base HTTP URL + "blackholio", # Module Name + options + ) + + circle_row_receiver.insert.connect(_on_circle_inserted) + entity_row_receiver.update.connect(_on_entity_updated) + entity_row_receiver.delete.connect(_on_entity_deleted) + food_row_receiver.insert.connect(_on_food_inserted) + player_row_receiver.insert.connect(_on_player_inserted) + player_row_receiver.delete.connect(_on_player_deleted) + +func _on_spacetimedb_connected(): + print("Game: Connected to SpacetimeDB!") + # Good place to subscribe to initial data + var queries = ["SELECT * FROM player", "SELECT * FROM config", "SELECT * FROM circle", "SELECT * FROM food", "SELECT * FROM entity"] + var req_id = SpacetimeDB.subscribe(queries) + if req_id < 0: printerr("Subscription failed!") + GameManager.is_connected = true + +func _on_spacetimedb_identity_received(identity_token: IdentityTokenData): + print("Game: My Identity: 0x%s" % identity_token.identity.hex_encode()) + GameManager.local_identity = identity_token.identity.hex_encode() + +func _on_spacetimedb_database_initialized(): + print("Game: Local database cache initialized.") + # Safe to query the local DB for initially subscribed data + var db = SpacetimeDB.get_local_database() + var config: BlackholioConfig = db.get_row("config", 0) + setup_arena(config.world_size) + var initial_players = db.get_all_rows("player") + print("Initial players found: %d" % initial_players.size()) + for player in initial_players: + _on_player_inserted(player) + + var initial_circles = db.get_all_rows("circle") + for circle in initial_circles: + _on_circle_inserted(circle) + + var initial_food = db.get_all_rows("food") + for food in initial_food: + _on_food_inserted(food) + +func _on_spacetimedb_disconnected(): + print("Game: Disconnected.") + +func _on_spacetimedb_connection_error(code, reason): + printerr("Game: Connection Error (Code: %d): %s" % [code, reason]) + +# func _on_transaction_update(update: TransactionUpdateData): + # Handle results/errors from reducer calls + #if update.status.status_type == UpdateStatusData.StatusType.FAILED: + #printerr("Reducer call (ReqID: %d) failed: %s" % [update.reducer_call.request_id, update.status.failure_message]) + #elif update.status.status_type == UpdateStatusData.StatusType.COMMITTED: + #print("Reducer call (ReqID: %d) committed." % update.reducer_call.request_id) + # Optionally inspect update.status.committed_update for DB changes + +func setup_arena(world_size: int): + var size = world_size / background.scale.x + background.size = Vector2(size, size) + $CameraController.world_size = world_size + +func _on_circle_inserted(circle: BlackholioCircle): + var db = SpacetimeDB.get_local_database() + var circle_controller: CircleController = circle_scene.instantiate() + var player = get_or_create_player_by_player_id(circle.player_id) + circle_controller.spawn(circle, player) + add_child(circle_controller) + entities.set(circle.entity_id, circle_controller) + +func get_or_create_player_by_player_id(player_id: int): + var db = SpacetimeDB.get_local_database() + var players = db.get_all_rows("player") + for player in players: + if player.player_id == player_id: + return get_or_create_player(player) + +func get_or_create_player(player: BlackholioPlayer) -> PlayerController: + if (!players.has(player.player_id)): + var player_controller: PlayerController = player_scene.instantiate() + add_child(player_controller) + player_controller.initialize(player) + players.set(player.player_id, player_controller) + + return players.get(player.player_id); + +func _on_entity_updated(_previous_entity: BlackholioEntity, new_entity: BlackholioEntity): + if (!entities.has(new_entity.entity_id)): return + entities.get(new_entity.entity_id).on_entity_update(new_entity) + +func _on_entity_deleted(entity: BlackholioEntity): + if (entities.has(entity.entity_id)): + entities.get(entity.entity_id).on_delete() + entities.erase(entity.entity_id) + +func _on_food_inserted(food: BlackholioFood): + var food_controller: FoodController = food_scene.instantiate() + add_child(food_controller) + food_controller.spawn(food) + entities.set(food.entity_id, food_controller) + +func _on_player_inserted(player: BlackholioPlayer): + get_or_create_player(player) + +func _on_player_deleted(player: BlackholioPlayer): + if (players.has(player.player_id)): + players.get(player.player_id).queue_free() + players.erase(player.player_id) diff --git a/demo/Blackholio/client-godot/scenes/main.gd.uid b/demo/Blackholio/client-godot/scenes/main.gd.uid new file mode 100644 index 00000000000..6ecfcdb956e --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/main.gd.uid @@ -0,0 +1 @@ +uid://caf2ukha6d1l5 diff --git a/demo/Blackholio/client-godot/scenes/main.gdshader b/demo/Blackholio/client-godot/scenes/main.gdshader new file mode 100644 index 00000000000..b1ad20cae36 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/main.gdshader @@ -0,0 +1,21 @@ +shader_type canvas_item; + +uniform float border_size : hint_range(0.0, 0.5) = 0.001; +uniform vec4 border_color : source_color = vec4(1.0, 1.0, 1.0, 1.0); +uniform vec4 background_color : source_color = vec4(0.0, 0.0, 0.0, 1.0); + +void fragment() { + vec2 uv = UV; + // Check if we're within the border region + bool is_border = + uv.x < border_size || + uv.x > 1.0 - border_size || + uv.y < border_size || + uv.y > 1.0 - border_size; + + if (is_border) { + COLOR = border_color; + } else { + COLOR = background_color - texture(TEXTURE, uv); + } +} diff --git a/demo/Blackholio/client-godot/scenes/main.gdshader.uid b/demo/Blackholio/client-godot/scenes/main.gdshader.uid new file mode 100644 index 00000000000..085a6573319 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/main.gdshader.uid @@ -0,0 +1 @@ +uid://b1oibng64wcag diff --git a/demo/Blackholio/client-godot/scenes/main.tscn b/demo/Blackholio/client-godot/scenes/main.tscn new file mode 100644 index 00000000000..76f56fea33c --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/main.tscn @@ -0,0 +1,169 @@ +[gd_scene load_steps=17 format=3 uid="uid://bxuk66njrs835"] + +[ext_resource type="Script" uid="uid://caf2ukha6d1l5" path="res://scenes/main.gd" id="1_o5qli"] +[ext_resource type="Script" uid="uid://jvk6ou7i2d4s" path="res://addons/SpacetimeDB/GodotHelpers/RowReceiver.gd" id="2_0wfyh"] +[ext_resource type="Script" uid="uid://dqmqs2xovlarq" path="res://spacetime_data/schema/tables/blackholio_circle_table.gd" id="3_tefeu"] +[ext_resource type="Script" uid="uid://baowi86860b4w" path="res://spacetime_data/schema/tables/blackholio_entity_table.gd" id="4_o6xl0"] +[ext_resource type="Script" uid="uid://dtnqygsfsqhr7" path="res://spacetime_data/schema/tables/blackholio_food_table.gd" id="5_tipki"] +[ext_resource type="Script" uid="uid://dfx137l3hqnta" path="res://spacetime_data/schema/tables/blackholio_player_table.gd" id="6_85g3d"] +[ext_resource type="Texture2D" uid="uid://bic0r5ke12xsa" path="res://assets/textures/StarBackground.png" id="7_o6xl0"] +[ext_resource type="PackedScene" uid="uid://buff80egtgxf0" path="res://scenes/camera_controller.tscn" id="7_tipki"] +[ext_resource type="Shader" uid="uid://b1oibng64wcag" path="res://scenes/main.gdshader" id="8_choun"] +[ext_resource type="Script" uid="uid://cj852krs1vdi0" path="res://scenes/enter_game_button.gd" id="9_85g3d"] +[ext_resource type="Script" uid="uid://b3polu81puxt7" path="res://scenes/respawn_button.gd" id="11_ya4ey"] + +[sub_resource type="Resource" id="Resource_choun"] +script = ExtResource("3_tefeu") +entity_id = 0 +player_id = 0 +speed = 0.0 +last_split_time = 0 +metadata/_custom_type_script = "uid://dqmqs2xovlarq" + +[sub_resource type="Resource" id="Resource_ya4ey"] +script = ExtResource("4_o6xl0") +entity_id = 0 +mass = 0 +metadata/_custom_type_script = "uid://baowi86860b4w" + +[sub_resource type="Resource" id="Resource_eb6dy"] +script = ExtResource("5_tipki") +entity_id = 0 +metadata/_custom_type_script = "uid://dtnqygsfsqhr7" + +[sub_resource type="Resource" id="Resource_trceg"] +script = ExtResource("6_85g3d") +identity = PackedByteArray() +player_id = 0 +name = "" +metadata/_custom_type_script = "uid://dfx137l3hqnta" + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_ya4ey"] +shader = ExtResource("8_choun") +shader_parameter/border_size = 0.001 +shader_parameter/border_color = Color(1, 1, 1, 1) +shader_parameter/background_color = Color(0, 0, 0, 1) + +[node name="Main" type="Node2D" node_paths=PackedStringArray("circle_row_receiver", "entity_row_receiver", "food_row_receiver", "player_row_receiver")] +script = ExtResource("1_o5qli") +circle_row_receiver = NodePath("Receiver [BlackholioCircleTable]") +entity_row_receiver = NodePath("Receiver [BlackholioEntityTable]") +food_row_receiver = NodePath("Receiver [BlackholioFoodTable]") +player_row_receiver = NodePath("Receiver [BlackholioPlayerTable]") + +[node name="Receiver [BlackholioCircleTable]" type="Node" parent="."] +script = ExtResource("2_0wfyh") +table_to_receive = SubResource("Resource_choun") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="Receiver [BlackholioEntityTable]" type="Node" parent="."] +script = ExtResource("2_0wfyh") +table_to_receive = SubResource("Resource_ya4ey") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="Receiver [BlackholioFoodTable]" type="Node" parent="."] +script = ExtResource("2_0wfyh") +table_to_receive = SubResource("Resource_eb6dy") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="Receiver [BlackholioPlayerTable]" type="Node" parent="."] +script = ExtResource("2_0wfyh") +table_to_receive = SubResource("Resource_trceg") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="CameraController" parent="." node_paths=PackedStringArray("menu_camera", "menu") instance=ExtResource("7_tipki")] +offset = Vector2(7.845, 16.265) +enabled = false +menu_camera = NodePath("../MenuCamera") +menu = NodePath("../Menu") + +[node name="MenuCamera" type="Camera2D" parent="."] +anchor_mode = 0 + +[node name="Background" type="TextureRect" parent="."] +material = SubResource("ShaderMaterial_ya4ey") +texture = ExtResource("7_o6xl0") +expand_mode = 2 + +[node name="Menu" type="Control" parent="."] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_right = 1152.0 +offset_bottom = 648.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Panel" type="Panel" parent="Menu"] +custom_minimum_size = Vector2(91.11, 65.925) +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -100.0 +offset_top = -36.0275 +offset_right = 100.0 +offset_bottom = 36.0275 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="Menu/Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="DisplayName" type="TextEdit" parent="Menu/Panel/VBoxContainer"] +custom_minimum_size = Vector2(0, 33) +layout_mode = 2 +placeholder_text = "Display Name" +scroll_fit_content_height = true + +[node name="EnterGameButton" type="Button" parent="Menu/Panel/VBoxContainer" node_paths=PackedStringArray("menu", "camera", "menu_camera", "displayname_panel", "respawn_panel", "display_input")] +layout_mode = 2 +text = "Join" +script = ExtResource("9_85g3d") +menu = NodePath("../../..") +camera = NodePath("../../../../CameraController") +menu_camera = NodePath("../../../../MenuCamera") +displayname_panel = NodePath("../..") +respawn_panel = NodePath("../../../Panel2") +display_input = NodePath("../DisplayName") + +[node name="Panel2" type="Panel" parent="Menu"] +visible = false +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -100.0 +offset_top = -17.235 +offset_right = 100.0 +offset_bottom = 17.235 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="Menu/Panel2"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="RespawnButton" type="Button" parent="Menu/Panel2/VBoxContainer" node_paths=PackedStringArray("menu", "camera", "menu_camera")] +layout_mode = 2 +text = "Respawn" +script = ExtResource("11_ya4ey") +menu = NodePath("../../..") +camera = NodePath("../../../../CameraController") +menu_camera = NodePath("../../../../MenuCamera") diff --git a/demo/Blackholio/client-godot/scenes/main.tscn10208720529.tmp b/demo/Blackholio/client-godot/scenes/main.tscn10208720529.tmp new file mode 100644 index 00000000000..e9c1f2e8093 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/main.tscn10208720529.tmp @@ -0,0 +1,126 @@ +[gd_scene load_steps=14 format=3 uid="uid://bxuk66njrs835"] + +[ext_resource type="Script" uid="uid://caf2ukha6d1l5" path="res://scenes/main.gd" id="1_o5qli"] +[ext_resource type="Script" uid="uid://jvk6ou7i2d4s" path="res://addons/SpacetimeDB/GodotHelpers/RowReceiver.gd" id="2_0wfyh"] +[ext_resource type="Script" uid="uid://dqmqs2xovlarq" path="res://spacetime_data/schema/tables/blackholio_circle_table.gd" id="3_tefeu"] +[ext_resource type="Script" uid="uid://baowi86860b4w" path="res://spacetime_data/schema/tables/blackholio_entity_table.gd" id="4_o6xl0"] +[ext_resource type="Script" uid="uid://dtnqygsfsqhr7" path="res://spacetime_data/schema/tables/blackholio_food_table.gd" id="5_tipki"] +[ext_resource type="Script" uid="uid://dfx137l3hqnta" path="res://spacetime_data/schema/tables/blackholio_player_table.gd" id="6_85g3d"] +[ext_resource type="Texture2D" uid="uid://bic0r5ke12xsa" path="res://assets/textures/StarBackground.png" id="7_o6xl0"] +[ext_resource type="PackedScene" uid="uid://buff80egtgxf0" path="res://scenes/camera_controller.tscn" id="7_tipki"] +[ext_resource type="Script" uid="uid://cj852krs1vdi0" path="res://scenes/enter_game_button.gd" id="9_85g3d"] + +[sub_resource type="Resource" id="Resource_choun"] +script = ExtResource("3_tefeu") +entity_id = 0 +player_id = 0 +speed = 0.0 +last_split_time = 0 +metadata/_custom_type_script = "uid://dqmqs2xovlarq" + +[sub_resource type="Resource" id="Resource_ya4ey"] +script = ExtResource("4_o6xl0") +entity_id = 0 +mass = 0 +metadata/_custom_type_script = "uid://baowi86860b4w" + +[sub_resource type="Resource" id="Resource_eb6dy"] +script = ExtResource("5_tipki") +entity_id = 0 +metadata/_custom_type_script = "uid://dtnqygsfsqhr7" + +[sub_resource type="Resource" id="Resource_trceg"] +script = ExtResource("6_85g3d") +identity = PackedByteArray() +player_id = 0 +name = "" +metadata/_custom_type_script = "uid://dfx137l3hqnta" + +[node name="Main" type="Node2D" node_paths=PackedStringArray("circle_row_receiver", "entity_row_receiver", "food_row_receiver", "player_row_receiver")] +script = ExtResource("1_o5qli") +circle_row_receiver = NodePath("Receiver [BlackholioCircleTable]") +entity_row_receiver = NodePath("Receiver [BlackholioEntityTable]") +food_row_receiver = NodePath("Receiver [BlackholioFoodTable]") +player_row_receiver = NodePath("Receiver [BlackholioPlayerTable]") + +[node name="Receiver [BlackholioCircleTable]" type="Node" parent="."] +script = ExtResource("2_0wfyh") +table_to_receive = SubResource("Resource_choun") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="Receiver [BlackholioEntityTable]" type="Node" parent="."] +script = ExtResource("2_0wfyh") +table_to_receive = SubResource("Resource_ya4ey") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="Receiver [BlackholioFoodTable]" type="Node" parent="."] +script = ExtResource("2_0wfyh") +table_to_receive = SubResource("Resource_eb6dy") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="Receiver [BlackholioPlayerTable]" type="Node" parent="."] +script = ExtResource("2_0wfyh") +table_to_receive = SubResource("Resource_trceg") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="CameraController" parent="." node_paths=PackedStringArray("menu_camera", "menu") instance=ExtResource("7_tipki")] +offset = Vector2(7.845, 16.265) +anchor_mode = 0 +enabled = false +menu_camera = NodePath("../MenuCamera") +menu = NodePath("../Menu") + +[node name="MenuCamera" type="Camera2D" parent="."] +anchor_mode = 0 + +[node name="Background" type="TextureRect" parent="."] +texture = ExtResource("7_o6xl0") +expand_mode = 2 + +[node name="Menu" type="Control" parent="."] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_right = 1152.0 +offset_bottom = 648.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="Panel" type="Panel" parent="Menu"] +custom_minimum_size = Vector2(91.11, 65.925) +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -100.0 +offset_top = -36.0275 +offset_right = 100.0 +offset_bottom = 36.0275 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="Menu/Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="DisplayName" type="TextEdit" parent="Menu/Panel/VBoxContainer"] +custom_minimum_size = Vector2(0, 33) +layout_mode = 2 +placeholder_text = "Display Name" +scroll_fit_content_height = true + +[node name="EnterGameButton" type="Button" parent="Menu/Panel/VBoxContainer" node_paths=PackedStringArray("menu", "camera")] +layout_mode = 2 +text = "Join" +script = ExtResource("9_85g3d") +menu = NodePath("../../..") +camera = NodePath("../../../../CameraController") diff --git a/demo/Blackholio/client-godot/scenes/main.tscn59755877029.tmp b/demo/Blackholio/client-godot/scenes/main.tscn59755877029.tmp new file mode 100644 index 00000000000..86c91071633 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/main.tscn59755877029.tmp @@ -0,0 +1,23 @@ +[gd_scene load_steps=3 format=3 uid="uid://bxuk66njrs835"] + +[ext_resource type="Script" uid="uid://caf2ukha6d1l5" path="res://scenes/main.gd" id="1_o5qli"] +[ext_resource type="Script" uid="uid://jvk6ou7i2d4s" path="res://addons/SpacetimeDB/GodotHelpers/RowReceiver.gd" id="2_0wfyh"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_o5qli") + +[node name="CircleRowReceiver" type="Node" parent="."] +script = ExtResource("2_0wfyh") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="EntityRowReceiver" type="Node" parent="."] +script = ExtResource("2_0wfyh") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="FoodRowReceiver" type="Node" parent="."] +script = ExtResource("2_0wfyh") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" + +[node name="PlayerRowReceiver" type="Node" parent="."] +script = ExtResource("2_0wfyh") +metadata/_custom_type_script = "uid://jvk6ou7i2d4s" diff --git a/demo/Blackholio/client-godot/scenes/player_controller.gd b/demo/Blackholio/client-godot/scenes/player_controller.gd new file mode 100644 index 00000000000..e62809bc60e --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/player_controller.gd @@ -0,0 +1,99 @@ +class_name PlayerController extends Node2D + +@export var username: String = "" +@export var number_of_owned_circles: int = 0: + get(): + return owned_circles.size() +@export var is_local_player: bool = false: + get(): + return GameManager.local_player == self + +const SEND_UPDATES_PER_SEC: int = 20 +const SEND_UPDATES_FREQUENCY: float = 1.0 / SEND_UPDATES_PER_SEC + +var player_id: int +var last_movement_timestamp: float = 0 +var lock_input_position: Vector2 = Vector2.ZERO +var owned_circles: Array[CircleController] = [] + +func initialize(player: BlackholioPlayer): + player_id = player.player_id + if player.identity.hex_encode() == GameManager.local_identity: + GameManager.local_player = self + +func on_destroy(): + for circle in owned_circles: + circle.queue_free() + +func on_circle_spawned(circle: CircleController): + owned_circles.append(circle) + +func on_circle_deleted(circle: CircleController): + if !is_local_player: return + + for owned_circle in owned_circles: + if owned_circle == circle: + circle.queue_free() + owned_circles.erase(circle) + + if number_of_owned_circles == 0: + GameManager.died.emit() + return + +func total_mass() -> float: + var mass: float = 0 + var db = SpacetimeDB.get_local_database() + for circle in owned_circles: + if !is_instance_valid(circle): continue + var entity = db.get_row("entity", circle.entity_id) + if entity: + mass += entity.mass + return mass + +func center_of_mass() -> Vector2: + if number_of_owned_circles == 0: + return Vector2.ZERO + + var db = SpacetimeDB.get_local_database() + var total_pos := Vector2.ZERO + var total_mass := 0.0 + + for circle in owned_circles: + if !is_instance_valid(circle): continue + var entity = db.get_row("entity", circle.entity_id) + if entity: + total_pos += circle.global_position * entity.mass + total_mass += entity.mass + + return total_pos / total_mass + +func _process(delta: float): + if !GameManager.local_player or number_of_owned_circles == 0: + return + + if Input.is_action_pressed("split"): + BlackholioModule.player_split() + + if Input.is_action_pressed("lock_input"): + if lock_input_position != Vector2.ZERO: + lock_input_position = Vector2.ZERO + else: + lock_input_position = get_viewport().get_mouse_position() + + if Input.is_action_pressed("suicide"): + BlackholioModule.suicide() + pass + + if Time.get_ticks_msec() - last_movement_timestamp >= SEND_UPDATES_FREQUENCY: + last_movement_timestamp = Time.get_ticks_msec() + var mouse_position = get_viewport().get_mouse_position() if lock_input_position == Vector2.ZERO else lock_input_position + var screen_size := get_viewport_rect().size + var center_of_screen := screen_size / 2 + var direction = (mouse_position - center_of_screen) / (screen_size.y / 3) + BlackholioModule.update_player_input(BlackholioDbVector2.create(direction.x, direction.y)) + +func on_gui(): + if !is_local_player or !GameManager.is_connected: + return + + # TODO: update label GUI.Label(new Rect(0, 0, 100, 50), $"Total Mass: {TotalMass()}"); diff --git a/demo/Blackholio/client-godot/scenes/player_controller.gd.uid b/demo/Blackholio/client-godot/scenes/player_controller.gd.uid new file mode 100644 index 00000000000..2acf894b00a --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/player_controller.gd.uid @@ -0,0 +1 @@ +uid://25m62eupexbf diff --git a/demo/Blackholio/client-godot/scenes/player_controller.tscn b/demo/Blackholio/client-godot/scenes/player_controller.tscn new file mode 100644 index 00000000000..63a9c8ce373 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/player_controller.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bxlb5swjnxwj5"] + +[ext_resource type="Script" uid="uid://25m62eupexbf" path="res://scenes/player_controller.gd" id="1_d4y06"] + +[node name="PlayerController" type="Node2D"] +script = ExtResource("1_d4y06") diff --git a/demo/Blackholio/client-godot/scenes/respawn_button.gd b/demo/Blackholio/client-godot/scenes/respawn_button.gd new file mode 100644 index 00000000000..b743ae5a7a6 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/respawn_button.gd @@ -0,0 +1,20 @@ +extends Button + +@export var menu: Control +@export var camera: Camera2D +@export var menu_camera: Camera2D + +func _ready(): + pressed.connect(func (): + BlackholioModule.respawn() + menu.hide() + camera.enabled = true + menu_camera.enabled = false + ) + + GameManager.died.connect(func(): + menu.show() + camera.enabled = false + menu_camera.enabled = true + ) + diff --git a/demo/Blackholio/client-godot/scenes/respawn_button.gd.uid b/demo/Blackholio/client-godot/scenes/respawn_button.gd.uid new file mode 100644 index 00000000000..7cded915505 --- /dev/null +++ b/demo/Blackholio/client-godot/scenes/respawn_button.gd.uid @@ -0,0 +1 @@ +uid://b3polu81puxt7 diff --git a/demo/Blackholio/client-godot/scripts/EntityController.gd b/demo/Blackholio/client-godot/scripts/EntityController.gd new file mode 100644 index 00000000000..bf060a96a90 --- /dev/null +++ b/demo/Blackholio/client-godot/scripts/EntityController.gd @@ -0,0 +1,44 @@ +class_name EntityController extends Node2D + +const LERP_DURATION_SEC: float = 0.1 + +var color: Color = Color.DEEP_PINK +var entity_id: int +var lerp_time: float +var lerp_target_position: BlackholioDbVector2 +var target_scale: Vector2 +var actual_scale: Vector2 = Vector2.ONE + +func _process(delta: float): + lerp_time = min(lerp_time + delta, LERP_DURATION_SEC) + if lerp_target_position: + global_position = lerp(global_position, Vector2(lerp_target_position.x, lerp_target_position.y), lerp_time / LERP_DURATION_SEC) + actual_scale = lerp(actual_scale, target_scale, delta * 8) + +func spawn_entity(input_entity_id: int): + entity_id = input_entity_id + var db = SpacetimeDB.get_local_database() + var entities = db.get_all_rows("entity") + for entity in entities: + if entity.entity_id == input_entity_id: + global_position = Vector2(entity.position.x, entity.position.y) + lerp_target_position = entity.position + global_position = Vector2(entity.position.x, entity.position.y) + scale = Vector2.ONE + target_scale = mass_to_scale(entity.mass) + +func on_delete(): + queue_free() + +func on_entity_update(entity: BlackholioEntity): + lerp_time = 0.0 + lerp_target_position = entity.position + target_scale = mass_to_scale(entity.mass) + queue_redraw() + +func mass_to_scale(mass: float): + var diameter = mass_to_diameter(mass) + return Vector2(diameter, diameter) + +func mass_to_radius(mass: float): return sqrt(mass) +func mass_to_diameter(mass: float): return mass_to_radius(mass) * 2 diff --git a/demo/Blackholio/client-godot/scripts/EntityController.gd.uid b/demo/Blackholio/client-godot/scripts/EntityController.gd.uid new file mode 100644 index 00000000000..4aaa7c10530 --- /dev/null +++ b/demo/Blackholio/client-godot/scripts/EntityController.gd.uid @@ -0,0 +1 @@ +uid://rnvpnrik3u04 diff --git a/demo/Blackholio/client-godot/spacetime_data/codegen_config.json b/demo/Blackholio/client-godot/spacetime_data/codegen_config.json new file mode 100644 index 00000000000..ad9e68cfcbe --- /dev/null +++ b/demo/Blackholio/client-godot/spacetime_data/codegen_config.json @@ -0,0 +1,7 @@ +{ + "config_version": 1, + "option_handling": 4, + "handling types": "ignore = 1, use_godot_option = 2, option_t_as_t = 3", + "hide_scheduled_reducers": true, + "hide_private_tables": true +} \ No newline at end of file diff --git a/demo/Blackholio/client-godot/spacetime_data/codegen_data.txt b/demo/Blackholio/client-godot/spacetime_data/codegen_data.txt new file mode 100644 index 00000000000..be45df21816 --- /dev/null +++ b/demo/Blackholio/client-godot/spacetime_data/codegen_data.txt @@ -0,0 +1 @@ +{"modules":["blackholio"],"uri":"http://127.0.0.1:3000"} \ No newline at end of file