diff --git a/npc/TestNPC.tscn b/npc/TestNPC.tscn new file mode 100644 index 00000000..c40ffba9 --- /dev/null +++ b/npc/TestNPC.tscn @@ -0,0 +1,68 @@ +[gd_scene format=3 uid="uid://bf4iah4bmvepo"] + +[ext_resource type="PackedScene" uid="uid://dyn4dqnx7qccp" path="res://npc/npc.tscn" id="1_p76ow"] +[ext_resource type="PackedScene" uid="uid://bvoha80et32ug" path="res://npc/capture_point.tscn" id="2_njkjl"] + +[sub_resource type="NavigationMesh" id="NavigationMesh_g4v7s"] +vertices = PackedVector3Array(-7.5164566, 0.4551066, -7.511303, -7.5164566, 0.4551066, 7.488697, 7.4835434, 0.4551066, 7.488697, 7.4835434, 0.4551066, -7.511303) +polygons = [PackedInt32Array(3, 2, 0), PackedInt32Array(0, 2, 1)] + +[sub_resource type="PlaneMesh" id="PlaneMesh_g4v7s"] + +[sub_resource type="HeightMapShape3D" id="HeightMapShape3D_g4v7s"] +map_width = 3 +map_depth = 3 +map_data = PackedFloat32Array(0, 0, 0, 0, 0, 0, 0, 0, 0) + +[node name="Node3D" type="Node3D" unique_id=1138881115] + +[node name="Camera3D" type="Camera3D" parent="." unique_id=436058419] +transform = Transform3D(-1, -8.742278e-08, 0, -3.821371e-15, 4.371139e-08, 1, -8.742278e-08, 1, -4.371139e-08, 0, 14.923, 0) + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1424074399] +transform = Transform3D(-0.8660254, -0.43301278, 0.25, 0, 0.49999997, 0.86602545, -0.50000006, 0.75, -0.43301266, 0, 0, 0) +shadow_enabled = true + +[node name="NavigationRegion3D" type="NavigationRegion3D" parent="." unique_id=1804967672] +transform = Transform3D(1.0000001, -1.4608759e-10, 0, -1.4614443e-10, 1.0000001, 0, 0, 0, 1.0000001, 0, 9.313226e-10, -4.656613e-10) +navigation_mesh = SubResource("NavigationMesh_g4v7s") + +[node name="MeshInstance3D2" type="MeshInstance3D" parent="NavigationRegion3D" unique_id=807263600] +transform = Transform3D(8, 0, 0, 0, 8, 0, 0, 0, 8, -0.0004460215, -0.009634018, 0.004761219) +mesh = SubResource("PlaneMesh_g4v7s") +skeleton = NodePath("../..") + +[node name="StaticBody3D" type="StaticBody3D" parent="NavigationRegion3D/MeshInstance3D2" unique_id=1307138777] +transform = Transform3D(1, -2.4851943e-10, 0, 2.1725555e-10, 1.0000001, 2.910383e-11, 2.3283064e-10, 0, 1.0000001, 0, 0, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="NavigationRegion3D/MeshInstance3D2/StaticBody3D" unique_id=768157444] +transform = Transform3D(1, 2.1714186e-10, 0, 2.1708502e-10, 0.99999994, 0, -2.3283064e-10, 0, 0.99999994, 0, 0, 0) +shape = SubResource("HeightMapShape3D_g4v7s") + +[node name="NPCBlue" parent="." unique_id=651721336 node_paths=PackedStringArray("capture_points") instance=ExtResource("1_p76ow")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4.1916294, 1.2098594, 5.1842046) +capture_points = [NodePath("../CapturePoint"), NodePath("../CapturePoint2")] +debug_paint_team_color = true + +[node name="NPCBlue2" parent="." unique_id=131586399 node_paths=PackedStringArray("capture_points") instance=ExtResource("1_p76ow")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.772899, 0.9137349, 4.1546454) +capture_points = [NodePath("../CapturePoint"), NodePath("../CapturePoint2")] +debug_paint_team_color = true + +[node name="CapturePoint" parent="." unique_id=1068191810 instance=ExtResource("2_njkjl")] +transform = Transform3D(0.0013185298, 0, -0.9999991, 0, 1, 0, 0.9999991, 0, 0.0013185298, 4.894, 0, -4.793) + +[node name="CapturePoint2" parent="." unique_id=243842588 instance=ExtResource("2_njkjl")] +transform = Transform3D(0.0013185298, 0, -0.9999991, 0, 1, 0, 0.9999991, 0, 0.0013185298, -4.136633, 0, 3.4616709) + +[node name="NPCRed" parent="." unique_id=342656491 node_paths=PackedStringArray("capture_points") instance=ExtResource("1_p76ow")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4.184131, 1.1782789, -5.9109287) +team = 1 +capture_points = [NodePath("../CapturePoint"), NodePath("../CapturePoint2")] +debug_paint_team_color = true + +[node name="NPC2Red2" parent="." unique_id=654741267 node_paths=PackedStringArray("capture_points") instance=ExtResource("1_p76ow")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6.2768826, 0.89189386, -4.5975275) +team = 1 +capture_points = [NodePath("../CapturePoint"), NodePath("../CapturePoint2")] +debug_paint_team_color = true diff --git a/npc/capture_point.gd b/npc/capture_point.gd new file mode 100644 index 00000000..c6f82b19 --- /dev/null +++ b/npc/capture_point.gd @@ -0,0 +1,61 @@ +extends Node3D +class_name CapturePoint + +## ============================================================ +## Punto de control +## El NPCSoldier ya hace el chequeo de distancia y el conteo de +## tiempo (capture_radius / capture_time) desde su propio script. +## Este script solo: +## - guarda quién es el dueño actual +## - cambia el color del punto para reflejarlo +## - avisa con una señal cuando cambia de dueño +## ============================================================ + +signal captured(team: int) + +@export var capture_time: float = 3.0 # segundos que un NPC debe quedarse para capturarlo + +# Colores por equipo. Índice = valor del enum Team en npc_soldier.gd (BLUE=0, RED=1) +@export var neutral_color: Color = Color(0.6, 0.6, 0.6) +@export var team_colors: Array[Color] = [ + Color(0.2, 0.4, 1.0), # BLUE + Color(1.0, 0.2, 0.2), # RED +] + +@onready var mesh_instance: MeshInstance3D = $MeshInstance3D + +var owner_team: int = -1 # -1 = neutral, nadie lo ha capturado aún +var _material: StandardMaterial3D + + +func _ready() -> void: + # Duplicamos el material para que cada punto tenga el suyo propio. + # Si no hacemos esto, cambiar el color de un punto cambiaría + # el color de TODOS los puntos que compartan el mismo material. + if mesh_instance.get_surface_override_material(0): + _material = mesh_instance.get_surface_override_material(0).duplicate() + else: + _material = StandardMaterial3D.new() + mesh_instance.set_surface_override_material(0, _material) + + _update_color() + + +func capture(team: int) -> void: + if owner_team == team: + return + owner_team = team + _update_color() + captured.emit(team) + + +func reset_point() -> void: + owner_team = -1 + _update_color() + + +func _update_color() -> void: + if owner_team == -1 or owner_team >= team_colors.size(): + _material.albedo_color = neutral_color + else: + _material.albedo_color = team_colors[owner_team] diff --git a/npc/capture_point.gd.uid b/npc/capture_point.gd.uid new file mode 100644 index 00000000..bf370073 --- /dev/null +++ b/npc/capture_point.gd.uid @@ -0,0 +1 @@ +uid://dl67rup3a1eex diff --git a/npc/capture_point.tscn b/npc/capture_point.tscn new file mode 100644 index 00000000..03d6f934 --- /dev/null +++ b/npc/capture_point.tscn @@ -0,0 +1,21 @@ +[gd_scene format=3 uid="uid://bvoha80et32ug"] + +[ext_resource type="Script" uid="uid://dl67rup3a1eex" path="res://npc/capture_point.gd" id="1_qf0cd"] + +[sub_resource type="CylinderMesh" id="CylinderMesh_g4v7s"] +top_radius = 3.0 +bottom_radius = 3.0 +height = 0.01 + +[node name="CapturePoint" type="Node3D" unique_id=1068191810] +script = ExtResource("1_qf0cd") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="." unique_id=1147327543] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.00050002337, 0) +mesh = SubResource("CylinderMesh_g4v7s") + +[node name="Label3D" type="Label3D" parent="." unique_id=1202558673] +offset = Vector2(0, 750) +text = "Point A +" +font_size = 48 diff --git a/npc/npc.tscn b/npc/npc.tscn new file mode 100644 index 00000000..f0cac8e6 --- /dev/null +++ b/npc/npc.tscn @@ -0,0 +1,29 @@ +[gd_scene format=3 uid="uid://dyn4dqnx7qccp"] + +[ext_resource type="Script" uid="uid://dfvd5c2kr48kt" path="res://npc/npcbase.gd" id="1_2xp5l"] + +[sub_resource type="SphereShape3D" id="SphereShape3D_g4v7s"] + +[sub_resource type="SphereMesh" id="SphereMesh_yf5fn"] + +[sub_resource type="SphereShape3D" id="SphereShape3D_57qmd"] +radius = 2.0 + +[node name="NPC" type="CharacterBody3D" unique_id=651721336] +script = ExtResource("1_2xp5l") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=905154456] +shape = SubResource("SphereShape3D_g4v7s") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="." unique_id=1321357786] +mesh = SubResource("SphereMesh_yf5fn") + +[node name="NavigationAgent3D" type="NavigationAgent3D" parent="." unique_id=348100203] + +[node name="VisionArea" type="Area3D" parent="." unique_id=1918785263] + +[node name="CollisionShape3D" type="CollisionShape3D" parent="VisionArea" unique_id=1310666543] +shape = SubResource("SphereShape3D_57qmd") + +[connection signal="body_entered" from="VisionArea" to="." method="_on_vision_area_body_entered"] +[connection signal="body_exited" from="VisionArea" to="." method="_on_vision_area_body_exited"] diff --git a/npc/npcbase.gd b/npc/npcbase.gd new file mode 100644 index 00000000..a77737c7 --- /dev/null +++ b/npc/npcbase.gd @@ -0,0 +1,353 @@ +extends CharacterBody3D +class_name NPCSoldier + +## ============================================================ +## NPC Soldado - Máquina de estados (estilo TABS) +## +## Prioridad de decisión, evaluada cada frame: +## 1) Si hay un NPC enemigo dentro del rango de visión -> atacarlo +## 2) Si no, pero hay un Player enemigo a la vista -> atacarlo +## 3) Si no hay nada que atacar -> correr hacia el punto actual. +## Si no quedan puntos, carga contra el NPC/Player más cercano. +## ============================================================ + +enum Team { BLUE, RED } +enum State { MOVING_TO_POINT, ATTACKING, DEAD } + +# --- Equipo --- +@export_group("Equipo") +@export var team: Team = Team.BLUE + +# --- Movimiento --- +@export_group("Movimiento") +@export var move_speed: float = 4.0 +@export var rotation_speed: float = 8.0 +@export var gravity: float = 20.0 + +# --- Combate --- +@export_group("Combate") +@export var max_health: float = 100.0 +@export var attack_damage: float = 10.0 +@export var attack_range: float = 2.0 +@export var attack_cooldown: float = 1.0 +@export var vision_range: float = 15.0 + +@export_group("Empuje entre aliados") +@export var push_strength: float = 1.5 # qué tan fuerte empuja un aliado bloqueado al que tiene enfrente + +# --- Puntos de captura (arrastra Node3D/Marker3D en el inspector) --- +@export_group("Puntos de captura") +@export var capture_points: Array[Node3D] = [] +@export var capture_radius: float = 1.5 + +# --- Debug --- +@export_group("Debug") +@export var debug_paint_team_color: bool = false # pinta el mesh según el equipo, solo para debug visual +@export var debug_team_colors: Array[Color] = [ + Color(0.2, 0.4, 1.0), # BLUE + Color(1.0, 0.2, 0.2), # RED +] + +# --- Referencias a nodos hijos (ajusta los nombres si difieren en tu escena) --- +@onready var vision_area: Area3D = $VisionArea +@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D +@onready var mesh_instance: MeshInstance3D = $MeshInstance3D + +# --- Estado interno --- +var current_health: float +var current_state: State = State.MOVING_TO_POINT +var target: Node3D = null +var _attack_timer: float = 0.0 +var _bodies_in_range: Array[Node3D] = [] +var _capturing_point: Node3D = null +var _capture_progress: float = 0.0 +var _push_velocity: Vector3 = Vector3.ZERO # empuje recibido de aliados este frame + + +func _ready() -> void: + current_health = max_health + add_to_group("npc") + add_to_group("combatant") + + var col: CollisionShape3D = vision_area.get_node("CollisionShape3D") + if col.shape is SphereShape3D: + col.shape.radius = vision_range + + if debug_paint_team_color: + apply_debug_team_color() + + +func _physics_process(delta: float) -> void: + if current_state == State.DEAD: + return + + if not is_on_floor(): + velocity.y -= gravity * delta + + decide_state() + + match current_state: + State.ATTACKING: + process_attacking(delta) + State.MOVING_TO_POINT: + process_moving_to_point(delta) + + # Aplico el empuje que me hayan dado aliados bloqueados detrás mío + velocity.x += _push_velocity.x + velocity.z += _push_velocity.z + _push_velocity = Vector3.ZERO + + move_and_slide() + + push_blocking_allies() + + +# --------------------------------------------------------- +# DECISIÓN DE ESTADO +# Enemigo NPC y enemigo Player tienen la misma prioridad: +# se ataca al que esté más cerca, sea del tipo que sea. +# --------------------------------------------------------- +func decide_state() -> void: + var enemy := get_nearest_hostile() + if enemy: + target = enemy + current_state = State.ATTACKING + return + + target = null + if current_state == State.ATTACKING: + current_state = State.MOVING_TO_POINT + + +# --------------------------------------------------------- +# ATACAR (persigue si está lejos, ataca si está en rango) +# --------------------------------------------------------- +func process_attacking(delta: float) -> void: + if not is_instance_valid(target): + current_state = State.MOVING_TO_POINT + return + + var dist := global_position.distance_to(target.global_position) + face_target(target.global_position, delta) + + if dist > attack_range: + nav_agent.target_position = target.global_position + move_along_path() + else: + velocity.x = 0 + velocity.z = 0 + _attack_timer -= delta + if _attack_timer <= 0.0: + _attack_timer = attack_cooldown + do_attack(target) + + +func do_attack(t: Node3D) -> void: + if t.has_method("take_damage"): + t.take_damage(attack_damage) + + +# --------------------------------------------------------- +# EMPUJE ENTRE ALIADOS +# Si quedo bloqueado por un aliado (ej: está peleando adelante mío +# y no me deja pasar), le doy un pequeño empujón para abrirme campo. +# --------------------------------------------------------- +func push_blocking_allies() -> void: + for i in get_slide_collision_count(): + var collision := get_slide_collision(i) + var collider := collision.get_collider() + + if not (collider is NPCSoldier): + continue + if collider == self or not collider.is_alive(): + continue + if collider.get_team() != team: + continue # a los enemigos no los empujo, a esos les pego + + # Punto al que intento llegar (mi objetivo de ataque o mi punto + # de captura). IMPORTANTE: uso la posición del objetivo, NO mi + # propia rotación (global_transform.basis) — usar la rotación + # arma un feedback loop con face_target() y por eso giraba solo. + var aim_pos: Vector3 + if is_instance_valid(target): + aim_pos = target.global_position + else: + var point := get_nearest_available_point() + aim_pos = point.global_position if point else (global_position - global_transform.basis.z) + + var forward := aim_pos - global_position + forward.y = 0 + if forward.length() < 0.01: + continue + forward = forward.normalized() + var right := Vector3(-forward.z, 0.0, forward.x) # perpendicular, geométrico, no depende de mi rotación + + # Lado fijo y determinístico por par de IDs: mientras dure el + # bloqueo, siempre empujo para el mismo lado. Nada de basarme + # en la normal de colisión (esa sí es ruidosa entre frames). + var side := 1.0 if get_instance_id() < collider.get_instance_id() else -1.0 + + collider.receive_push(right * side * push_strength) + +func receive_push(amount: Vector3) -> void: + _push_velocity += amount + + +# --------------------------------------------------------- +# CORRER A UN PUNTO +# --------------------------------------------------------- +func process_moving_to_point(delta: float) -> void: + var point := get_nearest_available_point() + + if not point: + # No quedan puntos por capturar. Si hubiera un enemigo visible, + # decide_state() ya nos habría mandado a ATTACKING antes de + # llegar acá, así que no hay nada que hacer más que esperar. + velocity.x = 0 + velocity.z = 0 + return + + var dist := global_position.distance_to(point.global_position) + face_target(point.global_position, delta) + + if dist <= capture_radius: + velocity.x = 0 + velocity.z = 0 + + if _capturing_point != point: + _capturing_point = point + _capture_progress = 0.0 + + _capture_progress += delta + var required_time: float = point.capture_time if "capture_time" in point else 0.0 + + if _capture_progress >= required_time: + on_point_captured(point) + _capturing_point = null + _capture_progress = 0.0 + else: + _capturing_point = null + _capture_progress = 0.0 + nav_agent.target_position = point.global_position + move_along_path() + + +func get_nearest_available_point() -> Node3D: + var nearest: Node3D = null + var nearest_dist := INF + for point in capture_points: + if not is_instance_valid(point): + continue + if "owner_team" in point and point.owner_team == team: + continue # ya es mío, no necesito volver a capturarlo + var d := global_position.distance_to(point.global_position) + if d < nearest_dist: + nearest_dist = d + nearest = point + return nearest + + +func on_point_captured(point: Node3D) -> void: + if point.has_method("capture"): + point.capture(team) + + +func move_along_path() -> void: + if nav_agent.is_navigation_finished(): + velocity.x = 0 + velocity.z = 0 + return + var next_pos := nav_agent.get_next_path_position() + var dir := next_pos - global_position + dir.y = 0 + dir = dir.normalized() + velocity.x = dir.x * move_speed + velocity.z = dir.z * move_speed + + +func face_target(pos: Vector3, delta: float) -> void: + var dir := pos - global_position + dir.y = 0 + if dir.length() < 0.01: + return + var target_rot := atan2(dir.x, dir.z) + rotation.y = lerp_angle(rotation.y, target_rot, rotation_speed * delta) + + +# --------------------------------------------------------- +# DETECCIÓN (Area3D de visión) +# --------------------------------------------------------- +func _on_vision_area_body_entered(body: Node3D) -> void: + if body == self: + return + if body.is_in_group("npc") or body.is_in_group("player"): + _bodies_in_range.append(body) + + +func _on_vision_area_body_exited(body: Node3D) -> void: + _bodies_in_range.erase(body) + + +func get_nearest_hostile() -> Node3D: + var nearest: Node3D = null + var nearest_dist := INF + for body in _bodies_in_range: + if not is_instance_valid(body): + continue + if not is_hostile(body): + continue + if body.has_method("is_alive") and not body.is_alive(): + continue + var d := global_position.distance_to(body.global_position) + if d < nearest_dist: + nearest_dist = d + nearest = body + return nearest + + +func is_hostile(body: Node3D) -> bool: + if body.has_method("get_team"): + return body.get_team() != team + return false + + +func get_team() -> int: + return team + + +# --------------------------------------------------------- +# DEBUG (solo visual, no afecta gameplay) +# --------------------------------------------------------- +func apply_debug_team_color() -> void: + if not mesh_instance: + return + var mat := StandardMaterial3D.new() + if team < debug_team_colors.size(): + mat.albedo_color = debug_team_colors[team] + else: + mat.albedo_color = Color.WHITE + mesh_instance.set_surface_override_material(0, mat) + + +# --------------------------------------------------------- +# VIDA / DAÑO +# --------------------------------------------------------- +func take_damage(amount: float) -> void: + if current_state == State.DEAD: + return + current_health -= amount + if current_health <= 0.0: + die() + + +func is_alive() -> bool: + return current_state != State.DEAD + + +func die() -> void: + current_state = State.DEAD + remove_from_group("npc") + remove_from_group("combatant") + set_physics_process(false) + # Aquí va tu ragdoll/animación de muerte estilo TABS, sonido, etc. + queue_free() # o desactiva la colisión y deja el cuerpo tirado si prefieres el efecto TABS diff --git a/npc/npcbase.gd.uid b/npc/npcbase.gd.uid new file mode 100644 index 00000000..ef1e56df --- /dev/null +++ b/npc/npcbase.gd.uid @@ -0,0 +1 @@ +uid://dfvd5c2kr48kt diff --git a/project.godot b/project.godot index df69d35f..d267857e 100644 --- a/project.godot +++ b/project.godot @@ -14,7 +14,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] -config/name="multiplayer_example" +config/name="MagiaSiege" run/main_scene="res://ui/main_menu.tscn" config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" diff --git a/world/TestTerrenos3D.tscn b/world/TestTerrenos3D.tscn new file mode 100644 index 00000000..b7a4da34 --- /dev/null +++ b/world/TestTerrenos3D.tscn @@ -0,0 +1,3 @@ +[gd_scene format=3 uid="uid://c4ktk4a8dpgly"] + +[node name="Terreno" type="Node3D" unique_id=1619818502]