diff --git a/main.tscn b/main.tscn index 40bfa97..cb91146 100644 --- a/main.tscn +++ b/main.tscn @@ -16,6 +16,8 @@ [ext_resource type="Script" path="res://world/ui/player_interaction_hud.gd" id="16_interaction"] [ext_resource type="Script" path="res://world/ui/villager_field_note_hud.gd" id="17_villager_note"] [ext_resource type="Script" path="res://world/ui/player_status_hud.gd" id="18_status"] +[ext_resource type="Script" path="res://player/combat/player_combat_controller.gd" id="19_combat"] +[ext_resource type="Script" path="res://world/combat/combat_presentation.gd" id="20_combat_view"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"] radius = 0.4 @@ -43,6 +45,13 @@ shape = SubResource("CapsuleShape3D_o5qli") [node name="Visual" parent="Player" instance=ExtResource("14_player_visual")] +[node name="PlayerCombatController" type="Node3D" parent="Player" unique_id=99761234 node_paths=PackedStringArray("player", "simulation_manager", "combat_presentation", "sword")] +script = ExtResource("19_combat") +player = NodePath("..") +simulation_manager = NodePath("../SimulationManager") +combat_presentation = NodePath("../CombatPresentation") +sword = NodePath("../Visual/Sword") + [node name="CameraRig" type="Node3D" parent="." unique_id=81378719 node_paths=PackedStringArray("target")] transform = Transform3D(1, 0, 0, 0, 0.57357645, 0.81915206, 0, -0.81915206, 0.57357645, 0, 10, 8) script = ExtResource("2_1bvp3") @@ -435,3 +444,11 @@ camera_rig = NodePath("../CameraRig") player = NodePath("../Player") pantry_storage = NodePath("../JajceWorld/WorldObjects/StorageSites/VillagePantry") crisis_caption = NodePath("../CrisisCaptionLayer/CrisisCaption") + +[node name="CombatPresentation" type="Node" parent="." unique_id=8844112 node_paths=PackedStringArray("simulation_manager", "hostiles_parent")] +script = ExtResource("20_combat_view") +simulation_manager = NodePath("../SimulationManager") +hostiles_parent = NodePath("../HostilesParent") +wolf_spawn_positions = PackedVector3Array(24, 0, 10, 30, 0, -4, 38, 0, 22) + +[node name="HostilesParent" type="Node3D" parent="." unique_id=8844113] diff --git a/player/PlayerVisual.tscn b/player/PlayerVisual.tscn index f3c818d..bc9885f 100644 --- a/player/PlayerVisual.tscn +++ b/player/PlayerVisual.tscn @@ -67,6 +67,24 @@ roughness = 0.82 material = SubResource("Material_accent") size = Vector3(0.48, 0.1, 0.38) +[sub_resource type="StandardMaterial3D" id="Material_sword"] +albedo_color = Color(0.72, 0.74, 0.78, 1) +roughness = 0.32 + +[sub_resource type="BoxMesh" id="Mesh_blade"] +material = SubResource("Material_sword") +size = Vector3(0.055, 0.78, 0.11) + +[sub_resource type="StandardMaterial3D" id="Material_grip"] +albedo_color = Color(0.3, 0.2, 0.12, 1) +roughness = 0.6 + +[sub_resource type="CylinderMesh" id="Mesh_grip"] +material = SubResource("Material_grip") +top_radius = 0.045 +bottom_radius = 0.045 +height = 0.16 + [sub_resource type="SphereMesh" id="Mesh_satchel"] material = SubResource("Material_accent") radius = 0.22 @@ -128,3 +146,15 @@ mesh = SubResource("Mesh_foot") [node name="Satchel" type="MeshInstance3D" parent="."] position = Vector3(-0.43, 0.87, -0.04) mesh = SubResource("Mesh_satchel") + +[node name="Sword" type="Node3D" parent="."] +position = Vector3(0.52, 1.22, 0.12) +rotation = Vector3(-0.35, 0, 0) + +[node name="Blade" type="MeshInstance3D" parent="Sword"] +position = Vector3(0, 0.42, 0) +mesh = SubResource("Mesh_blade") + +[node name="Grip" type="MeshInstance3D" parent="Sword"] +position = Vector3(0, -0.05, 0) +mesh = SubResource("Mesh_grip") diff --git a/player/combat/player_combat_controller.gd b/player/combat/player_combat_controller.gd new file mode 100644 index 0000000..83907d8 --- /dev/null +++ b/player/combat/player_combat_controller.gd @@ -0,0 +1,110 @@ +class_name PlayerCombatController +extends Node3D + +const SLASH_REST_ANGLE := Vector3(-0.35, 0.0, 0.0) + +@export var player: CharacterBody3D +@export var simulation_manager: Node +@export var combat_presentation: Node +@export var sword: Node3D + +@export_range(0.5, 4.0, 0.1) var attack_range := 2.6 +@export_range(30.0, 160.0, 5.0) var attack_angle := 90.0 +@export var dash_speed := 22.0 +@export var dash_duration := 0.18 +@export var dash_cooldown_seconds := 1.1 + +var attack_cooldown_seconds := 0.55 +var attack_timer := 0.0 +var dash_timer := 0.0 +var dashing := false +var dash_velocity := Vector3.ZERO +var _swing_tween: Tween +var _references_resolved := false + + +func _ready() -> void: + if sword == null: + return + sword.rotation = SLASH_REST_ANGLE + var definition := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD) + if definition != null: + attack_cooldown_seconds = definition.attack_cooldown + attack_range = maxf(definition.reach + 0.2, attack_range) + + +func _resolve_references() -> void: + if simulation_manager == null: + simulation_manager = get_tree().get_first_node_in_group("simulation_manager") + if combat_presentation == null: + combat_presentation = get_tree().get_first_node_in_group("combat_presentation") + _references_resolved = true + + +func _physics_process(delta: float) -> void: + if not _references_resolved: + _resolve_references() + attack_timer = maxf(attack_timer - delta, 0.0) + dash_timer = maxf(dash_timer - delta, 0.0) + if dashing: + player.velocity = dash_velocity + if dash_timer <= 0.0: + dashing = false + player.move_and_slide() + return + if Input.is_action_just_pressed("attack") and attack_timer <= 0.0: + _perform_attack() + if Input.is_action_just_pressed("dash") and dash_timer <= 0.0: + _perform_dash() + + +func _perform_attack() -> void: + attack_timer = attack_cooldown_seconds + _play_swing() + var facing := -player.global_transform.basis.z + var hostile: CombatantStateRecord = null + if combat_presentation != null and combat_presentation.has_method("find_hostile_in_arc"): + hostile = combat_presentation.find_hostile_in_arc( + player.global_position, facing, attack_range, attack_angle + ) + if hostile == null: + return + if simulation_manager != null and simulation_manager.has_method("player_attack"): + var damage: float = simulation_manager.player_attack(hostile.get_combatant_id()) + if damage > 0.0: + _emit_combat_feedback("Struck %s" % hostile.get_display_name(), damage) + + +func _perform_dash() -> void: + dash_timer = dash_duration + dashing = true + var facing := -player.global_transform.basis.z + facing.y = 0.0 + facing = facing.normalized() + dash_velocity = facing * dash_speed + dash_velocity.y = 0.0 + + +func _play_swing() -> void: + if sword == null: + return + if _swing_tween != null and _swing_tween.is_valid(): + _swing_tween.kill() + sword.rotation = SLASH_REST_ANGLE + _swing_tween = create_tween() + _swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + _swing_tween.tween_property(sword, "rotation", SLASH_REST_ANGLE + Vector3(-1.6, 0.6, 0.0), 0.12) + _swing_tween.tween_property(sword, "rotation", SLASH_REST_ANGLE, 0.2) + + +func _emit_combat_feedback(heading: String, damage: float) -> void: + if player != null and player.has_signal("interaction_feedback"): + player.interaction_feedback.emit(heading, "Dealt %.0f damage." % damage, true) + + +func is_attacking() -> bool: + return attack_timer > 0.0 + + +func is_dashing() -> bool: + return dashing diff --git a/player/combat/player_combat_controller.gd.uid b/player/combat/player_combat_controller.gd.uid new file mode 100644 index 0000000..c17e374 --- /dev/null +++ b/player/combat/player_combat_controller.gd.uid @@ -0,0 +1 @@ +uid://bfysko6pf0tnt diff --git a/project.godot b/project.godot index 3715d97..56c0e95 100644 --- a/project.godot +++ b/project.godot @@ -50,8 +50,18 @@ move_right={ "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":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) ] } -interact={ + interact={ "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":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) ] } +attack={ +"deadzone": 0.2, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +dash={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} diff --git a/tests/jajce_combat_presentation_test.gd b/tests/jajce_combat_presentation_test.gd new file mode 100644 index 0000000..e8b10e3 --- /dev/null +++ b/tests/jajce_combat_presentation_test.gd @@ -0,0 +1,124 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var main_scene: Node = load("res://main.tscn").instantiate() + root.add_child(main_scene) + await process_frame + for _frame in 12: + await physics_frame + + var simulation_manager: Node = main_scene.get_node("SimulationManager") + simulation_manager.set_process(false) + var combat_view: Node = main_scene.get_node("CombatPresentation") + var player := main_scene.get_node("Player") as Node3D + var controller := main_scene.get_node("Player/PlayerCombatController") + + await process_frame + await process_frame + _check( + combat_view.hostiles.size() >= 1, + "Authored wolves should spawn as hostile visuals in the loaded world" + ) + var hostiles: Array = simulation_manager.get_living_hostile_combatants() + _check(not hostiles.is_empty(), "The simulation should own at least one living hostile") + if hostiles.is_empty(): + main_scene.queue_free() + _finish() + return + var wolf: CombatantStateRecord = hostiles[0] + _check( + combat_view.hostiles.has(wolf.get_combatant_id()), + "The hostile visual should bind to the authoritative combatant record" + ) + _check( + ( + wolf.get_kind() == SimulationIds.COMBATANT_KIND_WOLF + and wolf.is_hostile() + and wolf.get_faction_id() == SimulationIds.FACTION_TRIBE + ), + "Spawned wolves should be hostile tribe-affiliated combatants" + ) + + var wolf_visual: Node3D = combat_view.hostiles[wolf.get_combatant_id()] + player.global_position = wolf_visual.global_position + Vector3(-2.0, 0.0, 0.0) + player.look_at(wolf_visual.global_position, Vector3.UP) + player.rotation.x = 0.0 + var wolf_health: float = wolf.get_health() + controller.call("_perform_attack") + _check( + wolf.get_health() < wolf_health, + "A player sword strike should damage the hostile wolf through the simulation" + ) + var struck := 0 + for _attempt in 6: + if not wolf.is_alive(): + break + controller.call("_perform_attack") + struck += 1 + await process_frame + _check(not wolf.is_alive(), "Repeated sword strikes should kill the hostile wolf") + await process_frame + await process_frame + _check( + not combat_view.hostiles.has(wolf.get_combatant_id()), + "Killing a hostile should remove its presentation visual" + ) + _check( + _has_event(simulation_manager, SimulationIds.EVENT_COMBATANT_KILLED), + "The player's kill should be recorded as a combatant_killed fact" + ) + + var tribe: FactionStateRecord = simulation_manager.get_faction(SimulationIds.FACTION_TRIBE) + _check( + ( + simulation_manager.get_village_defender_count() >= 0 + and tribe != null + and ( + simulation_manager.get_tribe_war_plan() + in [ + SimulationIds.WAR_PLAN_NONE, + SimulationIds.WAR_PLAN_PLANNED, + SimulationIds.WAR_PLAN_RAIDING, + SimulationIds.WAR_PLAN_ABORTED, + ] + ) + ), + "The runtime should expose village defense and the tribe war plan" + ) + + if failures.is_empty(): + print("[TEST] Jajce combat presentation passed: wolves -> sword -> kill -> cleanup") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + + +func _has_event(manager: Node, event_type: StringName) -> bool: + for event in manager.economic_events: + if StringName(event.data["event_type"]) == event_type: + return true + return false + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Jajce combat presentation passed: wolves -> sword -> kill -> cleanup") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/jajce_combat_presentation_test.gd.uid b/tests/jajce_combat_presentation_test.gd.uid new file mode 100644 index 0000000..52975f0 --- /dev/null +++ b/tests/jajce_combat_presentation_test.gd.uid @@ -0,0 +1 @@ +uid://bneh10pvbywgr diff --git a/world/combat/HostileCombatant.gd b/world/combat/HostileCombatant.gd new file mode 100644 index 0000000..d8838cd --- /dev/null +++ b/world/combat/HostileCombatant.gd @@ -0,0 +1,152 @@ +class_name HostileCombatant +extends Node3D + +const FOLLOW_SPEED := 6.0 +const ARRIVE_DISTANCE := 0.5 + +var combatant_id: StringName +var combatant: CombatantStateRecord +var simulation_manager: Node +var is_dead_visual := false +var flash_tween: Tween +var spawn_scale := Vector3.ONE + + +func _ready() -> void: + spawn_scale = Vector3.ONE + scale = Vector3.ONE * 0.001 + var tween := create_tween() + tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + tween.tween_property(self, "scale", Vector3.ONE, 0.28) + + +func bind(combatant_id_value: StringName, manager: Node) -> void: + combatant_id = combatant_id_value + simulation_manager = manager + combatant = manager.get_combatant(combatant_id) as CombatantStateRecord + if combatant == null: + return + global_position = combatant.get_position() + _build_body(combatant.get_kind()) + combatant.changed.connect(_on_combatant_changed) + + +func _process(delta: float) -> void: + if is_dead_visual or combatant == null or not is_instance_valid(combatant): + return + if not combatant.is_alive(): + _play_death() + return + var target_position := combatant.get_position() + var to_target := target_position - global_position + if to_target.length() <= ARRIVE_DISTANCE: + return + global_position += to_target.normalized() * minf(FOLLOW_SPEED * delta, to_target.length()) + rotation.y = lerp_angle(rotation.y, atan2(to_target.x, to_target.z), delta * 8.0) + + +func _on_combatant_changed(_state: CombatantStateRecord) -> void: + if combatant == null or combatant.get_health() >= combatant.get_max_health(): + return + _flash_hit() + + +func _flash_hit() -> void: + if flash_tween != null and flash_tween.is_valid(): + flash_tween.kill() + flash_tween = create_tween() + flash_tween.tween_property(self, "scale", Vector3.ONE * 0.85, 0.06) + flash_tween.tween_property(self, "scale", Vector3.ONE, 0.12) + + +func _play_death() -> void: + is_dead_visual = true + stop_travel_visual() + var tween := create_tween() + tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + tween.tween_property(self, "scale", Vector3(0.001, 0.001, 0.001), 0.45) + tween.tween_callback(queue_free) + + +func stop_travel_visual() -> void: + pass + + +func _build_body(kind: StringName) -> void: + if kind == SimulationIds.COMBATANT_KIND_WOLF: + _build_wolf() + else: + _build_raider() + + +func _build_raider() -> void: + var tunic := StandardMaterial3D.new() + tunic.albedo_color = Color(0.4, 0.13, 0.1, 1.0) + tunic.roughness = 0.85 + var skin := StandardMaterial3D.new() + skin.albedo_color = Color(0.8, 0.58, 0.36, 1.0) + skin.roughness = 0.9 + var metal := StandardMaterial3D.new() + metal.albedo_color = Color(0.55, 0.55, 0.58, 1.0) + metal.roughness = 0.35 + + _add_mesh("Body", CylinderMesh.new(), Vector3(0.0, 1.1, 0.0), tunic) + _add_mesh("Head", SphereMesh.new(), Vector3(0.0, 1.7, 0.0), skin) + var blade := BoxMesh.new() + blade.size = Vector3(0.06, 0.72, 0.14) + _add_mesh("Sword", blade, Vector3(0.42, 1.25, 0.0), metal, Vector3(0.0, 0.0, -0.2)) + var shield := CylinderMesh.new() + shield.top_radius = 0.3 + shield.bottom_radius = 0.3 + shield.height = 0.12 + _add_mesh( + "Shield", shield, Vector3(-0.42, 1.15, 0.0), metal, Vector3.ZERO, Vector3(1.57, 0.0, 0.0) + ) + + +func _build_wolf() -> void: + var fur := StandardMaterial3D.new() + fur.albedo_color = Color(0.32, 0.32, 0.34, 1.0) + fur.roughness = 0.95 + var eye := StandardMaterial3D.new() + eye.albedo_color = Color(0.95, 0.5, 0.12, 1.0) + eye.roughness = 0.3 + + var body := SphereMesh.new() + body.radius = 0.34 + body.height = 0.68 + _add_mesh("Body", body, Vector3(0.0, 0.55, 0.0), fur, Vector3.ZERO, Vector3(1.57, 0.0, 0.0)) + var head := SphereMesh.new() + head.radius = 0.2 + head.height = 0.4 + _add_mesh("Head", head, Vector3(0.0, 0.62, -0.42), fur, Vector3.ZERO, Vector3(1.57, 0.0, 0.0)) + var tail := BoxMesh.new() + tail.size = Vector3(0.1, 0.1, 0.5) + _add_mesh("Tail", tail, Vector3(0.0, 0.72, 0.42), fur, Vector3(0.6, 0.0, 0.0)) + var leg_mesh := CylinderMesh.new() + leg_mesh.top_radius = 0.06 + leg_mesh.bottom_radius = 0.06 + leg_mesh.height = 0.42 + for leg_x in [-0.18, 0.18]: + for leg_z in [-0.2, 0.2]: + _add_mesh("Leg", leg_mesh, Vector3(leg_x, 0.21, leg_z), fur) + + +func _add_mesh( + node_name: String, + mesh: PrimitiveMesh, + local_position: Vector3, + material: Material, + rotation_offset: Vector3 = Vector3.ZERO, + mesh_rotation: Vector3 = Vector3.ZERO +) -> void: + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = node_name + mesh_instance.mesh = mesh + mesh_instance.material_override = material + mesh_instance.position = local_position + if not rotation_offset.is_zero_approx(): + mesh_instance.rotation = rotation_offset + if not mesh_rotation.is_zero_approx(): + mesh_instance.rotation += mesh_rotation + add_child(mesh_instance) diff --git a/world/combat/HostileCombatant.gd.uid b/world/combat/HostileCombatant.gd.uid new file mode 100644 index 0000000..151cc36 --- /dev/null +++ b/world/combat/HostileCombatant.gd.uid @@ -0,0 +1 @@ +uid://8mx2at53whud diff --git a/world/combat/HostileCombatant.tscn b/world/combat/HostileCombatant.tscn new file mode 100644 index 0000000..12aa26a --- /dev/null +++ b/world/combat/HostileCombatant.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://world/combat/HostileCombatant.gd" id="1_hostile"] + +[node name="HostileCombatant" type="Node3D"] +script = ExtResource("1_hostile") diff --git a/world/combat/combat_presentation.gd b/world/combat/combat_presentation.gd new file mode 100644 index 0000000..211acc1 --- /dev/null +++ b/world/combat/combat_presentation.gd @@ -0,0 +1,110 @@ +class_name CombatPresentation +extends Node + +const HOSTILE_SCENE := preload("res://world/combat/HostileCombatant.tscn") + +@export var simulation_manager: Node +@export var hostiles_parent: Node3D +@export var wolf_spawn_positions: PackedVector3Array = PackedVector3Array() + +var hostiles: Dictionary = {} +var _initialized := false +var _wolves_spawned := false + + +func _ready() -> void: + add_to_group("combat_presentation") + if simulation_manager == null: + push_error("CombatPresentation: simulation_manager missing") + return + simulation_manager.combatant_spawned.connect(_on_combatant_spawned) + simulation_manager.combatant_died.connect(_on_combatant_died) + simulation_manager.state_restored.connect(_on_state_restored) + _initialized = true + call_deferred("_spawn_wolves") + _refresh_from_state() + + +func _spawn_wolves() -> void: + if _wolves_spawned or simulation_manager == null: + return + _wolves_spawned = true + for position in wolf_spawn_positions: + if simulation_manager.has_method("spawn_wolf"): + simulation_manager.spawn_wolf(position) + + +func _process(_delta: float) -> void: + if not _initialized: + return + for combatant_id in _hostile_ids(): + if not hostiles.has(combatant_id): + _spawn_visual(combatant_id) + + +func _hostile_ids() -> Array: + var ids: Array[StringName] = [] + var living: Array = simulation_manager.get_living_hostile_combatants() + for combatant in living: + ids.append(combatant.get_combatant_id()) + return ids + + +func _spawn_visual(combatant_id: StringName) -> void: + if hostiles.has(combatant_id): + return + var scene := HOSTILE_SCENE.instantiate() as HostileCombatant + hostiles_parent.add_child(scene) + scene.bind(combatant_id, simulation_manager) + hostiles[combatant_id] = scene + + +func _on_combatant_spawned(combatant_id: StringName) -> void: + _spawn_visual(combatant_id) + + +func _on_combatant_died(combatant_id: StringName) -> void: + var scene := hostiles.get(combatant_id) as HostileCombatant + if scene != null: + scene._play_death() + hostiles.erase(combatant_id) + + +func _on_state_restored() -> void: + for combatant_id in hostiles.keys(): + var scene := hostiles[combatant_id] as Node + if is_instance_valid(scene): + scene.queue_free() + hostiles.clear() + _refresh_from_state() + + +func _refresh_from_state() -> void: + if simulation_manager == null: + return + for combatant_id in _hostile_ids(): + _spawn_visual(combatant_id) + + +func find_hostile_in_arc( + origin: Vector3, facing: Vector3, max_distance: float, angle_degrees: float +) -> CombatantStateRecord: + var living: Array = simulation_manager.get_living_hostile_combatants() + var best: CombatantStateRecord + var best_score := INF + var facing_flat := Vector3(facing.x, 0.0, facing.z).normalized() + var half_cos := cos(deg_to_rad(angle_degrees) * 0.5) + for combatant in living: + var candidate := combatant as CombatantStateRecord + var offset := candidate.get_position() - origin + var horizontal := Vector2(offset.x, offset.z) + if horizontal.length() > max_distance: + continue + var direction := Vector3(offset.x, 0.0, offset.z).normalized() + if facing_flat.dot(direction) < half_cos: + continue + var distance := horizontal.length() + if distance < best_score: + best = candidate + best_score = distance + return best diff --git a/world/combat/combat_presentation.gd.uid b/world/combat/combat_presentation.gd.uid new file mode 100644 index 0000000..8dbf1a0 --- /dev/null +++ b/world/combat/combat_presentation.gd.uid @@ -0,0 +1 @@ +uid://qw0qb6mc8j7