diff --git a/README.md b/README.md index d239a72..f573a77 100644 --- a/README.md +++ b/README.md @@ -1876,13 +1876,24 @@ Actualizar esta sección conforme avance el proyecto. ## NOW ```text -Pendiente de establecer según el estado real del proyecto. +- Estructura mínima separada en game/, player/, enemies/ y levels/test/. +- Player único con movimiento, salto, coyote time y jump buffer. +- Ataque terrestre y ground pound con hitboxes reales. +- Vida, daño, knockback, invulnerabilidad breve y muerte/reinicio del jugador. +- CharacterData con cuatro recursos: singer, guitarist, bassist y drummer. +- Los cuatro personajes usan provisionalmente las mismas animaciones idle/walk de Diego. +- Primer enemigo Walker: patrulla, gira en paredes/bordes, hace daño por contacto, + recibe daño y muere. +- Hurtboxes y zonas de ataque visibles en modo debug con rojo semitransparente. +- Sala levels/test/combat_test.tscn para probar el combate. ``` ## NEXT ```text -Pendiente. +- Probar y ajustar tamaños/offsets de hurtboxes e hitboxes jugando la sala de combate. +- Sustituir las animaciones provisionales cuando exista arte para cada integrante. +- Añadir UI provisional de vida si hace falta para seguir afinando el combate. ``` ## LATER diff --git a/assets/characters/camera_2d.gd b/assets/characters/camera_2d.gd deleted file mode 100644 index 4fa87b7..0000000 --- a/assets/characters/camera_2d.gd +++ /dev/null @@ -1,14 +0,0 @@ -extends Camera2D - - -# Called when the node enters the scene tree for the first time. -func _ready() -> void: - limit_left = 0 - limit_top = 0 - limit_right = 3000 - limit_bottom = 2000 - - -# Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(delta: float) -> void: - pass diff --git a/enemies/walker/walker.gd b/enemies/walker/walker.gd new file mode 100644 index 0000000..ba30cf9 --- /dev/null +++ b/enemies/walker/walker.gd @@ -0,0 +1,176 @@ +extends CharacterBody2D + + +signal died + + +# First real enemy on purpose: no generic Enemy base class yet. +# Once a second/third enemy exists, we can extract only the behaviour they +# actually share instead of guessing an abstraction in advance. + +const PLAYER_HURTBOX_MASK: int = 1 << 3 + + +@export_category("Movement") +@export var move_speed: float = 85.0 +@export var max_fall_speed: float = 1200.0 + +@export_category("Combat") +@export_range(1, 100, 1) var max_health: int = 3 +@export_range(0, 100, 1) var contact_damage: int = 1 +@export var contact_damage_interval: float = 0.45 + +@export_category("Debug") +## Enabled while the enemy has no final sprite/animation. The grey rectangle is +## the temporary body; red overlays are the gameplay hurtbox/contact hitbox. +@export var show_debug_hitboxes: bool = true + + +@onready var body_collision: CollisionShape2D = $CollisionShape2D +@onready var hurtbox_collision: CollisionShape2D = $Hurtbox/CollisionShape2D +@onready var contact_hitbox: Area2D = $ContactHitbox +@onready var contact_collision: CollisionShape2D = $ContactHitbox/CollisionShape2D +@onready var floor_probe: RayCast2D = $FloorProbe + + +var current_health: int +var move_direction: float = -1.0 +var contact_damage_timer: float = 0.0 +var is_dead: bool = false + + +func _ready() -> void: + current_health = max_health + contact_hitbox.collision_layer = 0 + contact_hitbox.collision_mask = PLAYER_HURTBOX_MASK + contact_hitbox.monitoring = true + _update_floor_probe() + queue_redraw() + + +func _physics_process(delta: float) -> void: + if is_dead: + return + + contact_damage_timer = max(contact_damage_timer - delta, 0.0) + _apply_gravity(delta) + _turn_before_ledge() + + velocity.x = move_direction * move_speed + move_and_slide() + + if is_on_wall(): + _turn_around() + + _damage_player_on_contact() + queue_redraw() + + +func _apply_gravity(delta: float) -> void: + if is_on_floor(): + return + + velocity += get_gravity() * delta + velocity.y = min(velocity.y, max_fall_speed) + + +func _turn_before_ledge() -> void: + # The ray sits just in front of the feet. If it stops finding floor while the + # enemy is grounded, turning here prevents the basic walker from suiciding + # off every platform. + if is_on_floor() and not floor_probe.is_colliding(): + _turn_around() + + +func _turn_around() -> void: + move_direction *= -1.0 + _update_floor_probe() + + +func _update_floor_probe() -> void: + floor_probe.position.x = abs(floor_probe.position.x) * move_direction + + +func _damage_player_on_contact() -> void: + if contact_damage_timer > 0.0: + return + + for area in contact_hitbox.get_overlapping_areas(): + var target: Node = area.get_parent() + if target != null and target.has_method("take_damage"): + target.take_damage(contact_damage, global_position) + contact_damage_timer = contact_damage_interval + return + + +func take_damage(amount: int, source_position: Vector2) -> void: + if amount <= 0 or is_dead: + return + + current_health = max(current_health - amount, 0) + + if current_health == 0: + _die() + return + + # Turn away from the attacker. This gives a tiny readable reaction without + # inventing a hit-stun/knockback system before it is actually needed. + var reaction_direction: float = signf(global_position.x - source_position.x) + if reaction_direction != 0.0: + move_direction = reaction_direction + _update_floor_probe() + + +func _die() -> void: + is_dead = true + died.emit() + queue_free() + + +# ============================================================================ +# TEMPORARY VISUAL + DEBUG HITBOXES +# ============================================================================ + +func _draw() -> void: + # The enemy has no sprite yet. This neutral block makes the actual entity + # visible independently from the red debug overlays. + _draw_rectangle_collision( + body_collision, + Color(0.18, 0.18, 0.20, 0.90), + Color(0.85, 0.85, 0.90, 1.0) + ) + + if not show_debug_hitboxes: + return + + # Hurtbox: where punches / ground pound are allowed to damage the enemy. + _draw_rectangle_collision( + hurtbox_collision, + Color(1.0, 0.05, 0.05, 0.12), + Color(1.0, 0.05, 0.05, 0.55) + ) + + # Contact hitbox: slightly larger, so it is obvious exactly when touching + # the enemy can damage the player. + _draw_rectangle_collision( + contact_collision, + Color(1.0, 0.05, 0.05, 0.18), + Color(1.0, 0.05, 0.05, 0.85), + contact_hitbox.position + ) + + +func _draw_rectangle_collision( + collision: CollisionShape2D, + fill_color: Color, + outline_color: Color, + extra_offset: Vector2 = Vector2.ZERO +) -> void: + var rectangle: RectangleShape2D = collision.shape as RectangleShape2D + if rectangle == null: + return + + var center: Vector2 = collision.position + extra_offset + var rect: Rect2 = Rect2(center - rectangle.size / 2.0, rectangle.size) + draw_rect(rect, fill_color, true) + draw_rect(rect, outline_color, false, 2.0) diff --git a/enemies/walker/walker.gd.uid b/enemies/walker/walker.gd.uid new file mode 100644 index 0000000..0e37d9d --- /dev/null +++ b/enemies/walker/walker.gd.uid @@ -0,0 +1 @@ +uid://dbt03i3eiutv5 diff --git a/enemies/walker/walker.tscn b/enemies/walker/walker.tscn new file mode 100644 index 0000000..1a173ac --- /dev/null +++ b/enemies/walker/walker.tscn @@ -0,0 +1,42 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://enemies/walker/walker.gd" id="1_walker"] + +[sub_resource type="RectangleShape2D" id="BodyShape"] +size = Vector2(42, 78) + +[sub_resource type="RectangleShape2D" id="ContactShape"] +size = Vector2(58, 88) + +[node name="Walker" type="CharacterBody2D"] +collision_layer = 4 +collision_mask = 1 +script = ExtResource("1_walker") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +position = Vector2(0, 0) +shape = SubResource("BodyShape") + +[node name="Hurtbox" type="Area2D" parent="."] +collision_layer = 16 +collision_mask = 0 +monitoring = false +monitorable = true + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Hurtbox"] +shape = SubResource("BodyShape") + +[node name="ContactHitbox" type="Area2D" parent="."] +collision_layer = 0 +collision_mask = 8 +monitoring = true +monitorable = false + +[node name="CollisionShape2D" type="CollisionShape2D" parent="ContactHitbox"] +shape = SubResource("ContactShape") + +[node name="FloorProbe" type="RayCast2D" parent="."] +position = Vector2(24, 34) +target_position = Vector2(0, 34) +collision_mask = 1 +enabled = true diff --git a/game/main.tscn b/game/main.tscn new file mode 100644 index 0000000..f856dc1 --- /dev/null +++ b/game/main.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="PackedScene" path="res://levels/test/combat_test.tscn" id="1_test"] + +[node name="Main" type="Node"] + +[node name="CombatTest" parent="." instance=ExtResource("1_test")] diff --git a/levels/test/combat_test.tscn b/levels/test/combat_test.tscn new file mode 100644 index 0000000..817622d --- /dev/null +++ b/levels/test/combat_test.tscn @@ -0,0 +1,47 @@ +[gd_scene load_steps=6 format=3] + +[ext_resource type="Texture2D" uid="uid://jmadeu351yh6" path="res://assets/photo_5837080277660930029_w.jpg" id="1_background"] +[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] +[ext_resource type="PackedScene" path="res://enemies/walker/walker.tscn" id="3_walker"] + +[sub_resource type="RectangleShape2D" id="FloorShape"] +size = Vector2(1171, 20) + +[sub_resource type="RectangleShape2D" id="WallShape"] +size = Vector2(136, 156) + +[node name="CombatTest" type="Node2D"] + +[node name="Background" type="Sprite2D" parent="."] +position = Vector2(576, 324) +scale = Vector2(0.8181818, 0.84374994) +texture = ExtResource("1_background") + +[node name="World" type="StaticBody2D" parent="."] +collision_layer = 1 +collision_mask = 0 + +[node name="Floor" type="CollisionShape2D" parent="World"] +position = Vector2(585, 465) +shape = SubResource("FloorShape") + +[node name="LeftWall" type="CollisionShape2D" parent="World"] +position = Vector2(77, 361) +shape = SubResource("WallShape") + +[node name="Player" parent="." instance=ExtResource("2_player")] +position = Vector2(95, 215) + +[node name="Walker" parent="." instance=ExtResource("3_walker")] +position = Vector2(650, 390) + +[node name="DebugInfo" type="Label" parent="."] +offset_left = 16.0 +offset_top = 14.0 +offset_right = 560.0 +offset_bottom = 64.0 +theme_override_colors/font_color = Color(1, 1, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 2 +text = "A / D: mover W: saltar J: atacar\nRojo = hurtboxes y zonas de ataque reales" diff --git a/main.tscn b/main.tscn deleted file mode 100644 index d6d402c..0000000 --- a/main.tscn +++ /dev/null @@ -1,30 +0,0 @@ -[gd_scene format=3 uid="uid://dux51kbeg032e"] - -[ext_resource type="Texture2D" uid="uid://jmadeu351yh6" path="res://assets/photo_5837080277660930029_w.jpg" id="1_1bvp3"] -[ext_resource type="PackedScene" uid="uid://nwbjxttgghpq" path="res://assets/characters/ankarde.tscn" id="2_0xm2m"] - -[sub_resource type="RectangleShape2D" id="RectangleShape2D_rcrqh"] -size = Vector2(1171, 20) - -[sub_resource type="RectangleShape2D" id="RectangleShape2D_741h5"] -size = Vector2(136, 156) - -[node name="Main" type="Node2D" unique_id=1908609963] - -[node name="Photo5837080277660930029W" type="Sprite2D" parent="." unique_id=2065196895] -position = Vector2(576, 324) -scale = Vector2(0.8181818, 0.84374994) -texture = ExtResource("1_1bvp3") - -[node name="Limits" type="StaticBody2D" parent="." unique_id=648220000] - -[node name="CollisionShape2D" type="CollisionShape2D" parent="Limits" unique_id=423774102] -position = Vector2(585, 465) -shape = SubResource("RectangleShape2D_rcrqh") - -[node name="CollisionShape2D2" type="CollisionShape2D" parent="Limits" unique_id=296247895] -position = Vector2(77, 361) -shape = SubResource("RectangleShape2D_741h5") - -[node name="Player" parent="." unique_id=309719231 instance=ExtResource("2_0xm2m")] -position = Vector2(95, 215) diff --git a/player/camera_2d.gd b/player/camera_2d.gd new file mode 100644 index 0000000..5208f8f --- /dev/null +++ b/player/camera_2d.gd @@ -0,0 +1,10 @@ +extends Camera2D + + +## Límites provisionales de cámara para la sala de pruebas actual. +## Cuando existan niveles reales, estos límites podrán configurarse por nivel. +func _ready() -> void: + limit_left = 0 + limit_top = 0 + limit_right = 3000 + limit_bottom = 2000 diff --git a/assets/characters/camera_2d.gd.uid b/player/camera_2d.gd.uid similarity index 100% rename from assets/characters/camera_2d.gd.uid rename to player/camera_2d.gd.uid diff --git a/player/character_data.gd b/player/character_data.gd new file mode 100644 index 0000000..26d6b9d --- /dev/null +++ b/player/character_data.gd @@ -0,0 +1,24 @@ +extends Resource +class_name CharacterData + + +## Datos que pueden variar entre los cuatro integrantes de la banda. +## +## La lógica vive en Player. Este recurso solo contiene configuración para que +## cambiar de personaje no obligue a duplicar ni modificar player.gd. + +@export_category("Identity") +@export var id: StringName = &"character" +@export var display_name: String = "Character" + +@export_category("Visuals") +## Los cuatro recursos apuntan por ahora a las mismas animaciones de Diego. +## Más adelante cada integrante podrá tener su propio SpriteFrames sin tocar Player. +@export var sprite_frames: SpriteFrames + +@export_category("Stats") +@export_range(1, 100, 1) var max_health: int = 5 +@export var move_speed: float = 300.0 +@export var jump_velocity: float = -600.0 +@export_range(0, 100, 1) var basic_attack_damage: int = 1 +@export_range(0, 100, 1) var air_attack_damage: int = 3 diff --git a/player/character_data.gd.uid b/player/character_data.gd.uid new file mode 100644 index 0000000..a71a932 --- /dev/null +++ b/player/character_data.gd.uid @@ -0,0 +1 @@ +uid://bi04q2fe5goqn diff --git a/player/characters/bassist.tres b/player/characters/bassist.tres new file mode 100644 index 0000000..6092432 --- /dev/null +++ b/player/characters/bassist.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="CharacterData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://player/character_data.gd" id="1_data"] +[ext_resource type="SpriteFrames" path="res://player/characters/shared/prototype_sprite_frames.tres" id="2_frames"] + +[resource] +script = ExtResource("1_data") +id = &"bassist" +display_name = "Bassist" +sprite_frames = ExtResource("2_frames") +max_health = 5 +move_speed = 300.0 +jump_velocity = -600.0 +basic_attack_damage = 1 +air_attack_damage = 3 diff --git a/player/characters/drummer.tres b/player/characters/drummer.tres new file mode 100644 index 0000000..71e44fb --- /dev/null +++ b/player/characters/drummer.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="CharacterData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://player/character_data.gd" id="1_data"] +[ext_resource type="SpriteFrames" path="res://player/characters/shared/prototype_sprite_frames.tres" id="2_frames"] + +[resource] +script = ExtResource("1_data") +id = &"drummer" +display_name = "Drummer" +sprite_frames = ExtResource("2_frames") +max_health = 5 +move_speed = 300.0 +jump_velocity = -600.0 +basic_attack_damage = 1 +air_attack_damage = 3 diff --git a/player/characters/guitarist.tres b/player/characters/guitarist.tres new file mode 100644 index 0000000..318c31c --- /dev/null +++ b/player/characters/guitarist.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="CharacterData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://player/character_data.gd" id="1_data"] +[ext_resource type="SpriteFrames" path="res://player/characters/shared/prototype_sprite_frames.tres" id="2_frames"] + +[resource] +script = ExtResource("1_data") +id = &"guitarist" +display_name = "Guitarist" +sprite_frames = ExtResource("2_frames") +max_health = 5 +move_speed = 300.0 +jump_velocity = -600.0 +basic_attack_damage = 1 +air_attack_damage = 3 diff --git a/assets/characters/ankarde.tscn b/player/characters/shared/prototype_sprite_frames.tres similarity index 71% rename from assets/characters/ankarde.tscn rename to player/characters/shared/prototype_sprite_frames.tres index c8589db..e3d0f3e 100644 --- a/assets/characters/ankarde.tscn +++ b/player/characters/shared/prototype_sprite_frames.tres @@ -1,146 +1,141 @@ -[gd_scene format=3 uid="uid://nwbjxttgghpq"] +[gd_resource type="SpriteFrames" load_steps=36 format=3] -[ext_resource type="Script" uid="uid://dccfebno1gvh7" path="res://player.gd" id="1_seisg"] -[ext_resource type="Texture2D" uid="uid://cjxrw1p7eh82i" path="res://assets/characters/ankarde/Diego-idle-trimmed.png" id="2_yi62x"] -[ext_resource type="Texture2D" uid="uid://dg1x17ci14p4f" path="res://assets/characters/ankarde/Diego-walk-trimmed.png" id="3_sn41o"] -[ext_resource type="Script" uid="uid://kg3i7b0dj2p8" path="res://assets/characters/camera_2d.gd" id="4_yi62x"] - -[sub_resource type="RectangleShape2D" id="RectangleShape2D_rcrqh"] -size = Vector2(65, 115) +[ext_resource type="Texture2D" uid="uid://cjxrw1p7eh82i" path="res://assets/characters/ankarde/Diego-idle-trimmed.png" id="1_idle"] +[ext_resource type="Texture2D" uid="uid://dg1x17ci14p4f" path="res://assets/characters/ankarde/Diego-walk-trimmed.png" id="2_walk"] [sub_resource type="AtlasTexture" id="AtlasTexture_lquwl"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(0, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_7mycd"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(256, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_272bh"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(512, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_5vw27"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(768, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_kek77"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(1024, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_4c57u"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(0, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_efxa6"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(256, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_dg77c"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(512, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_ycdy4"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(768, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_w48qg"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(1024, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_vivmo"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(0, 512, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_2cqfq"] -atlas = ExtResource("2_yi62x") +atlas = ExtResource("1_idle") region = Rect2(256, 512, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_yaehf"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(0, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_074og"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(256, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_cegan"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(512, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_82xsv"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(768, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_getpj"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(1024, 0, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_ryguw"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(0, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_d13ii"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(256, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_1u8w0"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(512, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_0odxb"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(768, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_lswn8"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(1024, 256, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_a6jrf"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(0, 512, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_xuqvo"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(256, 512, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_qsp4k"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(512, 512, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_kq58d"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(768, 512, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_seu75"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(1024, 512, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_htxhm"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(0, 768, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_jq2sk"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(256, 768, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_4k2k6"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(512, 768, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_0rl1p"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(768, 768, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_ok6jj"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(1024, 768, 256, 256) [sub_resource type="AtlasTexture" id="AtlasTexture_facbu"] -atlas = ExtResource("3_sn41o") +atlas = ExtResource("2_walk") region = Rect2(0, 1024, 256, 256) -[sub_resource type="SpriteFrames" id="SpriteFrames_741h5"] +[resource] animations = [{ "frames": [{ "duration": 1.0, @@ -251,22 +246,3 @@ animations = [{ "name": &"walk", "speed": 5.0 }] - -[node name="Player" type="CharacterBody2D" unique_id=309719231] -script = ExtResource("1_seisg") - -[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1243984711] -position = Vector2(0.5, 5.5) -shape = SubResource("RectangleShape2D_rcrqh") - -[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="." unique_id=21110687] -scale = Vector2(0.80251265, 0.8025128) -sprite_frames = SubResource("SpriteFrames_741h5") -animation = &"walk" -frame_progress = 0.7021592 -speed_scale = 2.0 - -[node name="Camera2D" type="Camera2D" parent="." unique_id=1765495955] -scale = Vector2(0.5, 0.5) -zoom = Vector2(2, 2) -script = ExtResource("4_yi62x") diff --git a/player/characters/singer.tres b/player/characters/singer.tres new file mode 100644 index 0000000..307e8b4 --- /dev/null +++ b/player/characters/singer.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="CharacterData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://player/character_data.gd" id="1_data"] +[ext_resource type="SpriteFrames" path="res://player/characters/shared/prototype_sprite_frames.tres" id="2_frames"] + +[resource] +script = ExtResource("1_data") +id = &"singer" +display_name = "Singer" +sprite_frames = ExtResource("2_frames") +max_health = 5 +move_speed = 300.0 +jump_velocity = -600.0 +basic_attack_damage = 1 +air_attack_damage = 3 diff --git a/player.gd b/player/player.gd similarity index 50% rename from player.gd rename to player/player.gd index 1c9fddf..57a72ed 100644 --- a/player.gd +++ b/player/player.gd @@ -1,57 +1,85 @@ extends CharacterBody2D +signal health_changed(current_health: int, max_health: int) +signal died + + +# Collision layers used by this first playable slice. +# Keeping the numbers here makes the scene setup easier to understand: +# 1 -> world +# 2 -> player body +# 3 -> enemy body +# 4 -> player hurtbox +# 5 -> enemy hurtbox +const ENEMY_HURTBOX_MASK: int = 1 << 4 + + # ============================================================================ -# MOVIMIENTO +# CHARACTER +# ============================================================================ + +@export_category("Character") + +## Resource that identifies the selected band member and contains the stats +## that are allowed to differ between characters. +@export var character_data: CharacterData + + +# ============================================================================ +# MOVEMENT # ============================================================================ @export_category("Movement") -@export var speed: float = 300.0 @export var ground_acceleration: float = 2200.0 @export var ground_friction: float = 2600.0 @export var air_acceleration: float = 1000.0 # ============================================================================ -# SALTO +# JUMP # ============================================================================ @export_category("Jump") -@export var jump_velocity: float = -600.0 - -# Permite saltar ligeramente después de abandonar el suelo. +## Allows a jump a few milliseconds after walking off a ledge. @export var coyote_time: float = 0.12 -# Permite pulsar salto ligeramente antes de tocar el suelo. +## Remembers an early jump press just before landing. @export var jump_buffer_time: float = 0.12 -# Al soltar el botón durante la subida reducimos la velocidad vertical. -# Cuanto más pequeño, más brusco será el salto corto. +## Releasing jump while rising cuts the jump short. @export_range(0.1, 1.0) var jump_cut_multiplier: float = 0.45 -# La gravedad normal durante la subida. @export var gravity_multiplier: float = 1.0 - -# La caída es más rápida que la subida. @export var fall_gravity_multiplier: float = 1.7 - @export var max_fall_speed: float = 1200.0 # ============================================================================ -# PUÑETAZO +# DAMAGE RECEIVED +# ============================================================================ + +@export_category("Damage received") + +## Short grace period so a contact hitbox cannot drain all health in one frame. +@export var invulnerability_duration: float = 0.65 + +## Horizontal and vertical impulse applied when the player is hit. +@export var damage_knockback: Vector2 = Vector2(300.0, 260.0) + + +# ============================================================================ +# PUNCH # ============================================================================ @export_category("Punch") -@export var punch_damage: int = 1 @export var punch_duration: float = 0.13 @export var punch_cooldown: float = 0.28 - -@export var punch_hitbox_size := Vector2(55.0, 38.0) -@export var punch_offset := Vector2(38.0, 0.0) +@export var punch_hitbox_size: Vector2 = Vector2(55.0, 38.0) +@export var punch_offset: Vector2 = Vector2(48.0, 0.0) # ============================================================================ @@ -60,19 +88,17 @@ extends CharacterBody2D @export_category("Ground Pound") -@export var ground_pound_damage: int = 3 - -# Velocidad mínima que adquiere al comenzar el golpe hacia abajo. +## Minimum downward speed when the air attack starts. @export var ground_pound_start_speed: float = 450.0 -# Gravedad adicional mientras cae haciendo ground pound. +## Extra gravity while performing the air attack. @export var ground_pound_gravity_multiplier: float = 4.5 -# Control horizontal mientras cae. +## Small amount of steering kept during the fall. @export_range(0.0, 1.0) var ground_pound_horizontal_control: float = 0.25 @export var ground_pound_radius: float = 85.0 -@export var ground_pound_offset := Vector2(0.0, 20.0) +@export var ground_pound_offset: Vector2 = Vector2(0.0, 28.0) # ============================================================================ @@ -81,121 +107,122 @@ extends CharacterBody2D @export_category("Debug") -@export var show_attack_hitboxes: bool = true +## Draws the actual hurtbox and attack areas used by gameplay. +## It is deliberately enabled while the final animations do not exist. +@export var show_debug_hitboxes: bool = true + +## Keeps the ground-pound area visible briefly after impact. @export var debug_impact_duration: float = 0.18 # ============================================================================ -# NODOS +# NODES # ============================================================================ @onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D - +@onready var hurtbox_collision: CollisionShape2D = $Hurtbox/CollisionShape2D @onready var punch_hitbox: Area2D = $PunchHitbox @onready var punch_collision: CollisionShape2D = $PunchHitbox/CollisionShape2D - @onready var ground_pound_hitbox: Area2D = $GroundPoundHitbox -@onready var ground_pound_collision: CollisionShape2D = \ - $GroundPoundHitbox/CollisionShape2D +@onready var ground_pound_collision: CollisionShape2D = $GroundPoundHitbox/CollisionShape2D # ============================================================================ -# ESTADO INTERNO +# RUNTIME STATE # ============================================================================ +var current_health: int = 1 var facing_direction: float = 1.0 var coyote_timer: float = 0.0 var jump_buffer_timer: float = 0.0 +var invulnerability_timer: float = 0.0 var punch_timer: float = 0.0 var attack_cooldown_timer: float = 0.0 var ground_pound_active: bool = false var ground_pound_debug_timer: float = 0.0 +var is_dead: bool = false -# Evita que un enemigo reciba daño 60 veces durante el mismo puñetazo. +## Prevents one target from receiving damage every physics frame during the +## same punch. A new punch starts with an empty set again. var punch_hit_targets: Dictionary = {} func _ready() -> void: - _setup_hitboxes() + if character_data == null: + push_error("Player needs a CharacterData resource.") + return + + current_health = character_data.max_health + if character_data.sprite_frames != null: + animated_sprite.sprite_frames = character_data.sprite_frames + + _setup_attack_hitboxes() + _update_facing() + _update_animation() + health_changed.emit(current_health, character_data.max_health) func _physics_process(delta: float) -> void: - var was_on_floor := is_on_floor() + if character_data == null or is_dead: + return + + var was_on_floor: bool = is_on_floor() _update_timers(delta) _update_jump_windows(delta) - _handle_attack_input() _handle_jump() _apply_gravity(delta) _handle_horizontal_movement(delta) - _update_punch_damage() - _update_animation() move_and_slide() - # El impacto debe comprobarse DESPUÉS de move_and_slide(), - # porque es entonces cuando sabemos si hemos aterrizado. + # Landing is only reliable after move_and_slide() has resolved collisions. if ground_pound_active and not was_on_floor and is_on_floor(): _ground_pound_impact() + _update_animation() queue_redraw() # ============================================================================ -# MOVIMIENTO +# MOVEMENT # ============================================================================ func _handle_horizontal_movement(delta: float) -> void: - var direction := Input.get_axis("move_left", "move_right") + var direction: float = Input.get_axis("move_left", "move_right") if direction != 0.0: - facing_direction = sign(direction) + facing_direction = signf(direction) _update_facing() - if ground_pound_active: - var target_speed := ( - direction - * speed - * ground_pound_horizontal_control - ) + var target_speed: float = direction * character_data.move_speed + if ground_pound_active: + target_speed *= ground_pound_horizontal_control velocity.x = move_toward( velocity.x, target_speed, air_acceleration * delta ) - return if is_on_floor(): - if direction != 0.0: - velocity.x = move_toward( - velocity.x, - direction * speed, - ground_acceleration * delta - ) - else: - velocity.x = move_toward( - velocity.x, - 0.0, - ground_friction * delta - ) + var acceleration: float = ground_acceleration if direction != 0.0 else ground_friction + velocity.x = move_toward(velocity.x, target_speed, acceleration * delta) else: - velocity.x = move_toward( - velocity.x, - direction * speed, - air_acceleration * delta - ) + velocity.x = move_toward(velocity.x, target_speed, air_acceleration * delta) func _update_facing() -> void: animated_sprite.flip_h = facing_direction < 0.0 + # Only the horizontal offset changes when turning around. The hitbox size + # stays identical for both directions. punch_hitbox.position = Vector2( abs(punch_offset.x) * facing_direction, punch_offset.y @@ -203,37 +230,25 @@ func _update_facing() -> void: # ============================================================================ -# GRAVEDAD +# GRAVITY AND JUMP # ============================================================================ func _apply_gravity(delta: float) -> void: if is_on_floor(): return - var gravity := get_gravity() + var gravity: Vector2 = get_gravity() + var multiplier: float = gravity_multiplier if ground_pound_active: - velocity += ( - gravity - * ground_pound_gravity_multiplier - * delta - ) - + multiplier = ground_pound_gravity_multiplier elif velocity.y > 0.0: - # Más gravedad al caer. - velocity += gravity * fall_gravity_multiplier * delta - - else: - # Gravedad normal durante la subida. - velocity += gravity * gravity_multiplier * delta + multiplier = fall_gravity_multiplier + velocity += gravity * multiplier * delta velocity.y = min(velocity.y, max_fall_speed) -# ============================================================================ -# SALTO -# ============================================================================ - func _update_jump_windows(delta: float) -> void: if is_on_floor(): coyote_timer = coyote_time @@ -243,10 +258,7 @@ func _update_jump_windows(delta: float) -> void: if Input.is_action_just_pressed("jump"): jump_buffer_timer = jump_buffer_time else: - jump_buffer_timer = max( - jump_buffer_timer - delta, - 0.0 - ) + jump_buffer_timer = max(jump_buffer_timer - delta, 0.0) func _handle_jump() -> void: @@ -255,27 +267,16 @@ func _handle_jump() -> void: and coyote_timer > 0.0 and not ground_pound_active ): - velocity.y = jump_velocity - + velocity.y = character_data.jump_velocity jump_buffer_timer = 0.0 coyote_timer = 0.0 - # Salto variable. - # - # Mantienes pulsado: - # salto alto - # - # Sueltas pronto: - # salto corto - if ( - Input.is_action_just_released("jump") - and velocity.y < 0.0 - ): + if Input.is_action_just_released("jump") and velocity.y < 0.0: velocity.y *= jump_cut_multiplier # ============================================================================ -# ATAQUE +# ATTACK INPUT # ============================================================================ func _handle_attack_input() -> void: @@ -292,13 +293,12 @@ func _handle_attack_input() -> void: # ============================================================================ -# PUÑETAZO +# PUNCH # ============================================================================ func _start_punch() -> void: punch_timer = punch_duration attack_cooldown_timer = punch_cooldown - punch_hit_targets.clear() @@ -306,19 +306,8 @@ func _update_punch_damage() -> void: if punch_timer <= 0.0: return - for body in punch_hitbox.get_overlapping_bodies(): - _damage_target( - body, - punch_damage, - punch_hit_targets - ) - for area in punch_hitbox.get_overlapping_areas(): - _damage_target( - area, - punch_damage, - punch_hit_targets - ) + _damage_target(area, character_data.basic_attack_damage, punch_hit_targets) # ============================================================================ @@ -332,11 +321,9 @@ func _start_ground_pound() -> void: ground_pound_active = true attack_cooldown_timer = punch_cooldown - # Si todavía estamos subiendo, cortamos inmediatamente la subida. - velocity.y = max( - velocity.y, - ground_pound_start_speed - ) + # If the player is still rising, the attack immediately turns the movement + # into a fall instead of waiting for the jump apex. + velocity.y = max(velocity.y, ground_pound_start_speed) func _ground_pound_impact() -> void: @@ -345,174 +332,166 @@ func _ground_pound_impact() -> void: var hit_targets: Dictionary = {} - for body in ground_pound_hitbox.get_overlapping_bodies(): - _damage_target( - body, - ground_pound_damage, - hit_targets - ) - for area in ground_pound_hitbox.get_overlapping_areas(): - _damage_target( - area, - ground_pound_damage, - hit_targets - ) + _damage_target(area, character_data.air_attack_damage, hit_targets) # ============================================================================ -# DAÑO +# DEALING AND RECEIVING DAMAGE # ============================================================================ -func _damage_target( - collider: Node, - damage: int, - hit_targets: Dictionary -) -> void: - if collider == self: +func _damage_target(collider: Node, damage: int, hit_targets: Dictionary) -> void: + var target: Node = collider + + # Hurtboxes are Area2D children. Damage belongs to the entity that owns the + # hurtbox, so we ask the parent for take_damage(). + if not target.has_method("take_damage"): + target = target.get_parent() + + if target == null or not target.has_method("take_damage"): return - var target := collider - - # Esto permite que el enemigo tenga una Hurtbox Area2D. - if not target.has_method("take_damage"): - var parent := target.get_parent() - - if parent != null and parent.has_method("take_damage"): - target = parent - - if not target.has_method("take_damage"): - return - - var target_id := target.get_instance_id() - + var target_id: int = target.get_instance_id() if hit_targets.has(target_id): return hit_targets[target_id] = true + target.take_damage(damage, global_position) - target.take_damage( - damage, - global_position - ) + +func take_damage(amount: int, source_position: Vector2) -> void: + if amount <= 0 or is_dead or invulnerability_timer > 0.0: + return + + current_health = max(current_health - amount, 0) + invulnerability_timer = invulnerability_duration + + # Knock the player away from the source. If both centers are aligned, use + # the opposite of the current facing direction as a deterministic fallback. + var knockback_direction: float = signf(global_position.x - source_position.x) + if knockback_direction == 0.0: + knockback_direction = -facing_direction + + velocity.x = knockback_direction * damage_knockback.x + velocity.y = -damage_knockback.y + + health_changed.emit(current_health, character_data.max_health) + + if current_health == 0: + _die() + + +func _die() -> void: + is_dead = true + died.emit() + + # Reloading from a deferred call avoids changing the scene tree while a + # physics overlap is still being processed. + call_deferred("_reload_level") + + +func _reload_level() -> void: + get_tree().reload_current_scene() # ============================================================================ -# TIMERS +# TIMERS AND ANIMATION # ============================================================================ func _update_timers(delta: float) -> void: - punch_timer = max( - punch_timer - delta, - 0.0 - ) + punch_timer = max(punch_timer - delta, 0.0) + attack_cooldown_timer = max(attack_cooldown_timer - delta, 0.0) + ground_pound_debug_timer = max(ground_pound_debug_timer - delta, 0.0) + invulnerability_timer = max(invulnerability_timer - delta, 0.0) - attack_cooldown_timer = max( - attack_cooldown_timer - delta, - 0.0 - ) - - ground_pound_debug_timer = max( - ground_pound_debug_timer - delta, - 0.0 - ) - - -# ============================================================================ -# ANIMACIONES -# ============================================================================ func _update_animation() -> void: - if ground_pound_active: + # There are only idle and walk animations at the moment. Until proper jump + # and attack animations arrive, walk is used only for horizontal walking and + # idle is the safe fallback for every other state. + if is_on_floor() and abs(velocity.x) > 10.0: animated_sprite.play("walk") - - elif not is_on_floor(): - animated_sprite.play("walk") - - elif abs(velocity.x) > 10.0: - animated_sprite.play("walk") - else: animated_sprite.play("idle") # ============================================================================ -# CONFIGURACIÓN DE HITBOX +# HITBOX SETUP # ============================================================================ -func _setup_hitboxes() -> void: - # Puñetazo - var punch_shape := RectangleShape2D.new() +func _setup_attack_hitboxes() -> void: + var punch_shape: RectangleShape2D = RectangleShape2D.new() punch_shape.size = punch_hitbox_size - punch_collision.shape = punch_shape - punch_hitbox.collision_layer = 0 - punch_hitbox.collision_mask = 2 + punch_hitbox.collision_mask = ENEMY_HURTBOX_MASK punch_hitbox.monitoring = true - # Ground pound - var pound_shape := CircleShape2D.new() + var pound_shape: CircleShape2D = CircleShape2D.new() pound_shape.radius = ground_pound_radius - ground_pound_collision.shape = pound_shape - ground_pound_hitbox.position = ground_pound_offset - ground_pound_hitbox.collision_layer = 0 - ground_pound_hitbox.collision_mask = 2 + ground_pound_hitbox.collision_mask = ENEMY_HURTBOX_MASK ground_pound_hitbox.monitoring = true - _update_facing() - # ============================================================================ -# DEBUG HITBOXES +# DEBUG HITBOX DRAWING # ============================================================================ func _draw() -> void: - if not show_attack_hitboxes: + if not show_debug_hitboxes: return - # Puñetazo + # Player hurtbox: always visible and deliberately subtle. + _draw_rectangle_collision( + hurtbox_collision, + Color(1.0, 0.08, 0.08, 0.10), + Color(1.0, 0.08, 0.08, 0.45) + ) + + # Punch: only visible while it can actually deal damage. if punch_timer > 0.0: - var center := punch_hitbox.position - - var rect := Rect2( - center - punch_hitbox_size / 2.0, - punch_hitbox_size + _draw_rectangle_collision( + punch_collision, + Color(1.0, 0.05, 0.05, 0.25), + Color(1.0, 0.05, 0.05, 0.95), + punch_hitbox.position ) - draw_rect( - rect, - Color(1.0, 0.1, 0.1, 0.25), - true - ) - - draw_rect( - rect, - Color(1.0, 0.1, 0.1, 0.9), - false, - 2.0 - ) - - # Ground pound: - # solamente mostramos el área cuando realmente produce daño. - if ground_pound_debug_timer > 0.0: - var center := ground_pound_hitbox.position - + # Ground pound: show the real damage radius throughout the fall, then make + # it stronger for a few frames at the exact impact moment. + if ground_pound_active or ground_pound_debug_timer > 0.0: + var fill_alpha: float = 0.14 if ground_pound_active else 0.28 + var outline_alpha: float = 0.60 if ground_pound_active else 0.95 draw_circle( - center, + ground_pound_hitbox.position, ground_pound_radius, - Color(1.0, 0.5, 0.0, 0.20) + Color(1.0, 0.18, 0.05, fill_alpha) ) - draw_arc( - center, + ground_pound_hitbox.position, ground_pound_radius, 0.0, TAU, 48, - Color(1.0, 0.5, 0.0, 0.9), - 3.0 + Color(1.0, 0.18, 0.05, outline_alpha), + 2.0 ) + + +func _draw_rectangle_collision( + collision: CollisionShape2D, + fill_color: Color, + outline_color: Color, + extra_offset: Vector2 = Vector2.ZERO +) -> void: + var rectangle: RectangleShape2D = collision.shape as RectangleShape2D + if rectangle == null: + return + + var center: Vector2 = collision.position + extra_offset + var rect: Rect2 = Rect2(center - rectangle.size / 2.0, rectangle.size) + draw_rect(rect, fill_color, true) + draw_rect(rect, outline_color, false, 2.0) diff --git a/player.gd.uid b/player/player.gd.uid similarity index 100% rename from player.gd.uid rename to player/player.gd.uid diff --git a/player/player.tscn b/player/player.tscn new file mode 100644 index 0000000..0a7b92b --- /dev/null +++ b/player/player.tscn @@ -0,0 +1,58 @@ +[gd_scene load_steps=6 format=3 uid="uid://nwbjxttgghpq"] + +[ext_resource type="Script" uid="uid://dccfebno1gvh7" path="res://player/player.gd" id="1_player"] +[ext_resource type="SpriteFrames" path="res://player/characters/shared/prototype_sprite_frames.tres" id="2_frames"] +[ext_resource type="Script" uid="uid://kg3i7b0dj2p8" path="res://player/camera_2d.gd" id="3_camera"] +[ext_resource type="Resource" path="res://player/characters/singer.tres" id="4_character"] + +[sub_resource type="RectangleShape2D" id="PlayerShape"] +size = Vector2(65, 115) + +[node name="Player" type="CharacterBody2D"] +z_index = 1 +collision_layer = 2 +collision_mask = 1 +script = ExtResource("1_player") +character_data = ExtResource("4_character") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +position = Vector2(0.5, 5.5) +shape = SubResource("PlayerShape") + +[node name="Hurtbox" type="Area2D" parent="."] +collision_layer = 8 +collision_mask = 0 +monitoring = false +monitorable = true + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Hurtbox"] +position = Vector2(0.5, 5.5) +shape = SubResource("PlayerShape") + +[node name="PunchHitbox" type="Area2D" parent="."] +collision_layer = 0 +collision_mask = 16 +monitoring = true +monitorable = false + +[node name="CollisionShape2D" type="CollisionShape2D" parent="PunchHitbox"] + +[node name="GroundPoundHitbox" type="Area2D" parent="."] +collision_layer = 0 +collision_mask = 16 +monitoring = true +monitorable = false + +[node name="CollisionShape2D" type="CollisionShape2D" parent="GroundPoundHitbox"] + +[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."] +z_index = -1 +scale = Vector2(0.80251265, 0.8025128) +sprite_frames = ExtResource("2_frames") +animation = &"idle" +speed_scale = 2.0 + +[node name="Camera2D" type="Camera2D" parent="."] +scale = Vector2(0.5, 0.5) +zoom = Vector2(2, 2) +script = ExtResource("3_camera") diff --git a/project.godot b/project.godot index 7d32197..ceac9d1 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="Mortal Maze: The Game" -run/main_scene="uid://dux51kbeg032e" +run/main_scene="res://game/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://icon.svg" @@ -48,6 +48,14 @@ attack={ ] } +[layer_names] + +2d_physics/layer_1="World" +2d_physics/layer_2="PlayerBody" +2d_physics/layer_3="EnemyBody" +2d_physics/layer_4="PlayerHurtbox" +2d_physics/layer_5="EnemyHurtbox" + [physics] 3d/physics_engine="Jolt Physics"