Mejoras en el manejo del personaje, enemigo de pruebas

This commit is contained in:
ankdev committed 2026-10-01 16:39:07 +02:00
1 parent 1b797a3e90
commit e93efc533b
21 files changed
+774 -85

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
# Godot 4+ specific ignores # Godot 4+ specific ignores
.godot/ .godot/
/android/ /android/
# Local export artifacts
build/
+4
View File
@@ -1886,6 +1886,9 @@ Actualizar esta sección conforme avance el proyecto.
recibe daño y muere. recibe daño y muere.
- Hurtboxes y zonas de ataque visibles en modo debug con rojo semitransparente. - Hurtboxes y zonas de ataque visibles en modo debug con rojo semitransparente.
- Sala levels/test/combat_test.tscn para probar el combate. - Sala levels/test/combat_test.tscn para probar el combate.
- Controles táctiles provisionales para Android usando las mismas acciones del InputMap.
- Preset Android preparado para generar un APK arm64 de pruebas.
- Script scripts/export_android_debug.sh para exportar el APK desde un servidor sin GUI.
``` ```
## NEXT ## NEXT
@@ -1893,6 +1896,7 @@ Actualizar esta sección conforme avance el proyecto.
```text ```text
- Probar y ajustar tamaños/offsets de hurtboxes e hitboxes jugando la sala de combate. - 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. - Sustituir las animaciones provisionales cuando exista arte para cada integrante.
- Probar tamaños y colocación de los controles táctiles en varios teléfonos.
- Añadir UI provisional de vida si hace falta para seguir afinando el combate. - Añadir UI provisional de vida si hace falta para seguir afinando el combate.
``` ```
+400 -58
View File
@@ -11,40 +11,136 @@ signal died
const PLAYER_HURTBOX_MASK: int = 1 << 3 const PLAYER_HURTBOX_MASK: int = 1 << 3
enum AttackState {
IDLE,
WINDUP,
ACTIVE,
RECOVERY,
}
# ============================================================================
# MOVEMENT
# ============================================================================
@export_category("Movement") @export_category("Movement")
@export var move_speed: float = 85.0 @export var move_speed: float = 85.0
@export var max_fall_speed: float = 1200.0 @export var max_fall_speed: float = 1200.0
# ============================================================================
# COMBAT
# ============================================================================
@export_category("Combat") @export_category("Combat")
@export_range(1, 100, 1) var max_health: int = 3 @export_range(1, 100, 1) var max_health: int = 3
@export_range(0, 100, 1) var contact_damage: int = 1 @export_range(0, 100, 1) var attack_damage: int = 1
@export var contact_damage_interval: float = 0.45
## Minimum time between the beginning of two attacks.
@export var attack_interval: float = 2.0
## Horizontal distance at which the Walker stops chasing and prepares a hit.
## This is intentionally wider than the final hitbox so entering range does not
## mean taking unavoidable damage.
@export var attack_trigger_distance: float = 125.0
## Keeps the Walker from starting a ground attack against a player far above or
## below it.
@export var attack_vertical_tolerance: float = 70.0
## Readable pause before the dangerous part of the attack starts. During this
## time the direction is locked, so moving behind the enemy is a valid dodge.
@export var attack_windup_duration: float = 0.35
## Duration of the actual hit. The hitbox sweeps forward during this window.
@export var attack_duration: float = 0.22
## Small recovery after the hit before the enemy can move again.
@export var attack_recovery_duration: float = 0.30
## Where the hitbox starts, relative to the enemy while facing right.
@export var attack_start_offset: Vector2 = Vector2(34.0, 0.0)
## Furthest horizontal position reached by the moving hitbox.
@export var attack_reach: float = 105.0
## A successful player hit briefly interrupts the Walker. This makes attacking
## during its windup useful instead of forcing an unavoidable damage trade.
@export var hit_stun_duration: float = 0.18
# ============================================================================
# RESPAWN
# ============================================================================
@export_category("Respawn")
## Enough downtime to make defeating an enemy meaningful before it comes back.
@export var respawn_delay: float = 5.0
# ============================================================================
# DEBUG
# ============================================================================
@export_category("Debug") @export_category("Debug")
## Enabled while the enemy has no final sprite/animation. The grey rectangle is ## Enabled while the enemy has no final sprite/animation. The grey rectangle is
## the temporary body; red overlays are the gameplay hurtbox/contact hitbox. ## the temporary body. Red overlays are the hurtbox and active attack hitbox.
## The orange area during windup previews the complete attack sweep.
@export var show_debug_hitboxes: bool = true @export var show_debug_hitboxes: bool = true
# ============================================================================
# NODES
# ============================================================================
@onready var body_collision: CollisionShape2D = $CollisionShape2D @onready var body_collision: CollisionShape2D = $CollisionShape2D
@onready var hurtbox_collision: CollisionShape2D = $Hurtbox/CollisionShape2D @onready var hurtbox_collision: CollisionShape2D = $Hurtbox/CollisionShape2D
@onready var contact_hitbox: Area2D = $ContactHitbox @onready var attack_hitbox: Area2D = $AttackHitbox
@onready var contact_collision: CollisionShape2D = $ContactHitbox/CollisionShape2D @onready var attack_collision: CollisionShape2D = $AttackHitbox/CollisionShape2D
@onready var floor_probe: RayCast2D = $FloorProbe @onready var floor_probe: RayCast2D = $FloorProbe
@onready var visibility_notifier: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D
# ============================================================================
# RUNTIME STATE
# ============================================================================
var current_health: int var current_health: int
var move_direction: float = -1.0 var facing_direction: float = -1.0
var contact_damage_timer: float = 0.0
var attack_state: int = AttackState.IDLE
var attack_phase_timer: float = 0.0
var attack_cooldown_timer: float = 0.0
var attack_direction: float = -1.0
var hit_stun_timer: float = 0.0
var is_awake: bool = false
var is_dead: bool = false var is_dead: bool = false
var spawn_position: Vector2
var target: Node2D
## Prevents one sweep from damaging the same target once per physics frame.
var attack_hit_targets: Dictionary = {}
func _ready() -> void: func _ready() -> void:
spawn_position = global_position
current_health = max_health current_health = max_health
contact_hitbox.collision_layer = 0 target = get_tree().get_first_node_in_group(&"player") as Node2D
contact_hitbox.collision_mask = PLAYER_HURTBOX_MASK
contact_hitbox.monitoring = true attack_hitbox.collision_layer = 0
_update_floor_probe() attack_hitbox.collision_mask = PLAYER_HURTBOX_MASK
attack_hitbox.monitoring = true
visibility_notifier.screen_entered.connect(_wake_up)
_update_facing()
_reset_attack_hitbox_position()
queue_redraw() queue_redraw()
@@ -52,20 +148,80 @@ func _physics_process(delta: float) -> void:
if is_dead: if is_dead:
return return
contact_damage_timer = max(contact_damage_timer - delta, 0.0) _update_timers(delta)
_apply_gravity(delta) _apply_gravity(delta)
_turn_before_ledge()
velocity.x = move_direction * move_speed # The enemy deliberately does nothing until the camera has shown it once.
# After waking up it stays active, even if the player later runs away from
# the screen area where the enemy was first seen.
if not is_awake:
velocity.x = 0.0
move_and_slide()
queue_redraw()
return
_acquire_target_if_needed()
if target == null:
velocity.x = 0.0
move_and_slide()
queue_redraw()
return
# Being hit pauses both movement and attack for only a few frames. The
# cooldown keeps running, so interrupting an attack does not make the enemy
# immediately start another one.
if hit_stun_timer > 0.0:
velocity.x = 0.0
move_and_slide()
queue_redraw()
return
if attack_state == AttackState.IDLE:
_update_idle_behaviour()
else:
_update_attack(delta)
move_and_slide() move_and_slide()
if is_on_wall():
_turn_around()
_damage_player_on_contact()
queue_redraw() queue_redraw()
# ============================================================================
# ACTIVATION AND TARGETING
# ============================================================================
func _wake_up() -> void:
is_awake = true
func _acquire_target_if_needed() -> void:
if target != null and is_instance_valid(target):
return
target = get_tree().get_first_node_in_group(&"player") as Node2D
func _face_target() -> void:
var direction_to_target: float = signf(target.global_position.x - global_position.x)
if direction_to_target == 0.0 or direction_to_target == facing_direction:
return
facing_direction = direction_to_target
_update_facing()
func _update_facing() -> void:
# The ledge probe follows the current movement direction. Attack direction is
# stored separately once a windup starts so the enemy cannot rotate mid-hit.
floor_probe.position.x = abs(floor_probe.position.x) * facing_direction
if attack_state == AttackState.IDLE:
_reset_attack_hitbox_position()
# ============================================================================
# MOVEMENT
# ============================================================================
func _apply_gravity(delta: float) -> void: func _apply_gravity(delta: float) -> void:
if is_on_floor(): if is_on_floor():
return return
@@ -74,35 +230,158 @@ func _apply_gravity(delta: float) -> void:
velocity.y = min(velocity.y, max_fall_speed) velocity.y = min(velocity.y, max_fall_speed)
func _turn_before_ledge() -> void: func _update_idle_behaviour() -> void:
# The ray sits just in front of the feet. If it stops finding floor while the _face_target()
# 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()
if attack_cooldown_timer <= 0.0 and _is_target_in_attack_trigger():
func _turn_around() -> void: _start_attack()
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 return
for area in contact_hitbox.get_overlapping_areas(): _chase_target()
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 _chase_target() -> void:
# Do not blindly walk off a platform while chasing. Proper pathfinding is
# intentionally postponed until levels actually require it.
if is_on_floor() and not floor_probe.is_colliding():
velocity.x = 0.0
return
if is_on_wall():
velocity.x = 0.0
return
velocity.x = facing_direction * move_speed
# ============================================================================
# ATTACK
# ============================================================================
func _is_target_in_attack_trigger() -> bool:
var offset_to_target: Vector2 = target.global_position - global_position
return (
abs(offset_to_target.x) <= attack_trigger_distance
and abs(offset_to_target.y) <= attack_vertical_tolerance
)
func _start_attack() -> void:
# Lock the direction here. During windup/active frames the player can dodge
# behind the enemy without the hitbox snapping around to follow them.
_face_target()
attack_direction = facing_direction
attack_state = AttackState.WINDUP
attack_phase_timer = attack_windup_duration
attack_cooldown_timer = attack_interval
attack_hit_targets.clear()
velocity.x = 0.0
_reset_attack_hitbox_position()
func _update_attack(delta: float) -> void:
velocity.x = 0.0
match attack_state:
AttackState.WINDUP:
_update_attack_windup(delta)
AttackState.ACTIVE:
_update_active_attack(delta)
AttackState.RECOVERY:
_update_attack_recovery(delta)
func _update_attack_windup(delta: float) -> void:
attack_phase_timer = max(attack_phase_timer - delta, 0.0)
if attack_phase_timer > 0.0:
return
attack_state = AttackState.ACTIVE
attack_phase_timer = attack_duration
_set_attack_hitbox_progress(0.0)
func _update_active_attack(delta: float) -> void:
attack_phase_timer = max(attack_phase_timer - delta, 0.0)
# Sweep the actual physics hitbox from close to the enemy towards its maximum
# reach. This gives the player time to jump, back away or move behind it.
var progress: float = 1.0
if attack_duration > 0.0:
progress = 1.0 - (attack_phase_timer / attack_duration)
_set_attack_hitbox_progress(clampf(progress, 0.0, 1.0))
_update_attack_damage()
if attack_phase_timer > 0.0:
return
attack_state = AttackState.RECOVERY
attack_phase_timer = attack_recovery_duration
func _update_attack_recovery(delta: float) -> void:
attack_phase_timer = max(attack_phase_timer - delta, 0.0)
if attack_phase_timer > 0.0:
return
attack_state = AttackState.IDLE
attack_hit_targets.clear()
_reset_attack_hitbox_position()
func _set_attack_hitbox_progress(progress: float) -> void:
var start_x: float = abs(attack_start_offset.x)
var current_x: float = lerpf(start_x, attack_reach, progress)
attack_hitbox.position = Vector2(
current_x * attack_direction,
attack_start_offset.y
)
func _reset_attack_hitbox_position() -> void:
var direction: float = attack_direction
if attack_state == AttackState.IDLE:
direction = facing_direction
attack_hitbox.position = Vector2(
abs(attack_start_offset.x) * direction,
attack_start_offset.y
)
func _update_attack_damage() -> void:
for area in attack_hitbox.get_overlapping_areas():
var damage_target: Node = area.get_parent()
if damage_target == null or not damage_target.has_method("take_damage"):
continue
var target_id: int = damage_target.get_instance_id()
if attack_hit_targets.has(target_id):
continue
attack_hit_targets[target_id] = true
damage_target.take_damage(attack_damage, attack_hitbox.global_position)
func _interrupt_attack() -> void:
if attack_state == AttackState.IDLE:
return
attack_state = AttackState.IDLE
attack_phase_timer = 0.0
attack_hit_targets.clear()
_reset_attack_hitbox_position()
func _update_timers(delta: float) -> void:
attack_cooldown_timer = max(attack_cooldown_timer - delta, 0.0)
hit_stun_timer = max(hit_stun_timer - delta, 0.0)
# ============================================================================
# DAMAGE AND RESPAWN
# ============================================================================
func take_damage(amount: int, source_position: Vector2) -> void: func take_damage(amount: int, source_position: Vector2) -> void:
if amount <= 0 or is_dead: if amount <= 0 or is_dead:
return return
@@ -113,18 +392,59 @@ func take_damage(amount: int, source_position: Vector2) -> void:
_die() _die()
return return
# Turn away from the attacker. This gives a tiny readable reaction without # Light enemies are interruptible. This makes a well-timed player attack a
# inventing a hit-stun/knockback system before it is actually needed. # defensive option and avoids mandatory damage trades at close range.
var reaction_direction: float = signf(global_position.x - source_position.x) _interrupt_attack()
hit_stun_timer = hit_stun_duration
velocity.x = 0.0
var reaction_direction: float = signf(source_position.x - global_position.x)
if reaction_direction != 0.0: if reaction_direction != 0.0:
move_direction = reaction_direction facing_direction = reaction_direction
_update_floor_probe() _update_facing()
func _die() -> void: func _die() -> void:
is_dead = true is_dead = true
died.emit() died.emit()
queue_free()
velocity = Vector2.ZERO
visible = false
# take_damage() can run while Godot is flushing physics queries, therefore
# collision shapes are disabled deferred instead of changing them directly.
body_collision.set_deferred("disabled", true)
hurtbox_collision.set_deferred("disabled", true)
attack_collision.set_deferred("disabled", true)
await get_tree().create_timer(respawn_delay).timeout
_respawn()
func _respawn() -> void:
global_position = spawn_position
velocity = Vector2.ZERO
current_health = max_health
attack_state = AttackState.IDLE
attack_phase_timer = 0.0
attack_cooldown_timer = 0.0
hit_stun_timer = 0.0
attack_hit_targets.clear()
attack_direction = facing_direction
_reset_attack_hitbox_position()
body_collision.set_deferred("disabled", false)
hurtbox_collision.set_deferred("disabled", false)
attack_collision.set_deferred("disabled", false)
visible = true
is_dead = false
# If the spawn point is currently inside the camera, wake immediately.
# Otherwise the notifier will wake the enemy when the camera reaches it.
is_awake = visibility_notifier.is_on_screen()
queue_redraw()
# ============================================================================ # ============================================================================
@@ -133,7 +453,7 @@ func _die() -> void:
func _draw() -> void: func _draw() -> void:
# The enemy has no sprite yet. This neutral block makes the actual entity # The enemy has no sprite yet. This neutral block makes the actual entity
# visible independently from the red debug overlays. # visible independently from the debug overlays.
_draw_rectangle_collision( _draw_rectangle_collision(
body_collision, body_collision,
Color(0.18, 0.18, 0.20, 0.90), Color(0.18, 0.18, 0.20, 0.90),
@@ -150,14 +470,36 @@ func _draw() -> void:
Color(1.0, 0.05, 0.05, 0.55) Color(1.0, 0.05, 0.05, 0.55)
) )
# Contact hitbox: slightly larger, so it is obvious exactly when touching # Windup: preview the full sweep in orange. It is explicitly NOT dangerous
# the enemy can damage the player. # yet, so the different colour makes the reaction window easy to understand.
_draw_rectangle_collision( if attack_state == AttackState.WINDUP:
contact_collision, _draw_attack_telegraph()
Color(1.0, 0.05, 0.05, 0.18),
Color(1.0, 0.05, 0.05, 0.85), # Active attack: draw the real moving physics hitbox in strong red.
contact_hitbox.position if attack_state == AttackState.ACTIVE:
) _draw_rectangle_collision(
attack_collision,
Color(1.0, 0.05, 0.05, 0.28),
Color(1.0, 0.05, 0.05, 0.95),
attack_hitbox.position
)
func _draw_attack_telegraph() -> void:
var rectangle: RectangleShape2D = attack_collision.shape as RectangleShape2D
if rectangle == null:
return
var start_x: float = abs(attack_start_offset.x) * attack_direction
var end_x: float = attack_reach * attack_direction
var center_x: float = (start_x + end_x) * 0.5
var sweep_width: float = abs(end_x - start_x) + rectangle.size.x
var center: Vector2 = Vector2(center_x, attack_start_offset.y)
var sweep_size: Vector2 = Vector2(sweep_width, rectangle.size.y)
var rect: Rect2 = Rect2(center - sweep_size / 2.0, sweep_size)
draw_rect(rect, Color(1.0, 0.55, 0.05, 0.12), true)
draw_rect(rect, Color(1.0, 0.55, 0.05, 0.85), false, 2.0)
func _draw_rectangle_collision( func _draw_rectangle_collision(
+8 -5
View File
@@ -5,8 +5,8 @@
[sub_resource type="RectangleShape2D" id="BodyShape"] [sub_resource type="RectangleShape2D" id="BodyShape"]
size = Vector2(42, 78) size = Vector2(42, 78)
[sub_resource type="RectangleShape2D" id="ContactShape"] [sub_resource type="RectangleShape2D" id="AttackShape"]
size = Vector2(58, 88) size = Vector2(50, 60)
[node name="Walker" type="CharacterBody2D"] [node name="Walker" type="CharacterBody2D"]
collision_layer = 4 collision_layer = 4
@@ -26,17 +26,20 @@ monitorable = true
[node name="CollisionShape2D" type="CollisionShape2D" parent="Hurtbox"] [node name="CollisionShape2D" type="CollisionShape2D" parent="Hurtbox"]
shape = SubResource("BodyShape") shape = SubResource("BodyShape")
[node name="ContactHitbox" type="Area2D" parent="."] [node name="AttackHitbox" type="Area2D" parent="."]
collision_layer = 0 collision_layer = 0
collision_mask = 8 collision_mask = 8
monitoring = true monitoring = true
monitorable = false monitorable = false
[node name="CollisionShape2D" type="CollisionShape2D" parent="ContactHitbox"] [node name="CollisionShape2D" type="CollisionShape2D" parent="AttackHitbox"]
shape = SubResource("ContactShape") shape = SubResource("AttackShape")
[node name="FloorProbe" type="RayCast2D" parent="."] [node name="FloorProbe" type="RayCast2D" parent="."]
position = Vector2(24, 34) position = Vector2(24, 34)
target_position = Vector2(0, 34) target_position = Vector2(0, 34)
collision_mask = 1 collision_mask = 1
enabled = true enabled = true
[node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="."]
rect = Rect2(-32, -48, 64, 96)
+4 -8
View File
@@ -1,7 +1,3 @@
[runnable_presets]
Android="Android"
[preset.0] [preset.0]
name="Android" name="Android"
@@ -11,7 +7,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="" exclude_filter=""
export_path="" export_path="build/android/mortal-maze-debug.apk"
patches=PackedStringArray() patches=PackedStringArray()
patch_delta_encoding=false patch_delta_encoding=false
patch_delta_compression_level_zstd=19 patch_delta_compression_level_zstd=19
@@ -42,9 +38,9 @@ architectures/arm64-v8a=true
architectures/x86=false architectures/x86=false
architectures/x86_64=false architectures/x86_64=false
version/code=1 version/code=1
version/name="" version/name="0.1.0"
package/unique_name="com.example.$genname" package/unique_name="com.mortalmaze.thegame"
package/name="" package/name="Mortal Maze: The Game"
package/signed=true package/signed=true
package/app_category=2 package/app_category=2
package/retain_data_on_uninstall=false package/retain_data_on_uninstall=false
+5 -2
View File
@@ -1,8 +1,9 @@
[gd_scene load_steps=6 format=3] [gd_scene load_steps=7 format=3]
[ext_resource type="Texture2D" uid="uid://jmadeu351yh6" path="res://assets/photo_5837080277660930029_w.jpg" id="1_background"] [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://player/player.tscn" id="2_player"]
[ext_resource type="PackedScene" path="res://enemies/walker/walker.tscn" id="3_walker"] [ext_resource type="PackedScene" path="res://enemies/walker/walker.tscn" id="3_walker"]
[ext_resource type="PackedScene" path="res://ui/mobile_controls/mobile_controls.tscn" id="4_mobile_controls"]
[sub_resource type="RectangleShape2D" id="FloorShape"] [sub_resource type="RectangleShape2D" id="FloorShape"]
size = Vector2(1171, 20) size = Vector2(1171, 20)
@@ -44,4 +45,6 @@ theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9)
theme_override_constants/shadow_offset_x = 2 theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2 theme_override_constants/shadow_offset_y = 2
text = "A / D: mover W: saltar J: atacar\nRojo = hurtboxes y zonas de ataque reales" text = "A / D: mover W: saltar J: atacar\nTambién puedes usar los controles táctiles rojos"
[node name="MobileControls" parent="." instance=ExtResource("4_mobile_controls")]
+1 -1
View File
@@ -18,7 +18,7 @@ class_name CharacterData
@export_category("Stats") @export_category("Stats")
@export_range(1, 100, 1) var max_health: int = 5 @export_range(1, 100, 1) var max_health: int = 5
@export var move_speed: float = 300.0 @export var move_speed: float = 400.0
@export var jump_velocity: float = -600.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 basic_attack_damage: int = 1
@export_range(0, 100, 1) var air_attack_damage: int = 3 @export_range(0, 100, 1) var air_attack_damage: int = 3
+1 -1
View File
@@ -9,7 +9,7 @@ id = &"bassist"
display_name = "Bassist" display_name = "Bassist"
sprite_frames = ExtResource("2_frames") sprite_frames = ExtResource("2_frames")
max_health = 5 max_health = 5
move_speed = 300.0 move_speed = 400.0
jump_velocity = -600.0 jump_velocity = -600.0
basic_attack_damage = 1 basic_attack_damage = 1
air_attack_damage = 3 air_attack_damage = 3
+1 -1
View File
@@ -9,7 +9,7 @@ id = &"drummer"
display_name = "Drummer" display_name = "Drummer"
sprite_frames = ExtResource("2_frames") sprite_frames = ExtResource("2_frames")
max_health = 5 max_health = 5
move_speed = 300.0 move_speed = 400.0
jump_velocity = -600.0 jump_velocity = -600.0
basic_attack_damage = 1 basic_attack_damage = 1
air_attack_damage = 3 air_attack_damage = 3
+1 -1
View File
@@ -9,7 +9,7 @@ id = &"guitarist"
display_name = "Guitarist" display_name = "Guitarist"
sprite_frames = ExtResource("2_frames") sprite_frames = ExtResource("2_frames")
max_health = 5 max_health = 5
move_speed = 300.0 move_speed = 400.0
jump_velocity = -600.0 jump_velocity = -600.0
basic_attack_damage = 1 basic_attack_damage = 1
air_attack_damage = 3 air_attack_damage = 3
+1 -1
View File
@@ -9,7 +9,7 @@ id = &"singer"
display_name = "Singer" display_name = "Singer"
sprite_frames = ExtResource("2_frames") sprite_frames = ExtResource("2_frames")
max_health = 5 max_health = 5
move_speed = 300.0 move_speed = 400.0
jump_velocity = -600.0 jump_velocity = -600.0
basic_attack_damage = 1 basic_attack_damage = 1
air_attack_damage = 3 air_attack_damage = 3
+27 -5
View File
@@ -32,9 +32,20 @@ const ENEMY_HURTBOX_MASK: int = 1 << 4
@export_category("Movement") @export_category("Movement")
@export var ground_acceleration: float = 2200.0 ## Reaches full running speed quickly, but not instantly.
@export var ground_friction: float = 2600.0 @export var ground_acceleration: float = 3600.0
@export var air_acceleration: float = 1000.0
## Used when there is no horizontal input. Slightly stronger than acceleration
## so releasing the controls feels precise instead of slippery.
@export var ground_friction: float = 4800.0
## Braking used only when the player asks for the opposite direction. The
## character loses the old momentum first and then accelerates the other way,
## producing a small but readable turn delay.
@export var turn_braking: float = 5600.0
## Air control stays responsive but weaker than movement on the floor.
@export var air_acceleration: float = 1800.0
# ============================================================================ # ============================================================================
@@ -212,8 +223,19 @@ func _handle_horizontal_movement(delta: float) -> void:
return return
if is_on_floor(): if is_on_floor():
var acceleration: float = ground_acceleration if direction != 0.0 else ground_friction if direction == 0.0:
velocity.x = move_toward(velocity.x, target_speed, acceleration * delta) velocity.x = move_toward(velocity.x, 0.0, ground_friction * delta)
elif velocity.x != 0.0 and signf(velocity.x) != signf(direction):
# Do not snap directly from full speed right to full speed left. Brake
# the previous momentum first; on the next frames normal acceleration
# takes over in the requested direction.
velocity.x = move_toward(velocity.x, 0.0, turn_braking * delta)
else:
velocity.x = move_toward(
velocity.x,
target_speed,
ground_acceleration * delta
)
else: else:
velocity.x = move_toward(velocity.x, target_speed, air_acceleration * delta) velocity.x = move_toward(velocity.x, target_speed, air_acceleration * delta)
+5 -2
View File
@@ -1,14 +1,15 @@
[gd_scene load_steps=6 format=3 uid="uid://nwbjxttgghpq"] [gd_scene load_steps=7 format=3 uid="uid://nwbjxttgghpq"]
[ext_resource type="Script" uid="uid://dccfebno1gvh7" path="res://player/player.gd" id="1_player"] [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="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="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"] [ext_resource type="Resource" path="res://player/characters/singer.tres" id="4_character"]
[ext_resource type="PackedScene" path="res://ui/hud/player_health_hud.tscn" id="5_health_hud"]
[sub_resource type="RectangleShape2D" id="PlayerShape"] [sub_resource type="RectangleShape2D" id="PlayerShape"]
size = Vector2(65, 115) size = Vector2(65, 115)
[node name="Player" type="CharacterBody2D"] [node name="Player" type="CharacterBody2D" groups=["player"]]
z_index = 1 z_index = 1
collision_layer = 2 collision_layer = 2
collision_mask = 1 collision_mask = 1
@@ -56,3 +57,5 @@ speed_scale = 2.0
scale = Vector2(0.5, 0.5) scale = Vector2(0.5, 0.5)
zoom = Vector2(2, 2) zoom = Vector2(2, 2)
script = ExtResource("3_camera") script = ExtResource("3_camera")
[node name="PlayerHealthHUD" parent="." instance=ExtResource("5_health_hud")]
+4
View File
@@ -17,6 +17,9 @@ config/icon="res://icon.svg"
[display] [display]
window/size/viewport_width=1152
window/size/viewport_height=648
window/handheld/orientation=0
window/stretch/mode="canvas_items" window/stretch/mode="canvas_items"
window/stretch/aspect="expand" window/stretch/aspect="expand"
@@ -62,6 +65,7 @@ attack={
[rendering] [rendering]
textures/vram_compression/import_etc2_astc=true
rendering_device/driver.windows="d3d12" rendering_device/driver.windows="d3d12"
renderer/rendering_method="gl_compatibility" renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
# Exporta el APK de pruebas sin necesitar interfaz gráfica.
#
# Requisitos del entorno:
# - GODOT_BIN: ejecutable de Godot 4.7.x (por defecto: godot)
# - JAVA_HOME: ruta a OpenJDK 17
# - ANDROID_HOME: ruta al Android SDK
#
# El primer argumento es opcional y permite elegir otra ruta de salida.
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GODOT_BIN="${GODOT_BIN:-godot}"
OUTPUT_APK="${1:-${PROJECT_DIR}/build/android/mortal-maze-debug.apk}"
if [[ -z "${JAVA_HOME:-}" ]]; then
echo "ERROR: JAVA_HOME no está definido." >&2
exit 1
fi
if [[ -z "${ANDROID_HOME:-}" ]]; then
echo "ERROR: ANDROID_HOME no está definido." >&2
exit 1
fi
if ! command -v "${GODOT_BIN}" >/dev/null 2>&1; then
echo "ERROR: No encuentro Godot: ${GODOT_BIN}" >&2
echo "Define GODOT_BIN con la ruta al ejecutable si no está en PATH." >&2
exit 1
fi
mkdir -p "$(dirname "${OUTPUT_APK}")"
"${GODOT_BIN}" \
--headless \
--path "${PROJECT_DIR}" \
--export-debug "Android" \
"${OUTPUT_APK}"
echo "APK generado en: ${OUTPUT_APK}"
+29
View File
@@ -0,0 +1,29 @@
extends CanvasLayer
## Minimal player HUD.
##
## This is a CanvasLayer on purpose: its coordinates belong to the camera
## viewport, not to the level. Therefore (18, 18) always means "18 pixels from
## the top-left corner currently visible by the camera", even while the player
## and Camera2D move through the world.
@onready var health_bar: ProgressBar = $HUD/HealthBar
@onready var health_label: Label = $HUD/HealthBar/HealthLabel
func _ready() -> void:
var player: Node = get_parent()
if player == null or not player.has_signal(&"health_changed"):
push_error("PlayerHealthHUD must be a child of a Player with health_changed signal.")
return
# Child _ready() runs before Player._ready(). We connect here first and the
# Player will emit its initial health value a moment later during its _ready().
player.connect(&"health_changed", _on_health_changed)
func _on_health_changed(current_health: int, max_health: int) -> void:
health_bar.max_value = float(max_health)
health_bar.value = float(current_health)
health_label.text = "HP %d / %d" % [current_health, max_health]
+1
View File
@@ -0,0 +1 @@
uid://djbmxa3ubj4yk
+65
View File
@@ -0,0 +1,65 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://ui/hud/player_health_hud.gd" id="1_hud"]
[sub_resource type="StyleBoxFlat" id="HealthBackground"]
bg_color = Color(0.08, 0.08, 0.09, 0.82)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.95, 0.95, 0.95, 0.75)
corner_radius_top_left = 7
corner_radius_top_right = 7
corner_radius_bottom_right = 7
corner_radius_bottom_left = 7
[sub_resource type="StyleBoxFlat" id="HealthFill"]
bg_color = Color(0.72, 0.06, 0.06, 0.92)
corner_radius_top_left = 5
corner_radius_top_right = 5
corner_radius_bottom_right = 5
corner_radius_bottom_left = 5
[node name="PlayerHealthHUD" type="CanvasLayer"]
layer = 90
script = ExtResource("1_hud")
[node name="HUD" type="Control" parent="."]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="HealthBar" type="ProgressBar" parent="HUD"]
layout_mode = 0
offset_left = 18.0
offset_top = 18.0
offset_right = 278.0
offset_bottom = 58.0
mouse_filter = 2
theme_override_styles/background = SubResource("HealthBackground")
theme_override_styles/fill = SubResource("HealthFill")
max_value = 5.0
value = 5.0
show_percentage = false
[node name="HealthLabel" type="Label" parent="HUD/HealthBar"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
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 = 1
theme_override_constants/shadow_offset_y = 1
theme_override_font_sizes/font_size = 18
text = "HP 5 / 5"
horizontal_alignment = 1
vertical_alignment = 1
+50
View File
@@ -0,0 +1,50 @@
extends CanvasLayer
## Controles táctiles provisionales para jugar en móvil.
##
## Los botones no llaman directamente al Player. En su lugar pulsan y liberan
## las mismas acciones del InputMap que ya usa el teclado. De esta forma la
## lógica de movimiento/combate sigue teniendo una única fuente de entrada:
##
## teclado -> InputMap <- controles táctiles
## |
## Player
##
## Cuando llegue el arte definitivo, esta escena puede cambiar por completo sin
## tocar player.gd mientras conserve estas cuatro acciones.
@onready var move_left_button: Button = $Controls/MoveLeft
@onready var move_right_button: Button = $Controls/MoveRight
@onready var jump_button: Button = $Controls/Jump
@onready var attack_button: Button = $Controls/Attack
func _ready() -> void:
_bind_button(move_left_button, &"move_left")
_bind_button(move_right_button, &"move_right")
_bind_button(jump_button, &"jump")
_bind_button(attack_button, &"attack")
func _exit_tree() -> void:
# Si la escena cambia mientras un dedo sigue apoyado, Godot puede no llegar
# a emitir button_up. Liberamos las acciones para no dejar una tecla virtual
# atascada al cargar la siguiente escena.
Input.action_release(&"move_left")
Input.action_release(&"move_right")
Input.action_release(&"jump")
Input.action_release(&"attack")
func _bind_button(button: Button, action: StringName) -> void:
button.button_down.connect(_press_action.bind(action))
button.button_up.connect(_release_action.bind(action))
func _press_action(action: StringName) -> void:
Input.action_press(action)
func _release_action(action: StringName) -> void:
Input.action_release(action)
@@ -0,0 +1 @@
uid://2kbajcohyem7
+122
View File
@@ -0,0 +1,122 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://ui/mobile_controls/mobile_controls.gd" id="1_controls"]
[sub_resource type="StyleBoxFlat" id="ButtonNormal"]
bg_color = Color(0.65, 0.04, 0.04, 0.28)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(1, 0.15, 0.15, 0.72)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[sub_resource type="StyleBoxFlat" id="ButtonPressed"]
bg_color = Color(0.95, 0.05, 0.05, 0.52)
border_width_left = 4
border_width_top = 4
border_width_right = 4
border_width_bottom = 4
border_color = Color(1, 0.45, 0.45, 0.95)
corner_radius_top_left = 14
corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
[node name="MobileControls" type="CanvasLayer"]
layer = 100
script = ExtResource("1_controls")
[node name="Controls" type="Control" parent="."]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="MoveLeft" type="Button" parent="Controls"]
layout_mode = 0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 34.0
offset_top = -132.0
offset_right = 134.0
offset_bottom = -32.0
grow_vertical = 0
focus_mode = 0
keep_pressed_outside = true
theme_override_colors/font_color = Color(1, 1, 1, 0.95)
theme_override_colors/font_pressed_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 42
theme_override_styles/normal = SubResource("ButtonNormal")
theme_override_styles/hover = SubResource("ButtonNormal")
theme_override_styles/pressed = SubResource("ButtonPressed")
text = "<"
[node name="MoveRight" type="Button" parent="Controls"]
layout_mode = 0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 150.0
offset_top = -132.0
offset_right = 250.0
offset_bottom = -32.0
grow_vertical = 0
focus_mode = 0
keep_pressed_outside = true
theme_override_colors/font_color = Color(1, 1, 1, 0.95)
theme_override_colors/font_pressed_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 42
theme_override_styles/normal = SubResource("ButtonNormal")
theme_override_styles/hover = SubResource("ButtonNormal")
theme_override_styles/pressed = SubResource("ButtonPressed")
text = ">"
[node name="Jump" type="Button" parent="Controls"]
layout_mode = 0
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -264.0
offset_top = -132.0
offset_right = -154.0
offset_bottom = -32.0
grow_horizontal = 0
grow_vertical = 0
focus_mode = 0
keep_pressed_outside = true
theme_override_colors/font_color = Color(1, 1, 1, 0.95)
theme_override_colors/font_pressed_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 22
theme_override_styles/normal = SubResource("ButtonNormal")
theme_override_styles/hover = SubResource("ButtonNormal")
theme_override_styles/pressed = SubResource("ButtonPressed")
text = "JUMP"
[node name="Attack" type="Button" parent="Controls"]
layout_mode = 0
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -138.0
offset_top = -132.0
offset_right = -28.0
offset_bottom = -32.0
grow_horizontal = 0
grow_vertical = 0
focus_mode = 0
keep_pressed_outside = true
theme_override_colors/font_color = Color(1, 1, 1, 0.95)
theme_override_colors/font_pressed_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 22
theme_override_styles/normal = SubResource("ButtonNormal")
theme_override_styles/hover = SubResource("ButtonNormal")
theme_override_styles/pressed = SubResource("ButtonPressed")
text = "ATK"