diff --git a/.gitignore b/.gitignore index 0af181c..dc48c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # Godot 4+ specific ignores .godot/ /android/ + +# Local export artifacts +build/ diff --git a/README.md b/README.md index f573a77..e850c84 100644 --- a/README.md +++ b/README.md @@ -1886,6 +1886,9 @@ Actualizar esta sección conforme avance el proyecto. 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. +- 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 @@ -1893,6 +1896,7 @@ Actualizar esta sección conforme avance el proyecto. ```text - 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. +- 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. ``` diff --git a/enemies/walker/walker.gd b/enemies/walker/walker.gd index ba30cf9..d11f804 100644 --- a/enemies/walker/walker.gd +++ b/enemies/walker/walker.gd @@ -11,40 +11,136 @@ signal died const PLAYER_HURTBOX_MASK: int = 1 << 3 +enum AttackState { + IDLE, + WINDUP, + ACTIVE, + RECOVERY, +} + + +# ============================================================================ +# MOVEMENT +# ============================================================================ + @export_category("Movement") + @export var move_speed: float = 85.0 @export var max_fall_speed: float = 1200.0 + +# ============================================================================ +# COMBAT +# ============================================================================ + @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_range(0, 100, 1) var attack_damage: int = 1 + +## 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") + ## 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 +# ============================================================================ +# NODES +# ============================================================================ + @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 attack_hitbox: Area2D = $AttackHitbox +@onready var attack_collision: CollisionShape2D = $AttackHitbox/CollisionShape2D @onready var floor_probe: RayCast2D = $FloorProbe +@onready var visibility_notifier: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D +# ============================================================================ +# RUNTIME STATE +# ============================================================================ + var current_health: int -var move_direction: float = -1.0 -var contact_damage_timer: float = 0.0 +var facing_direction: float = -1.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 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: + spawn_position = global_position current_health = max_health - contact_hitbox.collision_layer = 0 - contact_hitbox.collision_mask = PLAYER_HURTBOX_MASK - contact_hitbox.monitoring = true - _update_floor_probe() + target = get_tree().get_first_node_in_group(&"player") as Node2D + + attack_hitbox.collision_layer = 0 + 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() @@ -52,20 +148,80 @@ func _physics_process(delta: float) -> void: if is_dead: return - contact_damage_timer = max(contact_damage_timer - delta, 0.0) + _update_timers(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() - - if is_on_wall(): - _turn_around() - - _damage_player_on_contact() 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: if is_on_floor(): return @@ -74,35 +230,158 @@ func _apply_gravity(delta: float) -> void: 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 _update_idle_behaviour() -> void: + _face_target() - -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: + if attack_cooldown_timer <= 0.0 and _is_target_in_attack_trigger(): + _start_attack() 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 + _chase_target() +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: if amount <= 0 or is_dead: return @@ -113,18 +392,59 @@ func take_damage(amount: int, source_position: Vector2) -> void: _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) + # Light enemies are interruptible. This makes a well-timed player attack a + # defensive option and avoids mandatory damage trades at close range. + _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: - move_direction = reaction_direction - _update_floor_probe() + facing_direction = reaction_direction + _update_facing() func _die() -> void: is_dead = true 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: # 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( body_collision, 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) ) - # 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 - ) + # Windup: preview the full sweep in orange. It is explicitly NOT dangerous + # yet, so the different colour makes the reaction window easy to understand. + if attack_state == AttackState.WINDUP: + _draw_attack_telegraph() + + # Active attack: draw the real moving physics hitbox in strong red. + 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( diff --git a/enemies/walker/walker.tscn b/enemies/walker/walker.tscn index 1a173ac..0158c2a 100644 --- a/enemies/walker/walker.tscn +++ b/enemies/walker/walker.tscn @@ -5,8 +5,8 @@ [sub_resource type="RectangleShape2D" id="BodyShape"] size = Vector2(42, 78) -[sub_resource type="RectangleShape2D" id="ContactShape"] -size = Vector2(58, 88) +[sub_resource type="RectangleShape2D" id="AttackShape"] +size = Vector2(50, 60) [node name="Walker" type="CharacterBody2D"] collision_layer = 4 @@ -26,17 +26,20 @@ monitorable = true [node name="CollisionShape2D" type="CollisionShape2D" parent="Hurtbox"] shape = SubResource("BodyShape") -[node name="ContactHitbox" type="Area2D" parent="."] +[node name="AttackHitbox" 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="CollisionShape2D" type="CollisionShape2D" parent="AttackHitbox"] +shape = SubResource("AttackShape") [node name="FloorProbe" type="RayCast2D" parent="."] position = Vector2(24, 34) target_position = Vector2(0, 34) collision_mask = 1 enabled = true + +[node name="VisibleOnScreenNotifier2D" type="VisibleOnScreenNotifier2D" parent="."] +rect = Rect2(-32, -48, 64, 96) diff --git a/export_presets.cfg b/export_presets.cfg index e13b58d..1c5f98d 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -1,7 +1,3 @@ -[runnable_presets] - -Android="Android" - [preset.0] name="Android" @@ -11,7 +7,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="" +export_path="build/android/mortal-maze-debug.apk" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 @@ -42,9 +38,9 @@ architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false version/code=1 -version/name="" -package/unique_name="com.example.$genname" -package/name="" +version/name="0.1.0" +package/unique_name="com.mortalmaze.thegame" +package/name="Mortal Maze: The Game" package/signed=true package/app_category=2 package/retain_data_on_uninstall=false diff --git a/levels/test/combat_test.tscn b/levels/test/combat_test.tscn index 817622d..bb6fca6 100644 --- a/levels/test/combat_test.tscn +++ b/levels/test/combat_test.tscn @@ -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="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://ui/mobile_controls/mobile_controls.tscn" id="4_mobile_controls"] [sub_resource type="RectangleShape2D" id="FloorShape"] 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_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" +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")] diff --git a/player/character_data.gd b/player/character_data.gd index 26d6b9d..d88077c 100644 --- a/player/character_data.gd +++ b/player/character_data.gd @@ -18,7 +18,7 @@ class_name CharacterData @export_category("Stats") @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_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/characters/bassist.tres b/player/characters/bassist.tres index 6092432..ad41200 100644 --- a/player/characters/bassist.tres +++ b/player/characters/bassist.tres @@ -9,7 +9,7 @@ id = &"bassist" display_name = "Bassist" sprite_frames = ExtResource("2_frames") max_health = 5 -move_speed = 300.0 +move_speed = 400.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 index 71e44fb..b83cfa8 100644 --- a/player/characters/drummer.tres +++ b/player/characters/drummer.tres @@ -9,7 +9,7 @@ id = &"drummer" display_name = "Drummer" sprite_frames = ExtResource("2_frames") max_health = 5 -move_speed = 300.0 +move_speed = 400.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 index 318c31c..2beb00f 100644 --- a/player/characters/guitarist.tres +++ b/player/characters/guitarist.tres @@ -9,7 +9,7 @@ id = &"guitarist" display_name = "Guitarist" sprite_frames = ExtResource("2_frames") max_health = 5 -move_speed = 300.0 +move_speed = 400.0 jump_velocity = -600.0 basic_attack_damage = 1 air_attack_damage = 3 diff --git a/player/characters/singer.tres b/player/characters/singer.tres index 307e8b4..6eb6aaa 100644 --- a/player/characters/singer.tres +++ b/player/characters/singer.tres @@ -9,7 +9,7 @@ id = &"singer" display_name = "Singer" sprite_frames = ExtResource("2_frames") max_health = 5 -move_speed = 300.0 +move_speed = 400.0 jump_velocity = -600.0 basic_attack_damage = 1 air_attack_damage = 3 diff --git a/player/player.gd b/player/player.gd index 57a72ed..c1997b0 100644 --- a/player/player.gd +++ b/player/player.gd @@ -32,9 +32,20 @@ const ENEMY_HURTBOX_MASK: int = 1 << 4 @export_category("Movement") -@export var ground_acceleration: float = 2200.0 -@export var ground_friction: float = 2600.0 -@export var air_acceleration: float = 1000.0 +## Reaches full running speed quickly, but not instantly. +@export var ground_acceleration: float = 3600.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 if is_on_floor(): - var acceleration: float = ground_acceleration if direction != 0.0 else ground_friction - velocity.x = move_toward(velocity.x, target_speed, acceleration * delta) + if direction == 0.0: + 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: velocity.x = move_toward(velocity.x, target_speed, air_acceleration * delta) diff --git a/player/player.tscn b/player/player.tscn index 0a7b92b..7001151 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -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="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"] +[ext_resource type="PackedScene" path="res://ui/hud/player_health_hud.tscn" id="5_health_hud"] [sub_resource type="RectangleShape2D" id="PlayerShape"] size = Vector2(65, 115) -[node name="Player" type="CharacterBody2D"] +[node name="Player" type="CharacterBody2D" groups=["player"]] z_index = 1 collision_layer = 2 collision_mask = 1 @@ -56,3 +57,5 @@ speed_scale = 2.0 scale = Vector2(0.5, 0.5) zoom = Vector2(2, 2) script = ExtResource("3_camera") + +[node name="PlayerHealthHUD" parent="." instance=ExtResource("5_health_hud")] diff --git a/project.godot b/project.godot index ceac9d1..9338158 100644 --- a/project.godot +++ b/project.godot @@ -17,6 +17,9 @@ config/icon="res://icon.svg" [display] +window/size/viewport_width=1152 +window/size/viewport_height=648 +window/handheld/orientation=0 window/stretch/mode="canvas_items" window/stretch/aspect="expand" @@ -62,6 +65,7 @@ attack={ [rendering] +textures/vram_compression/import_etc2_astc=true rendering_device/driver.windows="d3d12" renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility" diff --git a/scripts/export_android_debug.sh b/scripts/export_android_debug.sh new file mode 100755 index 0000000..3e0eb2e --- /dev/null +++ b/scripts/export_android_debug.sh @@ -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}" diff --git a/ui/hud/player_health_hud.gd b/ui/hud/player_health_hud.gd new file mode 100644 index 0000000..5c488ee --- /dev/null +++ b/ui/hud/player_health_hud.gd @@ -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] diff --git a/ui/hud/player_health_hud.gd.uid b/ui/hud/player_health_hud.gd.uid new file mode 100644 index 0000000..96ba9ae --- /dev/null +++ b/ui/hud/player_health_hud.gd.uid @@ -0,0 +1 @@ +uid://djbmxa3ubj4yk diff --git a/ui/hud/player_health_hud.tscn b/ui/hud/player_health_hud.tscn new file mode 100644 index 0000000..9607bbc --- /dev/null +++ b/ui/hud/player_health_hud.tscn @@ -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 diff --git a/ui/mobile_controls/mobile_controls.gd b/ui/mobile_controls/mobile_controls.gd new file mode 100644 index 0000000..715f4c7 --- /dev/null +++ b/ui/mobile_controls/mobile_controls.gd @@ -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) diff --git a/ui/mobile_controls/mobile_controls.gd.uid b/ui/mobile_controls/mobile_controls.gd.uid new file mode 100644 index 0000000..d34bc84 --- /dev/null +++ b/ui/mobile_controls/mobile_controls.gd.uid @@ -0,0 +1 @@ +uid://2kbajcohyem7 diff --git a/ui/mobile_controls/mobile_controls.tscn b/ui/mobile_controls/mobile_controls.tscn new file mode 100644 index 0000000..eafb917 --- /dev/null +++ b/ui/mobile_controls/mobile_controls.tscn @@ -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"