diff --git a/Assets/bull_hud_bg.png b/Assets/bull_hud_bg.png new file mode 100644 index 0000000..99cc75e Binary files /dev/null and b/Assets/bull_hud_bg.png differ diff --git a/Assets/bull_hud_bg.png.import b/Assets/bull_hud_bg.png.import new file mode 100644 index 0000000..25e330b --- /dev/null +++ b/Assets/bull_hud_bg.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ml0ntjv5injp" +path="res://.godot/imported/bull_hud_bg.png-4645abe7114935a3d4a8e0f88ccce15c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/bull_hud_bg.png" +dest_files=["res://.godot/imported/bull_hud_bg.png-4645abe7114935a3d4a8e0f88ccce15c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/Assets/loadingScreen.png b/Assets/loadingScreen.png new file mode 100644 index 0000000..406c467 Binary files /dev/null and b/Assets/loadingScreen.png differ diff --git a/Assets/loading_screen.png b/Assets/loading_screen.png new file mode 100644 index 0000000..c2b2e05 Binary files /dev/null and b/Assets/loading_screen.png differ diff --git a/Assets/spear.glb b/Assets/spear.glb new file mode 100644 index 0000000..d0627ef Binary files /dev/null and b/Assets/spear.glb differ diff --git a/Assets/spear.glb.import b/Assets/spear.glb.import new file mode 100644 index 0000000..d567ee1 --- /dev/null +++ b/Assets/spear.glb.import @@ -0,0 +1,45 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://jepc74w8opbu" +path="res://.godot/imported/spear.glb-0139879d219c93f9dd35db2304db7d5d.scn" + +[deps] + +source_file="res://Assets/spear.glb" +dest_files=["res://.godot/imported/spear.glb-0139879d219c93f9dd35db2304db7d5d.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=1 diff --git a/Blender/spear.blend b/Blender/spear.blend new file mode 100644 index 0000000..6ca201f Binary files /dev/null and b/Blender/spear.blend differ diff --git a/MainMenu.tscn b/MainMenu.tscn index ae234f0..4c20a86 100644 --- a/MainMenu.tscn +++ b/MainMenu.tscn @@ -2,6 +2,12 @@ [ext_resource type="Script" uid="uid://46y10alai56q" path="res://main_menu.gd" id="1_menu"] [ext_resource type="VideoStream" uid="uid://csxxlnq74fej4" path="res://Assets/intro_bg.ogv" id="2_video"] +[ext_resource type="Shader" path="res://menu_blur.gdshader" id="3_blur"] + +[sub_resource type="ShaderMaterial" id="mat_blur"] +shader = ExtResource("3_blur") +shader_parameter/blur_radius = 3.0 +shader_parameter/darken = 0.35 [sub_resource type="StyleBoxFlat" id="sb_normal"] content_margin_left = 16.0 @@ -39,25 +45,50 @@ grow_horizontal = 2 grow_vertical = 2 script = ExtResource("1_menu") -[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="." unique_id=374854265] +[node name="VideoBlur" type="AspectRatioContainer" parent="." unique_id=374854263] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 +mouse_filter = 2 +ratio = 1.7778 +stretch_mode = 3 + +[node name="BlurVideo" type="VideoStreamPlayer" parent="VideoBlur" unique_id=374854262] +material = SubResource("mat_blur") +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +mouse_filter = 2 stream = ExtResource("2_video") expand = true -[node name="MainPanel" type="Control" parent="." unique_id=1263320918] +[node name="VideoAspect" type="AspectRatioContainer" parent="." unique_id=374854264] layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 +mouse_filter = 2 +ratio = 1.7778 +stretch_mode = 2 -[node name="PlayButton" type="Button" parent="MainPanel" unique_id=743089847] +[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="VideoAspect" unique_id=374854265] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +mouse_filter = 2 +stream = ExtResource("2_video") +expand = true + +[node name="CapeLayer" type="Control" parent="VideoAspect" unique_id=374854266] +layout_mode = 2 +mouse_filter = 2 + +[node name="PlayButton" type="Button" parent="VideoAspect/CapeLayer" unique_id=743089847] layout_mode = 1 anchors_preset = -1 anchor_left = 0.453 @@ -80,6 +111,15 @@ theme_override_styles/hover = SubResource("sb_hover") theme_override_styles/focus = SubResource("sb_hover") text = "PLAY" +[node name="MainPanel" type="Control" parent="." unique_id=1263320918] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + [node name="OptionsButton" type="Button" parent="MainPanel" unique_id=673211179] layout_mode = 1 anchors_preset = -1 diff --git a/camera_iso.gd b/camera_iso.gd index b3f6ae9..34c299c 100644 --- a/camera_iso.gd +++ b/camera_iso.gd @@ -92,6 +92,8 @@ func _drag_focus(target: Node3D, delta: float) -> Vector3: func trigger_hit(strength: float) -> void: + if not Settings.screen_shake: + return _shake_amount = strength * 0.5 _shake_decay = _shake_amount / 0.4 diff --git a/controls_manager.gd b/controls_manager.gd index ccdb190..c3ebd80 100644 --- a/controls_manager.gd +++ b/controls_manager.gd @@ -15,8 +15,8 @@ const REBINDABLE_ACTIONS: PackedStringArray = [ &"move_left", &"move_right", &"ability_kick", - &"ability_slam", &"ability_dash", + &"ability_slam", &"ability_roll", ] @@ -25,6 +25,21 @@ func _ready() -> void: load_saved() +## Whether to drive the game with the on-screen touch UI (joystick + buttons) +## instead of keyboard hints. DisplayServer.is_touchscreen_available() can't be +## used here: `emulate_touch_from_mouse` (on for desktop testing) makes it report +## true on every desktop. Gate on the actual runtime platform instead — real +## mobile/native and mobile web browsers — plus the DP force flag for testing. +func use_touch_ui() -> bool: + if DP.b("force_touch_controls"): + return true + return ( + OS.has_feature("mobile") + or OS.has_feature("web_android") + or OS.has_feature("web_ios") + ) + + ## Returns the first keyboard event bound to an action, or null. func get_key_event(action: StringName) -> InputEventKey: for event: InputEvent in InputMap.action_get_events(action): diff --git a/damage_vignette.gdshader b/damage_vignette.gdshader new file mode 100644 index 0000000..fee31eb --- /dev/null +++ b/damage_vignette.gdshader @@ -0,0 +1,16 @@ +shader_type canvas_item; + +// Faint red gradient hugging the screen edges, flashed on a hit. `flash` is tweened +// 0.9 → 0 by the HUD; the vignette is transparent in the centre and reddens toward +// the corners so the middle of the screen stays clear. +uniform float flash : hint_range(0.0, 1.0) = 0.0; +uniform vec3 tint : source_color = vec3(0.75, 0.0, 0.0); +uniform float inner : hint_range(0.0, 1.0) = 0.45; +uniform float outer : hint_range(0.0, 1.0) = 1.0; + +void fragment() { + // Distance from centre normalised so the corners land at ~1.0. + float dist = length(UV - vec2(0.5)) / 0.70710678; + float v = smoothstep(inner, outer, dist); + COLOR = vec4(tint, v * flash); +} diff --git a/damage_vignette.gdshader.uid b/damage_vignette.gdshader.uid new file mode 100644 index 0000000..e76c083 --- /dev/null +++ b/damage_vignette.gdshader.uid @@ -0,0 +1 @@ +uid://dys23pu1kw5fl diff --git a/debug_params.gd b/debug_params.gd index f7f18ca..b1017ac 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -24,7 +24,7 @@ func _register_all() -> void: # ── Movement ────────────────────────────────────────────────────────────── # WASD/arrows drive the bull: move_accel ramps flat velocity toward max_speed in # the input direction; move_friction bleeds it off when no direction is held. - _reg_f("Movement", "max_speed", 50.0, 2.0, 1000.0) + _reg_f("Movement", "max_speed", 10.0, 2.0, 1000.0) _reg_f("Movement", "move_accel", 120.0, 5.0, 600.0) _reg_f("Movement", "move_friction", 90.0, 5.0, 600.0) _reg_f("Movement", "jump_velocity", 4.5, 1.0, 200.0) @@ -33,6 +33,11 @@ func _register_all() -> void: _reg_f("Movement", "bounce_min_impact", 1.0, 0.0, 20.0) _reg_f("Movement", "wall_bounce_restitution", 0.6, 0.0, 1.5, 0.05) _reg_f("Movement", "wall_min_bounce_speed", 6.0, 0.0, 30.0) + # ── Bull health ─────────────────────────────────────────────────────────── + # The bull soaks bull_max_hp clean hits before it dies; each hit grants + # bull_hit_iframes seconds of invulnerability so a single pass can't drain it. + _reg_f("Bull", "bull_max_hp", 10.0, 1.0, 100.0, 1.0) + _reg_f("Bull", "bull_hit_iframes", 0.8, 0.0, 3.0, 0.1) # ── Dust speed thresholds ───────────────────────────────────────────────── _reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0) _reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0) @@ -95,13 +100,22 @@ func _register_all() -> void: _reg_f("Camera", "cam_follow_lerp", 10.0, 1.0, 40.0) # ── Matador ─────────────────────────────────────────────────────────────── _reg_f("Matador", "mat_spawn_count", 1.0, 1.0, 30.0, 1.0) + # Hydra split: a slain matador is replaced on the spot by mat_split_factor fresh + # matadors, so the horde doubles each generation (1 → 2 → 4 → 8 …) at the default + # 2. Capped at mat_split_generations deep so the arena stays winnable (factor <2 or + # generations 0 = no splitting, the classic one-and-done arena). + _reg_f("Matador", "mat_split_factor", 2.0, 1.0, 6.0, 1.0) + _reg_f("Matador", "mat_split_generations", 3.0, 0.0, 6.0, 1.0) + # The bull wins outright once it has slain this many matadors, even if the hydra + # still has bodies on the sand. 0 = disabled (win only when the horde dies out) + _reg_f("Matador", "mat_win_kills", 21.0, 0.0, 200.0, 1.0) _reg_f("Matador", "mat_walk_speed", 5.5, 0.5, 10.0) _reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0) # Radius of the inner fighting floor: bull + matadors spawn on this ring (so # they stay off the benches). Tune in the console to hug the bench edge. _reg_f("Matador", "arena_spawn_radius", 10.0, 2.0, 22.0, 0.5) - _reg_f("Matador", "mat_idle_min", 0.5, 0.0, 5.0, 0.1) - _reg_f("Matador", "mat_idle_max", 2.5, 0.5, 10.0, 0.1) + _reg_f("Matador", "mat_idle_min", 1.2, 0.0, 5.0, 0.1) + _reg_f("Matador", "mat_idle_max", 4.0, 0.5, 10.0, 0.1) _reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0) _reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0) # Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its @@ -153,10 +167,27 @@ func _register_all() -> void: # bullfighter, caution for a slippery one. mat_w_commit is a stickiness bonus for # the current choice so it doesn't dither between two near-tied options. _reg_f("Matador", "mat_decide_interval", 0.2, 0.05, 1.0, 0.05) - _reg_f("Matador", "mat_w_aggression", 1.4, 0.0, 3.0, 0.05) + _reg_f("Matador", "mat_w_aggression", 1.1, 0.0, 3.0, 0.05) _reg_f("Matador", "mat_w_caution", 1.0, 0.0, 3.0, 0.05) _reg_f("Matador", "mat_w_flee", 0.8, 0.0, 3.0, 0.05) - _reg_f("Matador", "mat_w_showmanship", 1.0, 0.0, 3.0, 0.05) + # Showmanship (strut, circle the bull, taunt) — the supporting matadors lean on + # this to spread out and give the lead room, so a whole crowd reads as a bullring, + # not a swarm. Crank it for more posturing, drop it for a relentless mob. + _reg_f("Matador", "mat_w_showmanship", 1.6, 0.0, 3.0, 0.05) + # Weight on hurling the blade. Kept low — a thrown sword is an occasional flourish, + # not a barrage; raising this brings back the pelting. + _reg_f("Matador", "mat_w_throw", 0.35, 0.0, 3.0, 0.05) + # How many matadors press the bull at once. The nearest this-many engage (close, + # throw); everyone else keeps their distance and taunts, taking turns as the bull + # moves. 1 = strict one-on-one, higher = a bolder pack. + _reg_f("Matador", "mat_engage_slots", 2.0, 1.0, 8.0, 1.0) + # Preferred distance (m) a taunting matador circles the bull at while waiting its + # turn — the showmanship ring the crowd spreads out onto. + _reg_f("Matador", "mat_taunt_ring", 7.0, 3.0, 15.0, 0.5) + # How long (s) a matador safely out of the action holds a taunt before drifting to + # a new spot — much longer than the between-passes idle, so bystanders mostly pose + # for the crowd instead of milling about. Actual hold is randomised ±30%. + _reg_f("Matador", "mat_taunt_hold", 8.0, 1.0, 20.0, 0.5) # Small so it smooths dithering without locking the matador into idling. _reg_f("Matador", "mat_w_commit", 0.15, 0.0, 2.0, 0.05) # ── Sword ───────────────────────────────────────────────────────────────── @@ -178,18 +209,28 @@ func _register_all() -> void: _reg_f("Sword", "sword_rest_rot_x", -120.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rest_rot_y", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rest_rot_z", 0.0, -180.0, 180.0, 1.0) - # ── Sword throw (matador ranged attack) ────────────────────────────────── - # Delay before a fresh sword appears in the holster after one is thrown. + # ── Spear throw (matador ranged attack) ────────────────────────────────── + # Local seating of the spear in the throwing hand during the wind-up. Tune to + # seat the shaft in the grip without re-exporting; flight orientation is derived + # from the aim direction at release, not from this pose. + _reg_f("Sword", "spear_pos_x", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "spear_pos_y", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "spear_pos_z", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "spear_rot_x", 0.0, -180.0, 180.0, 1.0) + _reg_f("Sword", "spear_rot_y", 0.0, -180.0, 180.0, 1.0) + _reg_f("Sword", "spear_rot_z", 0.0, -180.0, 180.0, 1.0) + # Delay before a fresh sword reappears in the holster if one is ever lost. _reg_f("Sword", "mat_resword_delay", 2.0, 0.0, 10.0, 0.5) _reg_f("Sword", "mat_throw_range", 18.0, 5.0, 40.0) _reg_f("Sword", "mat_throw_min_dist", 5.0, 2.0, 15.0, 0.1) _reg_f("Sword", "mat_throw_windup", 0.5, 0.2, 2.0, 0.05) _reg_f("Sword", "mat_throw_release", 0.55, 0.1, 0.95, 0.01) - _reg_f("Sword", "mat_throw_speed", 24.0, 5.0, 60.0) - _reg_f("Sword", "mat_throw_spin", 14.0, 0.0, 40.0) - # Gravity scale on the thrown blade (low = flies straight to the aim point) and - # the vertical aim offset from the bull origin down onto its low collision body. - _reg_f("Sword", "mat_throw_gravity", 0.25, 0.0, 1.0, 0.05) + # Horizontal speed of the thrown spear; the vertical launch is solved from this + # and the gravity below so the arc lands on the bull. Lower = a higher, slower lob. + _reg_f("Sword", "mat_throw_speed", 11.0, 5.0, 60.0) + # Gravity scale on the spear (higher = a more pronounced arc) and the vertical aim + # offset from the bull origin down onto its low collision body. + _reg_f("Sword", "mat_throw_gravity", 1.0, 0.1, 3.0, 0.05) _reg_f("Sword", "mat_throw_aim_y", -0.45,-1.5, 0.5, 0.05) # Aim scatter (± degrees) so thrown blades can be read and dodged on the move. _reg_f("Sword", "mat_throw_spread", 11.0, 0.0, 30.0, 0.5) @@ -198,7 +239,7 @@ func _register_all() -> void: _reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0) # ── Abilities ───────────────────────────────────────────────────────────── _reg_f("Abilities", "kick_cooldown", 1.5, 0.2, 10.0, 0.1) - _reg_f("Abilities", "kick_range", 3.5, 0.5, 8.0, 0.1) + _reg_f("Abilities", "kick_range", 6.0, 0.5, 8.0, 0.1) _reg_f("Abilities", "kick_strength", 12.0, 1.0, 30.0) _reg_f("Abilities", "slam_cooldown", 4.0, 0.5, 15.0, 0.1) _reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0) @@ -209,19 +250,24 @@ func _register_all() -> void: _reg_f("Abilities", "slam_launch_up", 7.0, 0.0, 15.0, 0.5) _reg_f("Abilities", "slam_range", 4.0, 1.0, 10.0, 0.1) _reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0) - _reg_f("Abilities", "dash_cooldown", 2.0, 0.5, 10.0, 0.1) + _reg_f("Abilities", "dash_cooldown", 0.5, 0.1, 10.0, 0.1) _reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0) - # ── Roll (Rammus Powerball) ─────────────────────────────────────────────── + # Exponential rate the dash bleeds from dash_speed back to cruise (max_speed). + # Higher = a snappier bullet that dies off quicker; lower = a longer glide. + _reg_f("Abilities", "dash_decel", 7.0, 0.5, 30.0, 0.5) + # ── Roll (homing chain ball) ────────────────────────────────────────────── # The bull curls into a ball and ACCELERATES the longer it rolls, from - # roll_base_speed up to roll_max_speed over roll_rampup_time. Turning is wide - # (momentum steering via roll_turn). On slamming into a matador the ball POPS: - # the target is launched skyward (roll_knockup) and the roll ends. + # roll_base_speed up to roll_max_speed over roll_rampup_time. It HOMES on the + # closest matador; on slamming one the ball POPS it (launched skyward via + # roll_knockup) then CHAINS to the next closest, up to roll_chain_count times. + # roll_turn is the homing turn rate. When no chain targets remain, the roll ends. _reg_f("Roll", "roll_cooldown", 3.0, 0.5, 15.0, 0.1) _reg_f("Roll", "roll_duration", 2.5, 0.5, 6.0, 0.1) # max roll time + _reg_f("Roll", "roll_chain_count", 2.0, 0.0, 8.0, 1.0) # extra matadors chained after the first _reg_f("Roll", "roll_base_speed", 28.0, 5.0, 120.0) # speed at the start of the roll _reg_f("Roll", "roll_max_speed", 95.0, 10.0, 200.0) # top speed once fully ramped _reg_f("Roll", "roll_rampup_time", 2.0, 0.2, 6.0, 0.1) # time to reach top speed - _reg_f("Roll", "roll_turn", 7.0, 0.2, 20.0) # steer rate (WASD/arrows; higher = tighter) + _reg_f("Roll", "roll_turn", 7.0, 0.2, 20.0) # homing turn rate (higher = tighter tracking) _reg_f("Roll", "roll_spin_radius", 1.4, 0.3, 4.0, 0.1) # ball radius for tumble spin (smaller = faster) _reg_f("Roll", "roll_hit_radius", 2.4, 0.5, 6.0, 0.1) # contact radius that pops the ball _reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) # horizontal launch on the pop @@ -250,6 +296,9 @@ func _register_all() -> void: _reg_b("Debug", "show_wireframe", false) _reg_b("Debug", "show_grid", false) _reg_b("Debug", "show_axes", false) + # Force the on-screen touch controls (joystick + ability buttons) on even on a + # desktop build — otherwise they appear only when a touchscreen is detected. + _reg_b("Touch", "force_touch_controls", false) func _reg_f(section: String, key: String, default: float, diff --git a/hud.gd b/hud.gd index 603efae..b28d67d 100644 --- a/hud.gd +++ b/hud.gd @@ -5,25 +5,25 @@ const _CREAM := Color(0.93, 0.88, 0.72) const _MUTED := Color(0.66, 0.60, 0.46) # Per-ability trim colour, tying each tile (and its key cap) to that ability's FX. -# Order matches _ABILITY_ICONS: kick, slam, dash, roll. +# Order matches _ABILITY_ICONS: kick, dash, slam, roll. const _ABILITY_ACCENT: Array[Color] = [ Color(1.00, 0.62, 0.16), # Kick — ember orange - Color(0.98, 0.34, 0.20), # Slam — crimson Color(0.45, 0.75, 1.00), # Dash — sky blue + Color(0.98, 0.34, 0.20), # Slam — crimson Color(1.00, 0.82, 0.32), # Roll — gold ] const _ABILITY_ICONS: Array[String] = [ "res://HUD/KICK.png", - "res://HUD/SLAM.png", "res://HUD/DASH.png", + "res://HUD/SLAM.png", "res://HUD/ROLL.png", ] const _ABILITY_ACTIONS: Array[StringName] = [ - &"ability_kick", &"ability_slam", &"ability_dash", &"ability_roll", + &"ability_kick", &"ability_dash", &"ability_slam", &"ability_roll", ] # Fallback label shown in a slot whose icon file fails to load. -const _ABILITY_NAMES: Array[String] = ["Kick", "Slam", "Dash", "Roll"] +const _ABILITY_NAMES: Array[String] = ["Kick", "Dash", "Slam", "Roll"] var _controls_visible: bool = false var _controls_panel: PanelContainer @@ -34,18 +34,43 @@ var _player: Node = null var _died_connected: bool = false var _cd_overlays: Array[ColorRect] = [] var _cd_labels: Array[Label] = [] +var _hp_label: Label +var _hp_segments: HBoxContainer +var _hp_seg_fills: Array[ColorRect] = [] +var _bottom_col: VBoxContainer var _ability_bar: HBoxContainer var _ability_panels: Array[PanelContainer] = [] +var _bull_bg: TextureRect + +# Red damage vignette — a full-screen shader ColorRect flashed on every hit. +const _VIGNETTE_SHADER := "res://damage_vignette.gdshader" +var _vignette: ColorRect +var _vignette_mat: ShaderMaterial + +# Bull-head backdrop for the gameplay HUD. The image is a wide horned silhouette; its +# solid "face" occupies the centred middle band, with horns flaring above. We size the +# image so that face band wraps the ability+HP cluster (horns crowning it above). +const _BULL_BG := "res://Assets/bull_hud_bg.png" +const _BULL_FACE_W: float = 0.529 # face width as a fraction of image width +const _BULL_ASPECT: float = 0.2741 # image height / width (267 / 974) +const _BULL_FACE_PAD: float = 1.15 # breathing room around the cluster within the face # Win/lose screen. _game_over latches so the result is shown once; _result_win # records which way it went (read by the gameplay test). const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs +# Beat between the run ending and the outcome screen. A loss is a short freeze on the +# dead bull; a win gives the player a longer stretch to strut a victory lap around the +# arena before the screen (and the pause) takes over. +const _LOSS_DELAY := 2.0 +const _WIN_DELAY := 3.0 + var _game_over: bool = false var _result_win: bool = false var _over_root: Control var _over_video: VideoStreamPlayer +var _over_label: Label func _ready() -> void: @@ -53,12 +78,85 @@ func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS _build_controls_panel() _build_tab_hint() + _build_bottom_hud() + _build_bull_hud_bg() _build_ability_bar() + _build_health() _build_version_label() + _build_damage_vignette() _build_game_over() + _build_touch_controls() + _update_control_hints() _find_spawner.call_deferred() +# On-screen joystick + ability buttons for touch devices. Self-gating: the overlay +# only shows itself when a touchscreen is present (or DP force_touch_controls is on), +# so this is a no-op on desktop. +func _build_touch_controls() -> void: + add_child((load("res://touch_controls.gd") as Script).new()) + + +# Bottom-centre column that groups the gameplay HUD (abilities on top, bull health +# directly beneath) so both read as one cluster instead of scattered corners. +func _build_bottom_hud() -> void: + _bottom_col = VBoxContainer.new() + _bottom_col.add_theme_constant_override("separation", 8) + _bottom_col.alignment = BoxContainer.ALIGNMENT_END + # Collapsed horizontal anchors so the column shrinks to its content and stays + # bottom-centred; the bull backdrop is sized against this content rect. + _bottom_col.anchor_left = 0.5 + _bottom_col.anchor_right = 0.5 + _bottom_col.anchor_top = 1.0 + _bottom_col.anchor_bottom = 1.0 + _bottom_col.grow_horizontal = Control.GROW_DIRECTION_BOTH + _bottom_col.grow_vertical = Control.GROW_DIRECTION_BEGIN + _bottom_col.offset_bottom = -16.0 + _bottom_col.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_bottom_col) + + +# Bull-head plate that sits behind the ability + health cluster. It's a sibling of the +# cluster (not a parent) so it never constrains the cluster's layout; we size and place +# it against the cluster's rect in _position_bull_bg. +func _build_bull_hud_bg() -> void: + _bull_bg = TextureRect.new() + if ResourceLoader.exists(_BULL_BG): + _bull_bg.texture = load(_BULL_BG) + # We size the rect ourselves to the image's aspect, so STRETCH_SCALE fills it + # exactly with no letterboxing. + _bull_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _bull_bg.stretch_mode = TextureRect.STRETCH_SCALE + _bull_bg.modulate.a = 0.95 + _bull_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_bull_bg) + # Draw the plate behind the cluster. + move_child(_bull_bg, _bottom_col.get_index()) + + _bottom_col.resized.connect(_position_bull_bg) + get_viewport().size_changed.connect(_position_bull_bg) + _position_bull_bg.call_deferred() + + +# Wrap the bull's solid "face" band around the current cluster rect: scale the image up +# so its face spans the cluster (plus padding), centre it horizontally on the cluster, +# and pin its bottom edge to the bottom of the screen so the plate is always seated flush +# against the screen edge on any resolution (horns crowning the cluster above). +func _position_bull_bg() -> void: + if _bull_bg == null or _bottom_col == null: + return + var r := _bottom_col.get_global_rect() + if r.size.x <= 1.0: + return + var vp_h := get_viewport().get_visible_rect().size.y + var bw := (r.size.x * _BULL_FACE_PAD) / _BULL_FACE_W + var bh := bw * _BULL_ASPECT + _bull_bg.size = Vector2(bw, bh) + _bull_bg.position = Vector2( + r.position.x + r.size.x * 0.5 - bw * 0.5, + vp_h - bh) + + # Unobtrusive early-build tag in the bottom-right corner. func _build_version_label() -> void: var lbl := Label.new() @@ -75,6 +173,7 @@ func _build_version_label() -> void: func _process(_delta: float) -> void: + _update_control_hints() if _player == null: var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): @@ -83,6 +182,9 @@ func _process(_delta: float) -> void: if not _died_connected: _died_connected = true _player.died.connect(_on_player_died) + _player.health_changed.connect(_on_health_changed) + _player.hit_taken.connect(_on_hit_taken) + _on_health_changed(_player.call(&"get_hp"), _player.call(&"get_max_hp")) for i: int in _cd_overlays.size(): var fraction: float = _player.call(&"ability_cooldown_fraction", i) var ov := _cd_overlays[i] @@ -129,9 +231,48 @@ func _restart() -> void: func _toggle_controls() -> void: _controls_visible = not _controls_visible - _controls_panel.visible = _controls_visible - _tab_hint.visible = not _controls_visible _toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" + _update_control_hints() + + +# The Tab hint and the keyboard controls plaque only make sense with a keyboard, so +# they stand down on touch devices — where touch_controls.gd shows the on-screen +# joystick and ability buttons instead. Shares Controls.use_touch_ui() with that +# overlay so exactly one control scheme shows on any given device. +func _update_control_hints() -> void: + if _game_over or Controls.use_touch_ui(): + _controls_panel.visible = false + _tab_hint.visible = false + else: + _controls_panel.visible = _controls_visible + _tab_hint.visible = not _controls_visible + + +# ── Damage vignette ─────────────────────────────────────────────────────────── +# A full-screen ColorRect driven by damage_vignette.gdshader: transparent centre, red +# toward the edges, alpha ("flash") tweened up on a hit and eased back to nothing. Built +# before the game-over layer so the outcome screen always draws over it. + +func _build_damage_vignette() -> void: + _vignette = ColorRect.new() + _vignette.set_anchors_preset(Control.PRESET_FULL_RECT) + _vignette.color = Color.WHITE # the shader writes COLOR outright; this is just a base + _vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE + if ResourceLoader.exists(_VIGNETTE_SHADER): + _vignette_mat = ShaderMaterial.new() + _vignette_mat.shader = load(_VIGNETTE_SHADER) + _vignette_mat.set_shader_parameter("flash", 0.0) + _vignette.material = _vignette_mat + add_child(_vignette) + + +func _on_hit_taken(_cause: String) -> void: + if _vignette_mat == null: + return + var set_flash := func(v: float) -> void: + _vignette_mat.set_shader_parameter("flash", v) + create_tween().tween_method(set_flash, 0.9, 0.0, 0.5)\ + .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) # ── Win / lose ────────────────────────────────────────────────────────────── @@ -157,8 +298,11 @@ func _show_game_over(win: bool, _cause: String = "") -> void: if _tab_hint: _tab_hint.visible = false - # Swap in the matching outcome clip and loop it behind the buttons. - var path := _WIN_VIDEO if win else _LOSS_VIDEO + await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout + + _over_label.text = "Bull Won!" if _result_win else "Matadors Won!" + + var path := _WIN_VIDEO if _result_win else _LOSS_VIDEO if ResourceLoader.exists(path): _over_video.stream = load(path) _over_video.play() @@ -179,13 +323,40 @@ func _build_game_over() -> void: add_child(_over_root) # Full-screen outcome clip (bull win / matador win), looping. + # AspectRatioContainer keeps the 16:9 video's ratio and crops overflow (COVER), + # so it fills any screen shape without stretching or leaving dead bars. + var arc := AspectRatioContainer.new() + arc.set_anchors_preset(Control.PRESET_FULL_RECT) + arc.ratio = 16.0 / 9.0 + arc.stretch_mode = AspectRatioContainer.STRETCH_COVER + arc.alignment_horizontal = AspectRatioContainer.ALIGNMENT_CENTER + arc.alignment_vertical = AspectRatioContainer.ALIGNMENT_CENTER + arc.process_mode = Node.PROCESS_MODE_ALWAYS + arc.mouse_filter = Control.MOUSE_FILTER_IGNORE + _over_root.add_child(arc) + _over_video = VideoStreamPlayer.new() _over_video.set_anchors_preset(Control.PRESET_FULL_RECT) _over_video.expand = true _over_video.process_mode = Node.PROCESS_MODE_ALWAYS _over_video.mouse_filter = Control.MOUSE_FILTER_IGNORE _over_video.finished.connect(_over_video.play) # loop the outcome clip - _over_root.add_child(_over_video) + arc.add_child(_over_video) + + # Outcome text centred over the video. + _over_label = Label.new() + _over_label.set_anchors_preset(Control.PRESET_CENTER_TOP) + _over_label.grow_horizontal = Control.GROW_DIRECTION_BOTH + _over_label.grow_vertical = Control.GROW_DIRECTION_END + _over_label.offset_top = 80.0 + _over_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _over_label.add_theme_font_override("font", UiFonts.title()) + _over_label.add_theme_font_size_override("font_size", 64) + _over_label.add_theme_color_override("font_color", _GOLD) + _over_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9)) + _over_label.add_theme_constant_override("outline_size", 6) + _over_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _over_root.add_child(_over_label) # Buttons overlaid at the bottom of the clip. var row := HBoxContainer.new() @@ -208,6 +379,97 @@ func _build_game_over() -> void: menu.pressed.connect(_return_to_menu) row.add_child(menu) +# ── Bull health ─────────────────────────────────────────────────────────────── +# A row of gold-socketed pips beneath the ability bar — one pip per HP, so raising or +# lowering max HP visibly grows or shrinks the row. Lit pips run red and shade toward +# crimson as the bull is worn down; spent pips read as empty dark sockets. + +const _HP_FULL: Color = Color(0.82, 0.16, 0.12) +const _HP_EMPTY: Color = Color(0.35, 0.05, 0.05) +const _HP_SOCKET: Color = Color(0.05, 0.02, 0.02, 0.92) + +const _HP_PIP_W: float = 22.0 +const _HP_PIP_H: float = 14.0 + + +func _build_health() -> void: + var panel := PanelContainer.new() + panel.add_theme_stylebox_override( + "panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0)) + panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _bottom_col.add_child(panel) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + panel.add_child(row) + + _hp_label = Label.new() + _hp_label.text = "BULL" + _hp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _hp_label.add_theme_font_override("font", UiFonts.body()) + _hp_label.add_theme_color_override("font_color", _GOLD) + _hp_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85)) + _hp_label.add_theme_constant_override("outline_size", 3) + _hp_label.add_theme_font_size_override("font_size", 14) + row.add_child(_hp_label) + + _hp_segments = HBoxContainer.new() + _hp_segments.add_theme_constant_override("separation", 4) + _hp_segments.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _hp_segments.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(_hp_segments) + + +# The gold-underlined socket behind each pip — shared, non-mutating, so one instance +# is safe across every pip. +func _hp_socket_style() -> StyleBoxFlat: + var s := StyleBoxFlat.new() + s.bg_color = _HP_SOCKET + s.set_corner_radius_all(3) + s.corner_detail = 4 + s.border_width_bottom = 2 + s.border_color = Color(UiTheme.GOLD.r, UiTheme.GOLD.g, UiTheme.GOLD.b, 0.45) + s.content_margin_left = 2.0 + s.content_margin_right = 2.0 + s.content_margin_top = 2.0 + s.content_margin_bottom = 2.0 + return s + + +func _rebuild_hp_pips(max_hp: int) -> void: + for child in _hp_segments.get_children(): + child.queue_free() + _hp_seg_fills.clear() + var socket_style := _hp_socket_style() + for _i: int in maxi(max_hp, 0): + var socket := PanelContainer.new() + socket.custom_minimum_size = Vector2(_HP_PIP_W, _HP_PIP_H) + socket.clip_contents = true + socket.add_theme_stylebox_override("panel", socket_style) + socket.mouse_filter = Control.MOUSE_FILTER_IGNORE + _hp_segments.add_child(socket) + + var fill := ColorRect.new() + fill.color = _HP_FULL + fill.mouse_filter = Control.MOUSE_FILTER_IGNORE + socket.add_child(fill) + _hp_seg_fills.append(fill) + + +func _on_health_changed(current: int, max_hp: int) -> void: + if _hp_segments == null: + return + if _hp_seg_fills.size() != max_hp: + _rebuild_hp_pips(max_hp) + var cur := maxi(current, 0) + var frac := 0.0 if max_hp <= 0 else clampf(float(cur) / float(max_hp), 0.0, 1.0) + var lit := _HP_FULL.lerp(_HP_EMPTY, 1.0 - frac) + for i: int in _hp_seg_fills.size(): + var fill := _hp_seg_fills[i] + fill.visible = i < cur + fill.color = lit + const _SLOT_SEPARATION: int = 10 @@ -215,14 +477,9 @@ const _SLOT_SEPARATION: int = 10 func _build_ability_bar() -> void: _ability_bar = HBoxContainer.new() _ability_bar.add_theme_constant_override("separation", _SLOT_SEPARATION) - _ability_bar.anchor_left = 0.5 - _ability_bar.anchor_right = 0.5 - _ability_bar.anchor_top = 1.0 - _ability_bar.anchor_bottom = 1.0 - _ability_bar.grow_horizontal = Control.GROW_DIRECTION_BOTH - _ability_bar.grow_vertical = Control.GROW_DIRECTION_BEGIN - _ability_bar.offset_bottom = -20.0 - add_child(_ability_bar) + _ability_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + _ability_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + _bottom_col.add_child(_ability_bar) for i: int in 4: _ability_bar.add_child(_build_ability_slot(i)) _layout_ability_bar() @@ -230,20 +487,17 @@ func _build_ability_bar() -> void: # Size the ability slots as a fraction of the viewport (clamped) so the bar scales -# with the window, and keep it horizontally centred by matching the HBox offsets. +# with the window; the bottom column keeps the bar horizontally centred. func _layout_ability_bar() -> void: var vp := get_viewport().get_visible_rect().size var slot := clampf(minf(vp.x * 0.055, vp.y * 0.11), 56.0, 104.0) for panel: PanelContainer in _ability_panels: panel.custom_minimum_size = Vector2(slot, slot) - var total := slot * 4.0 + float(_SLOT_SEPARATION) * 3.0 - _ability_bar.offset_left = -total * 0.5 - _ability_bar.offset_right = total * 0.5 func _build_ability_slot(idx: int) -> Control: var vbox := VBoxContainer.new() - vbox.add_theme_constant_override("separation", 4) + vbox.add_theme_constant_override("separation", 3) var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(72.0, 72.0) @@ -298,19 +552,48 @@ func _build_ability_slot(idx: int) -> Control: stack.add_child(cd_lbl) _cd_labels.append(cd_lbl) + # Keycap tucked directly beneath the tile — a solid dark plate wearing the + # ability's accent underline, so it reads as attached to its box without ever + # covering the artwork. Centred under the tile and tightly spaced. + var keycap := PanelContainer.new() + keycap.add_theme_stylebox_override("panel", _keycap_style(idx)) + keycap.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + keycap.mouse_filter = Control.MOUSE_FILTER_IGNORE + vbox.add_child(keycap) + var key_lbl := Label.new() - key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx]) - key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - key_lbl.add_theme_font_override("font", UiFonts.body()) - key_lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[idx].lerp(_CREAM, 0.35)) - key_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85)) + key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx]) + key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + key_lbl.add_theme_font_override("font", UiFonts.body_bold()) + key_lbl.add_theme_color_override("font_color", _CREAM) + key_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9)) key_lbl.add_theme_constant_override("outline_size", 3) - key_lbl.add_theme_font_size_override("font_size", 13) - vbox.add_child(key_lbl) + key_lbl.add_theme_font_size_override("font_size", 14) + keycap.add_child(key_lbl) return vbox +# Solid keycap plate for an ability bind: near-black fill with the ability's accent +# as a chunky top edge, so the accent bridges up to the tile it sits under (reads as +# attached) while the key stays on a high-contrast background against busy scenes. +func _keycap_style(idx: int) -> StyleBoxFlat: + var s := StyleBoxFlat.new() + s.bg_color = Color(0.04, 0.03, 0.02, 0.94) + s.set_corner_radius_all(4) + s.corner_detail = 4 + s.set_border_width_all(1) + s.border_width_top = 3 + s.border_color = _ABILITY_ACCENT[idx] + s.content_margin_left = 7.0 + s.content_margin_right = 7.0 + s.content_margin_top = 2.0 + s.content_margin_bottom = 1.0 + return s + + func _build_tab_hint() -> void: _tab_hint = Label.new() _tab_hint.text = "Press 'Tab' to see controls" @@ -353,8 +636,8 @@ func _build_controls_panel() -> void: ] _row(vbox, move_keys, "Move", _CREAM) _row(vbox, Controls.get_key_label(&"ability_kick"), "Kick", _CREAM) - _row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM) _row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM) + _row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM) _row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (Powerball: ram the matador!)", _CREAM) _row(vbox, "Scroll", "Zoom", _CREAM) _row(vbox, "Tab", "Toggle controls", _MUTED) diff --git a/main_menu.gd b/main_menu.gd index 16e27eb..8d05d79 100644 --- a/main_menu.gd +++ b/main_menu.gd @@ -57,23 +57,26 @@ const ACTION_LABELS: Dictionary = { &"move_left": "Move Left", &"move_right": "Move Right", &"ability_kick": "Kick", - &"ability_slam": "Slam", &"ability_dash": "Dash", + &"ability_slam": "Slam", &"ability_roll": "Roll", } @onready var main_panel: Control = $MainPanel @onready var options_panel: Control = $OptionsPanel @onready var credits_panel: Control = $CreditsPanel +@onready var options_vbox: VBoxContainer = $OptionsPanel/VBox +@onready var options_scroll: ScrollContainer = $OptionsPanel/VBox/Scroll @onready var rebind_list: VBoxContainer = $OptionsPanel/VBox/Scroll/RebindList @onready var fade_rect: ColorRect = $FadeRect -@onready var play_button: Button = $MainPanel/PlayButton +@onready var play_button: Button = $VideoAspect/CapeLayer/PlayButton @onready var options_button: Button = $MainPanel/OptionsButton @onready var credits_button: Button = $MainPanel/CreditsButton @onready var exit_button: Button = $MainPanel/ExitButton @onready var options_back: Button = $OptionsPanel/VBox/BackButton @onready var credits_back: Button = $CreditsPanel/VBox/BackButton -@onready var video: VideoStreamPlayer = $VideoStreamPlayer +@onready var video: VideoStreamPlayer = $VideoAspect/VideoStreamPlayer +@onready var blur_video: VideoStreamPlayer = $VideoBlur/BlurVideo var _listening_action: StringName = &"" var _listening_button: Button = null @@ -87,6 +90,9 @@ func _ready() -> void: if video.stream != null: video.play() video.finished.connect(video.play) # loop the background clip + if blur_video.stream != null: + blur_video.play() + blur_video.finished.connect(blur_video.play) # loop the blurred backdrop play_button.pressed.connect(_on_play_pressed) options_button.pressed.connect(_on_options_pressed) @@ -96,6 +102,7 @@ func _ready() -> void: credits_back.pressed.connect(_close_subpanels) _apply_theme() _build_version_label() + _build_options_toggles() _build_rebind_rows() play_button.grab_focus() @@ -183,6 +190,40 @@ func _process(_delta: float) -> void: play_button.anchor_bottom = cape.y +# Player-facing option toggles, seated above the rebind list in the Options panel: +# the master screen-shake switch and the gore toggle (both persisted via Settings). +func _build_options_toggles() -> void: + var scroll_idx := options_scroll.get_index() + # Insert in reverse so both land directly above the rebind scroll, in listed order. + _add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, scroll_idx) + _add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, scroll_idx) + + +func _add_toggle_row(label_text: String, value: bool, setter: Callable, at_index: int) -> void: + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var name_label := Label.new() + name_label.text = label_text + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + name_label.add_theme_font_override("font", UiFonts.body()) + name_label.add_theme_font_size_override("font_size", 20) + name_label.add_theme_color_override("font_color", MENU_CREAM) + name_label.add_theme_color_override("font_outline_color", Color.BLACK) + name_label.add_theme_constant_override("outline_size", 5) + row.add_child(name_label) + + var toggle := CheckButton.new() + toggle.button_pressed = value + toggle.add_theme_color_override("font_color", MENU_GOLD) + toggle.toggled.connect(func(on: bool) -> void: setter.call(on)) + row.add_child(toggle) + + options_vbox.add_child(row) + options_vbox.move_child(row, at_index) + + func _build_rebind_rows() -> void: for child in rebind_list.get_children(): child.queue_free() @@ -221,12 +262,14 @@ func _on_play_pressed() -> void: func _on_options_pressed() -> void: main_panel.visible = false + play_button.visible = false options_panel.visible = true options_back.grab_focus() func _on_credits_pressed() -> void: main_panel.visible = false + play_button.visible = false credits_panel.visible = true credits_back.grab_focus() @@ -240,6 +283,7 @@ func _close_subpanels() -> void: options_panel.visible = false credits_panel.visible = false main_panel.visible = true + play_button.visible = true play_button.grab_focus() diff --git a/matador.gd b/matador.gd index 8ba10f3..6245a79 100644 --- a/matador.gd +++ b/matador.gd @@ -22,6 +22,7 @@ var _sim: PhysicalBoneSimulator3D = null var _anim_player: AnimationPlayer = null var _wander_target: Vector3 = Vector3.ZERO var _idle_timer: float = 0.0 +var _taunt_swap: float = 0.0 var _idle_anim: StringName = _ANIM_IDLE var _bull: CharacterBody3D = null var _step_dir: Vector3 = Vector3.ZERO @@ -50,8 +51,8 @@ var _drawing: bool = false var _sheathing: bool = false var _draw_timer: float = 0.0 var _draw_len: float = 0.0 -var _resword_timer: float = 0.0 var _sword_blade_area: Area3D = null +var _spear_node: Node3D = null var _blade_hit_cd: float = 0.0 var _death_player: AudioStreamPlayer = null var _blood_burst: CPUParticles3D = null @@ -74,6 +75,9 @@ const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"] const _DRAW_GRAB_AT: float = 0.55 const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb") +const _SPEAR_SCENE: PackedScene = preload("res://Assets/spear.glb") + +const _GRAVITY: float = 9.8 # arena gravity, for the spear's ballistic arc const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec @@ -95,8 +99,9 @@ func _ready() -> void: if _skeleton: _sim = MatadorRagdoll.build(_skeleton) if _anim_player: - for anim in [_ANIM_RUN, _ANIM_ATTACK, _ANIM_ROLL] + _ANIM_TAUNTS: + for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS: _ensure_loop(anim) + _flatten_roll_travel() if not _anim_player.has_animation(_ANIM_RUN): push_warning("Matador: expected animations not found. Available: %s" % str(_anim_player.get_animation_list())) @@ -104,7 +109,7 @@ func _ready() -> void: _anim_player.play(_ANIM_IDLE) _setup_sword() DP.any_changed.connect(_on_dp_changed) - _throw_aim_cd = randf_range(0.3, 1.0) + _throw_aim_cd = randf_range(2.0, 5.0) _hit_area.body_entered.connect(_on_body_entered) _pick_wander_target() _blood_burst = preload("res://blood_burst.gd").new() @@ -148,12 +153,6 @@ func _physics_process(delta: float) -> void: _dodge_cd = maxf(0.0, _dodge_cd - delta) _blade_hit_cd = maxf(0.0, _blade_hit_cd - delta) _throw_aim_cd = maxf(0.0, _throw_aim_cd - delta) - # Pull a replacement sword from the holster a beat after throwing the last one. - if _state != State.RAGDOLL and not is_instance_valid(_sword_node) \ - and (_sword_rest_attach != null or _sword_hand_attach != null): - _resword_timer -= delta - if _resword_timer <= 0.0: - _spawn_sword() if _state != State.RAGDOLL and _bull != null: _update_ai_state(delta) @@ -216,20 +215,30 @@ func _choose_intent() -> Intent: var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \ and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range") + # Only the nearest few matadors work the bull at once; the rest hang back and + # taunt (see _engagement_rank). This turns a crowd into a bullring — matadors + # spreading out and taking turns — instead of a swarm all piling on together. + var is_engager := _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))) + # Supporting matadors strut and circle constantly; the lead only struts between + # passes, once the bull is back out past horn reach. + var strut := 0.65 if not is_engager else 0.3 * beyond + var engage_mult := 1.0 if is_engager else 0.15 + var score := { - # Only drift when the bull is out past throw range; up close this is ~0. - Intent.WANDER: DP.f("mat_w_showmanship") * 0.3 * beyond, + # Showmanship: circle the bull at a taunting distance and pose for the crowd. + Intent.WANDER: DP.f("mat_w_showmanship") * strut, # Retreat only when the bull is close AND actually dangerous. Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger), - # Press the attack; ease off a touch when a charge is barrelling in. + # Press the attack; ease off a touch when a charge is barrelling in. Supporting + # matadors barely press — they yield the pass to whoever's engaging. Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \ - * (1.0 - 0.55 * danger) * dodge_ready, + * (1.0 - 0.55 * danger) * dodge_ready * engage_mult, # Sidestep / plant against an incoming charge. Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0), - # Hurl the blade whenever there's a clean lane at range — the ranged threat - # that punishes a bull for hanging back out of horn reach. - Intent.THROW: ((DP.f("mat_w_showmanship") * 0.55 + DP.f("mat_w_aggression") * 0.6) \ - * (0.35 + 0.65 * (1.0 - close))) if throw_ok else -1.0, + # Hurl the blade — an occasional ranged flourish from an engaging matador when + # there's a clean lane, not a barrage (see mat_w_throw). Supporters never pelt. + Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \ + if (throw_ok and is_engager) else -1.0, } score[_intent] = float(score[_intent]) + DP.f("mat_w_commit") @@ -277,6 +286,40 @@ func _enter_intent(intent: Intent) -> void: _start_throw() +# How many other live matadors are closer to the bull than this one. 0 = the +# closest (the natural lead engager); the utility brain gives the nearest +# mat_engage_slots the go-ahead to press while the rest spread out and taunt. +func _engagement_rank() -> int: + if _bull == null: + return 0 + var my_d := global_position.distance_squared_to(_bull.global_position) + var rank := 0 + for node: Node in get_tree().get_nodes_in_group(&"matador"): + if node == self: + continue + var other := node as Node3D + if other == null or not is_instance_valid(other) or not other.call(&"is_active"): + continue + if other.global_position.distance_squared_to(_bull.global_position) < my_d: + rank += 1 + return rank + + +# Live and vying for the bull's attention — a ragdolled corpse doesn't hold a slot. +func is_active() -> bool: + return _state != State.RAGDOLL + + +# True when this matador is safely out of the action — not one of the engaging few, +# and with the bull far enough off to strike a long, unhurried taunt for the crowd. +func _out_of_harm() -> bool: + if _bull == null: + return true + if _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))): + return false + return global_position.distance_to(_bull.global_position) >= DP.f("mat_taunt_ring") * 0.6 + + # Bull is moving fast and aimed within ~30° of the matador func _is_charge_incoming() -> bool: var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) @@ -351,6 +394,14 @@ func _tick_wander(delta: float) -> void: if _drawing or _sheathing or _idle_timer > 0.0: _idle_timer = maxf(0.0, _idle_timer - delta) _decelerate(10.0, delta) + # Taunt toward the bull so the posturing plays to it, not off into space. + if _bull != null and _idle_timer > 0.0: + _face_point(_bull.global_position, delta, _TURN_MOVE) + # Cycle gestures through a long hold so a posing matador stays lively. + _taunt_swap -= delta + if _taunt_swap <= 0.0 and not _drawing and not _sheathing: + _idle_anim = _pick_taunt() + _taunt_swap = randf_range(1.5, 3.0) _play_anim(_idle_anim) move_and_slide() return @@ -358,8 +409,12 @@ func _tick_wander(delta: float) -> void: var to_target := Vector3( _wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z) if to_target.length() < 0.8: - _idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) + # A matador out of harm's way settles into a long taunt for the crowd; one still + # in the mix only pauses briefly between passes. + _idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3) \ + if _out_of_harm() else randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) _idle_anim = _pick_taunt() + _taunt_swap = randf_range(1.5, 3.0) _pick_wander_target() else: var dir := _steer_clear(to_target.normalized(), delta) @@ -528,7 +583,8 @@ func _start_throw() -> void: _throw_timer = DP.f("mat_throw_windup") velocity = Vector3.ZERO _cancel_transitions() - _carry_sword(true) # blade must be in hand for the wind-up and release + _carry_sword(false) # the sword is melee-only — holster it for the throw + _spawn_spear_in_hand() # a fresh spear is drawn for the overhand throw if _anim_player: _anim_player.pause() var to_bull := _bull.global_position - global_position @@ -552,8 +608,8 @@ func _tick_throw(delta: float) -> void: _pose_throw_arm(phase) var t_rel := DP.f("mat_throw_release") - if prev < t_rel and phase >= t_rel and is_instance_valid(_sword_node): - _release_sword() + if prev < t_rel and phase >= t_rel and is_instance_valid(_spear_node): + _release_spear() if _throw_timer <= 0.0: _end_throw() @@ -562,20 +618,18 @@ func _tick_throw(delta: float) -> void: func _end_throw() -> void: _state = State.FLEE + _clear_spear() # drop any spear still in hand if the wind-up was cut short _dodge_cd = DP.f("mat_dodge_cooldown") - # Space throws out so the utility brain doesn't immediately vote another one, - # but keep them frequent enough to pressure a bull that hangs back at range. - _throw_aim_cd = randf_range(1.6, 3.2) + # Space throws well apart so a hurl stays a flourish, not a barrage. + _throw_aim_cd = randf_range(4.0, 7.0) _intent = Intent.FLEE - # Blend straight into locomotion so the manually-posed throw arm eases out - # (a bare resume() would snap and leave the player on whatever it paused on). + # Blend to locomotion so the manually-posed throw arm eases out (resume() snaps). if _anim_player: _anim_player.play(_ANIM_RUN) # Drive arm_R + forearm_R through a cock-back → release arc while the -# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md rig notes); -# angles are DP-tunable so the arc can be dialled in per rig. +# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md), DP-tunable. func _pose_throw_arm(phase: float) -> void: if not _skeleton: return @@ -605,82 +659,59 @@ func _set_bone_swing(bone: String, deg: float) -> void: _skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg))) -# Detach the sword from the hand and launch it as a spinning physics projectile -# that damages the bull on contact, then settles and despawns. -func _release_sword() -> void: - if not is_instance_valid(_sword_node): +# Draw a fresh spear into the throwing hand for the wind-up (no melee collider). +func _spawn_spear_in_hand() -> void: + if is_instance_valid(_spear_node) or _sword_hand_attach == null: return - var sword := _sword_node - var blade := _sword_blade_area - var gx := sword.global_transform - _sword_node = null - _sword_blade_area = null - _sword_in_hand = false - _resword_timer = DP.f("mat_resword_delay") - # The RigidBody owns collisions now — silence the melee blade Area it carries. - if is_instance_valid(blade): - blade.monitoring = false + _spear_node = _SPEAR_SCENE.instantiate() as Node3D + _sword_hand_attach.add_child(_spear_node) + _spear_node.position = Vector3( + DP.f("spear_pos_x"), DP.f("spear_pos_y"), DP.f("spear_pos_z")) + _spear_node.rotation_degrees = Vector3( + DP.f("spear_rot_x"), DP.f("spear_rot_y"), DP.f("spear_rot_z")) - var rb := RigidBody3D.new() - rb.collision_layer = 0 - rb.collision_mask = 1 - rb.contact_monitor = true - rb.max_contacts_reported = 6 - # Fly nearly straight to the aim point instead of lobbing — the old horizontal - # throw sailed clean over the bull's low body. A small gravity_scale keeps a - # touch of drop for feel without dropping short. - rb.gravity_scale = DP.f("mat_throw_gravity") - get_tree().current_scene.add_child(rb) - rb.global_transform = gx - sword.reparent(rb, true) - # Physics collider along the blade (GLB +Z), which equals rb-local +Z because - # the mesh kept its global transform and rb adopted it. Fattened so a fast throw - # reliably overlaps the bull's collision spheres instead of tunnelling past. - var cap := CapsuleShape3D.new() - cap.radius = 0.14 - cap.height = 1.4 - var cs := CollisionShape3D.new() - cs.shape = cap - cs.position = Vector3(0.0, 0.0, 0.7) - cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) - rb.add_child(cs) +func _clear_spear() -> void: + if is_instance_valid(_spear_node): + _spear_node.queue_free() + _spear_node = null - # Aim at the bull's BODY (its collision spheres ride low, ~0.5 m below the - # origin), leading a moving target so it arrives where the bull will be. The aim - # keeps its vertical component so the blade drives into the body, not over it. - var speed := maxf(DP.f("mat_throw_speed"), 0.1) - var dir := (_throw_dir + Vector3.UP * 0.0) + +# Hurl the held spear on a ballistic arc that lands on the bull's low body. The +# launch velocity is solved so gravity (mat_throw_gravity) carries it down onto the +# lead point; SpearProjectile aims the shaft along the arc and embeds it on a hit. +func _release_spear() -> void: + if not is_instance_valid(_spear_node): + return + var spear := _spear_node + var origin := spear.global_position + _spear_node = null + + var hs := maxf(DP.f("mat_throw_speed"), 0.1) # horizontal speed + var g := _GRAVITY * maxf(DP.f("mat_throw_gravity"), 0.05) + # Aim low (the bull's collision spheres ride ~0.5 m below its origin) and lead it + # across the arc's flight time. + var target := origin + _throw_dir * 5.0 if is_instance_valid(_bull): - var target := _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0) - var flat := target - gx.origin - flat.y = 0.0 - var flight := flat.length() / speed - target += _bull.velocity * flight - var to_target := target - gx.origin - if to_target.length() > 0.1: - dir = to_target.normalized() - # A little aim scatter so throws are a threat to READ and dodge, not a hitscan — - # a bull that keeps moving can slip them, a stationary one gets pinned. + target = _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0) + var lead := target - origin + lead.y = 0.0 + target += _bull.velocity * (lead.length() / hs) + var flat := target - origin + var rise := flat.y + flat.y = 0.0 + var dist := flat.length() + var hdir := flat.normalized() if dist > 0.01 else _throw_dir + # Aim scatter so the arc can be read and sidestepped, not a hitscan. var spread := deg_to_rad(DP.f("mat_throw_spread")) - dir = dir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized() - rb.linear_velocity = dir * speed - rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin") - - var hit_done := [false] - rb.body_entered.connect(func(body: Node) -> void: - if hit_done[0]: - return - if body.is_in_group(&"player"): - hit_done[0] = true - body.call(&"take_sword_hit", "thrown") - ) - - var cleanup := get_tree().create_timer(6.0) - cleanup.timeout.connect(func() -> void: - if is_instance_valid(rb): - rb.queue_free() - ) + hdir = hdir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized() + # Ballistic solve: with horizontal speed hs over distance dist, the flight lasts t; + # pick the vertical launch speed that lands the arc on the aim point's height. + var t := dist / hs + var vy := (rise + 0.5 * g * t * t) / t + var vel := hdir * hs + Vector3.UP * vy + SpearProjectile.launch(get_tree().current_scene, spear, origin, vel, + maxf(DP.f("mat_throw_gravity"), 0.05)) func _tick_ragdoll(_delta: float) -> void: @@ -756,6 +787,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) _state = State.RAGDOLL if _sword_blade_area: _sword_blade_area.monitoring = false + _clear_spear() # a spear mid-wind-up dies with the matador, not in the air killed.emit() var cleanup_timer := get_tree().create_timer(4.0) cleanup_timer.timeout.connect(func() -> void: @@ -866,6 +898,32 @@ func _ensure_loop(anim_name: StringName) -> void: anim.loop_mode = Animation.LOOP_LINEAR +# Roll was authored *travelling* (COG drives ~4.5 m forward on Z), which snapped the +# mesh back when the clip ended. _tick_roll moves the dodge, so strip that COG travel. +func _flatten_roll_travel() -> void: + if _anim_player == null or not _anim_player.has_animation(_ANIM_ROLL): + return + var anim := _anim_player.get_animation(_ANIM_ROLL) + if anim.has_meta(&"_travel_flattened"): + return + anim.set_meta(&"_travel_flattened", true) + var cog := anim.find_track(NodePath("Armature/Skeleton3D:COG"), Animation.TYPE_POSITION_3D) + if cog < 0: + return + # Snapshot the COG travel before mutating (flattening COG first would zero it). + var travel: Dictionary = {} + for i in anim.get_track_count(): + if anim.track_get_type(i) == Animation.TYPE_POSITION_3D: + for k in anim.track_get_key_count(i): + var t := anim.track_get_key_time(i, k) + travel[t] = anim.position_track_interpolate(cog, t).z + for i in anim.get_track_count(): + if anim.track_get_type(i) == Animation.TYPE_POSITION_3D: + for k in anim.track_get_key_count(i): + var v: Vector3 = anim.track_get_key_value(i, k) + anim.track_set_key_value(i, k, v - Vector3(0.0, 0.0, travel[anim.track_get_key_time(i, k)])) + + func _pick_taunt() -> StringName: var choices: Array[StringName] = [] for anim in _ANIM_TAUNTS: @@ -893,13 +951,25 @@ func _play_anim(anim_name: StringName) -> void: func _pick_wander_target() -> void: - # 35% chance: pick a point orbiting close to the bull for a daring pass - if _bull != null and randf() < 0.35: - var angle := randf() * TAU - var r := randf_range(1.5, 4.0) + # Circle the bull for showmanship: mostly settle on the taunt ring (spreading the + # crowd out around it), with an occasional close pass for daring flair. + if _bull != null: var bull_flat := _bull.global_position bull_flat.y = 0.0 + var r: float + if randf() < 0.2: + r = randf_range(1.5, 4.0) # close daring pass + else: + var ring := DP.f("mat_taunt_ring") + r = randf_range(ring * 0.7, ring * 1.3) # the showmanship ring + var angle := randf() * TAU _wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r) + # Keep the pose inside the fighting floor rather than backing onto the benches. + var arena_r := DP.f("arena_spawn_radius") + var flat := Vector2(_wander_target.x, _wander_target.z) + if flat.length() > arena_r: + flat = flat.normalized() * arena_r + _wander_target = Vector3(flat.x, 0.0, flat.y) return var radius := DP.f("mat_wander_radius") var angle := randf() * TAU @@ -909,13 +979,10 @@ func _pick_wander_target() -> void: # ── Sword attachment ────────────────────────────────────────────────────────── -# The rig (matador_v03) carries two purpose-built bones for the sword: -# weapon_bone — the grip pose in the right hand (drawn / fighting) -# weapon_rest_bone — the holstered pose at the hip (sheathed / wandering) -# The sword lives in the holster by default and is carried to the hand while the -# matador is in a combat state (see _carry_sword). Each bone has its own -# DP-tunable local offset/rotation (sword_* for the hand, sword_rest_* for the -# holster) so the two poses can be seated independently without re-exporting. +# The rig (matador_v03) carries two purpose-built bones: weapon_bone (right-hand +# grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is +# carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable +# local offset/rotation (sword_* / sword_rest_*) so the poses seat independently. func _setup_sword() -> void: if not _skeleton: return @@ -934,8 +1001,7 @@ func _setup_sword() -> void: _spawn_sword() -# Instantiate a fresh sword into the holster, with its own blade collider. Called -# at spawn and again after a throw, so a matador can keep drawing replacements. +# Instantiate the matador's sword into the holster, with its own blade collider. func _spawn_sword() -> void: if is_instance_valid(_sword_node): return @@ -949,15 +1015,14 @@ func _spawn_sword() -> void: _drawing = false _apply_sword_grip() - # Blade collider spans the steel (hilt at local origin, blade running +Z out - # to ~1.4 m). Only monitors while the sword is in hand (see _carry_sword). + # Blade collider along +Z (hilt at origin), tipped from the capsule's default Y. + # Only monitors while the sword is in hand (see _carry_sword). var blade_cap := CapsuleShape3D.new() blade_cap.radius = 0.18 blade_cap.height = 1.30 var blade_cs := CollisionShape3D.new() blade_cs.shape = blade_cap blade_cs.position = Vector3(0.0, 0.0, 0.8) - # CapsuleShape3D runs along Y by default; tip it onto +Z to follow the blade. blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) _sword_blade_area = Area3D.new() @@ -969,12 +1034,11 @@ func _spawn_sword() -> void: _sword_blade_area.body_entered.connect(_on_blade_hit) -# The sword is carried by ACTION, not distance: drawn only while striking -# (ATTACK / THROW) and holstered while running, dodging, bracing or taunting. -# THROW snaps the blade out (its arm cock-back covers the motion); everything -# else animates with the Draw_weapon clip — forward to draw, reversed to sheathe. +# The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding +# (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead +# (see _spawn_spear_in_hand), so THROW keeps the sword holstered. func _wants_sword_drawn() -> bool: - return _state == State.ATTACK or _state == State.THROW \ + return _state == State.ATTACK \ or _state == State.BRACE or _state == State.SIDESTEP @@ -1019,8 +1083,7 @@ func _begin_sheathe() -> void: _anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true) -# Progress a draw or sheathe; the blade reparents at the grab point (mirrored for -# the reverse sheathe), and the transition ends when the clip's runtime elapses. +# Progress a draw or sheathe; the blade reparents at the grab point (mirrored on the reverse). func _advance_draw(delta: float) -> void: if not _drawing and not _sheathing: return @@ -1049,9 +1112,7 @@ func _draw_speed() -> float: return maxf(DP.f("mat_draw_speed"), 0.1) -# Move the sword between the hand grip and the hip holster. Reparenting follows -# whichever bone the BoneAttachment tracks; the local grip transform is reapplied -# so the seating is identical in both sockets. +# Move the sword between hand grip and hip holster, reapplying the local seating. func _carry_sword(in_hand: bool) -> void: if not is_instance_valid(_sword_node): return diff --git a/matador_spawn.gd b/matador_spawn.gd index 0c02ce3..3361c61 100644 --- a/matador_spawn.gd +++ b/matador_spawn.gd @@ -7,8 +7,14 @@ const MATADOR := preload("res://Matador.tscn") var _spawn_count: int = 1 var _alive: int = 0 +var _kills: int = 0 var _resolved: bool = false +# Flat (x,z) positions of the most recent batch spawned. A fresh wave keeps at least +# _SPAWN_MIN_SEP away from these so it never lands on top of where the last one did. +const _SPAWN_MIN_SEP: float = 3.5 +var _last_spawn_positions: Array[Vector3] = [] + func _ready() -> void: add_to_group(&"matador_spawn") @@ -23,8 +29,10 @@ func _spawn() -> void: var arena_r := DP.f("arena_spawn_radius") var bull_angle := randf() * TAU _place_bull(bull_angle, arena_r) - for pos: Vector3 in _spread_positions(_spawn_count, bull_angle + PI, arena_r): + var placed: Array[Vector3] = _spread_positions(_spawn_count, bull_angle + PI, arena_r) + for pos: Vector3 in placed: _spawn_at(pos) + _last_spawn_positions = placed func _place_bull(angle: float, arena_r: float) -> void: @@ -36,34 +44,99 @@ func _place_bull(angle: float, arena_r: float) -> void: ) -# Positions for the matadors, clustered on the far side (centred on `center_angle`) -# so every matador stays near the point diametrically opposite the bull. +# Positions for the matadors, spread across the far side (centred on `center_angle`) +# so every matador stays near the point diametrically opposite the bull. Each slot gets +# its own angular band and a staggered radius, then rejection-samples inside that band so +# no two matadors land closer than _SPAWN_MIN_SEP — otherwise they pile up on top of +# each other on spawn. The arc widens with the crowd so a big wave doesn't get cramped. func _spread_positions(count: int, center_angle: float, arena_r: float) -> Array[Vector3]: var positions: Array[Vector3] = [] if count <= 1: positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r)) return positions - var arc := TAU * 0.33 + var arc := clampf(TAU * 0.33 + float(count) * 0.25, TAU * 0.33, TAU * 0.92) + var slot := arc / float(count) + var inner := maxf(2.5, arena_r * 0.5) for i: int in count: - var t := float(i) / float(count - 1) - 0.5 # -0.5 .. 0.5 across the arc - var angle := center_angle + t * arc - var radius := randf_range(arena_r * 0.7, arena_r) - positions.append(Vector3(cos(angle) * radius, 1.0, sin(angle) * radius)) + # Give each matador its own angular slot for an even fan, but sample the radius + # freely across the whole far-side band so the rejection test can push crowded + # neighbours onto a different ring instead of piling them at one distance. + var base_angle := center_angle - arc * 0.5 + slot * (float(i) + 0.5) + var pos := Vector3(cos(base_angle) * arena_r, 1.0, sin(base_angle) * arena_r) + for attempt: int in 16: + var angle := base_angle + randf_range(-0.5, 0.5) * slot + var radius := randf_range(inner, arena_r) + pos = Vector3(cos(angle) * radius, 1.0, sin(angle) * radius) + if _clear_of(pos, positions, _SPAWN_MIN_SEP): + break + positions.append(pos) return positions -func _spawn_at(pos: Vector3) -> void: +func _spawn_at(pos: Vector3, generation: int = 0) -> void: var m: Node3D = MATADOR.instantiate() m.position = pos + m.set_meta(&"generation", generation) get_parent().add_child(m) - m.killed.connect(_on_matador_killed) + m.killed.connect(_on_matador_killed.bind(m)) _alive += 1 -# Every matador down = the bull wins. No waves, no respawns — one clean arena. -func _on_matador_killed() -> void: +# A slain matador is replaced by mat_split_factor fresh ones on the spot (a hydra), +# so the horde doubles each generation, up to mat_split_generations deep. The bull +# wins only when a whole generation dies out without splitting — i.e. once the spawns +# stop and the last body drops. +func _on_matador_killed(dead: Node3D) -> void: matador_killed.emit() _alive -= 1 - if _alive <= 0 and not _resolved: + _kills += 1 + _split(dead) + var win_kills := int(DP.f("mat_win_kills")) + var reached_quota := win_kills > 0 and _kills >= win_kills + if (reached_quota or _alive <= 0) and not _resolved: _resolved = true all_defeated.emit() + + +func _split(dead: Node3D) -> void: + var count := int(DP.f("mat_split_factor")) + var generation := int(dead.get_meta(&"generation", 0)) + if count < 2 or generation >= int(DP.f("mat_split_generations")): + return + var arena_r := DP.f("arena_spawn_radius") + var origin := dead.global_position + origin.y = 0.0 + var wave: Array[Vector3] = [] + for i: int in count: + var pos := _pick_spawn(origin, arena_r, wave) + wave.append(pos) + _spawn_at(pos, generation + 1) + _last_spawn_positions = wave + + +# Rejection-sample a spawn point ringed around `origin`, pushing the ring outward on +# each failed attempt until it clears both the previous wave (_last_spawn_positions) +# and the spots already taken this wave — so a fresh wave never lands where the last +# one did. Falls back to the last candidate if the arena is too cramped to satisfy it. +func _pick_spawn(origin: Vector3, arena_r: float, taken: Array[Vector3]) -> Vector3: + var pos := origin + for attempt: int in 12: + var angle := randf() * TAU + var r := randf_range(2.0, 3.5) + float(attempt) * 0.4 + pos = origin + Vector3(cos(angle) * r, 0.0, sin(angle) * r) + var flat := Vector2(pos.x, pos.z) + if flat.length() > arena_r: + flat = flat.normalized() * arena_r + pos = Vector3(flat.x, 0.0, flat.y) + if _clear_of(pos, _last_spawn_positions, _SPAWN_MIN_SEP) \ + and _clear_of(pos, taken, _SPAWN_MIN_SEP): + break + pos.y = 1.0 + return pos + + +func _clear_of(pos: Vector3, others: Array[Vector3], min_sep: float) -> bool: + for o: Vector3 in others: + if Vector2(pos.x - o.x, pos.z - o.z).length() < min_sep: + return false + return true diff --git a/menu_blur.gdshader b/menu_blur.gdshader new file mode 100644 index 0000000..9f5480e --- /dev/null +++ b/menu_blur.gdshader @@ -0,0 +1,25 @@ +shader_type canvas_item; + +// Blurred, darkened backdrop for the intro video. A COVER-scaled copy of the +// clip sits behind the sharp fitted clip so portrait/ultrawide screens fill +// edge-to-edge instead of showing dead bars. + +uniform float blur_radius : hint_range(0.0, 8.0) = 3.0; +uniform float darken : hint_range(0.0, 1.0) = 0.35; + +void fragment() { + vec2 px = TEXTURE_PIXEL_SIZE * blur_radius; + vec4 sum = vec4(0.0); + // 3x3 gaussian-ish tap, weights sum to 16. + sum += texture(TEXTURE, UV + vec2(-px.x, -px.y)) * 1.0; + sum += texture(TEXTURE, UV + vec2(0.0, -px.y)) * 2.0; + sum += texture(TEXTURE, UV + vec2(px.x, -px.y)) * 1.0; + sum += texture(TEXTURE, UV + vec2(-px.x, 0.0)) * 2.0; + sum += texture(TEXTURE, UV) * 4.0; + sum += texture(TEXTURE, UV + vec2(px.x, 0.0)) * 2.0; + sum += texture(TEXTURE, UV + vec2(-px.x, px.y)) * 1.0; + sum += texture(TEXTURE, UV + vec2(0.0, px.y)) * 2.0; + sum += texture(TEXTURE, UV + vec2(px.x, px.y)) * 1.0; + sum /= 16.0; + COLOR = vec4(sum.rgb * (1.0 - darken), sum.a); +} diff --git a/menu_blur.gdshader.uid b/menu_blur.gdshader.uid new file mode 100644 index 0000000..44a1eb2 --- /dev/null +++ b/menu_blur.gdshader.uid @@ -0,0 +1 @@ +uid://bmdtg6qlh7wri diff --git a/player.gd b/player.gd index 15efa2a..d8cc15b 100644 --- a/player.gd +++ b/player.gd @@ -27,16 +27,23 @@ var _pre_slide_vel_y: float = 0.0 var _slam_ring_mesh: ArrayMesh = null -# Ability system — kick=0, slam=1, dash=2, roll=3 +# Ability system — kick=0, dash=1, slam=2, roll=3 var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0] var _ability_active: bool = false var _ability_timer: float = 0.0 var _active_ability: int = -1 var _dash_dir: Vector3 = Vector3.ZERO +var _dash_speed_cur: float = 0.0 var _roll_dir: Vector3 = Vector3.ZERO var _roll_ramp: float = 0.0 var _roll_spin: float = 0.0 +var _roll_target: Node3D = null +var _roll_chains_left: int = 0 +var _roll_hit: Array[Node] = [] var _bull_anim: AnimationPlayer = null +# Locomotion animation state: true while the looping DASH clip is driving WASD +# movement, false while idling. Flipping it cross-fades between the two clips. +var _locomotion_moving: bool = false var _slam_pending: bool = false var _charge_trail_emitter: CPUParticles3D = null @@ -49,6 +56,17 @@ var _cube_scale: Vector3 = Vector3.ONE signal died(cause: String) var _dead: bool = false +# HP system — the bull soaks several clean hits instead of dying to the first. +# Each landed blade drops one pip and starts an i-frame window (so one pass can't +# empty the bar); at 0 the run ends via `died`. The HUD listens on `health_changed`. +signal health_changed(current: int, max_hp: int) +# Fired on every landed hit (after i-frames pass) so the HUD can flash the damage +# vignette; `cause` mirrors take_sword_hit ("gored" / "thrown"). +signal hit_taken(cause: String) +var _max_hp: int = 10 +var _hp: int = 10 +var _hit_iframes: float = 0.0 + var _huff_player: AudioStreamPlayer = null var _crowd_player: AudioStreamPlayer = null var _huff_timer: float = 0.0 @@ -61,6 +79,8 @@ var _charge_dust_ramp: Gradient = null func _ready() -> void: add_to_group(&"player") + _max_hp = maxi(1, int(DP.f("bull_max_hp"))) + _hp = _max_hp _cube_scale = cube_guy.scale _setup_hoof_dust() _setup_legs() @@ -258,8 +278,9 @@ func _find_anim_player(node: Node) -> AnimationPlayer: func _setup_bull_idle() -> void: if _bull_anim == null: return - # IDLE and ROLL both loop as continuous states; the ability clips are one-shot. - for clip: StringName in [_ANIM_IDLE, _ANIM_ROLL]: + # IDLE, DASH and ROLL loop as continuous states (DASH doubles as the WASD + # locomotion cycle); the remaining ability clips are one-shot. + for clip: StringName in [_ANIM_IDLE, _ANIM_DASH, _ANIM_ROLL]: if _bull_anim.has_animation(clip): _bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR _play_idle() @@ -268,6 +289,7 @@ func _setup_bull_idle() -> void: # Return to the looping idle (and undo any ability speed-scale). Cross-fades so a # finishing ability clip eases back to idle instead of snapping. func _play_idle() -> void: + _locomotion_moving = false if _bull_anim == null: return _bull_anim.speed_scale = 1.0 @@ -275,25 +297,56 @@ func _play_idle() -> void: _bull_anim.play(_ANIM_IDLE, 0.2) +# Drive the WASD locomotion clip: the looping DASH doubles as the run cycle while a +# direction is held, easing back to idle when the bull coasts to a stop. Cross-fades +# on each transition so idle↔run (and steering into it after an ability) blend rather +# than snap. Abilities own the animation while active, so this yields to them. +func _update_locomotion_anim(moving: bool) -> void: + if _ability_active or _bull_anim == null or moving == _locomotion_moving: + return + _locomotion_moving = moving + if moving: + if _bull_anim.has_animation(_ANIM_DASH): + _bull_anim.speed_scale = 1.0 + _bull_anim.play(_ANIM_DASH, 0.25) + else: + _play_idle() + + func _unhandled_input(event: InputEvent) -> void: + if _dead: + return # Abilities can be chained: a new one interrupts whatever is currently # active, gated only by each ability's own cooldown. - if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0: - _activate_kick() - elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0: - _activate_slam() - elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0: - _activate_dash() - elif event.is_action_pressed(&"ability_roll") and ability_cd[3] <= 0.0: - _activate_roll() + if event.is_action_pressed(&"ability_kick"): + try_activate_ability(0) + elif event.is_action_pressed(&"ability_dash"): + try_activate_ability(1) + elif event.is_action_pressed(&"ability_slam"): + try_activate_ability(2) + elif event.is_action_pressed(&"ability_roll"): + try_activate_ability(3) + + +# Public ability entry point shared by keyboard/gamepad input and the on-screen +# touch buttons. Slot order matches ability_cd — kick=0, dash=1, slam=2, roll=3. +# No-ops if the bull is dead or the slot is still cooling down. +func try_activate_ability(slot: int) -> void: + if _dead or slot < 0 or slot >= ability_cd.size() or ability_cd[slot] > 0.0: + return + match slot: + 0: _activate_kick() + 1: _activate_dash() + 2: _activate_slam() + 3: _activate_roll() func ability_cooldown_fraction(slot: int) -> float: var maxcd := 0.0 match slot: 0: maxcd = DP.f("kick_cooldown") - 1: maxcd = DP.f("slam_cooldown") - 2: maxcd = DP.f("dash_cooldown") + 1: maxcd = DP.f("dash_cooldown") + 2: maxcd = DP.f("slam_cooldown") 3: maxcd = DP.f("roll_cooldown") return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0 @@ -306,9 +359,9 @@ func _activate_kick() -> void: ability_cd[0] = DP.f("kick_cooldown") _ability_active = true _active_ability = 0 - _ability_timer = 0.6 + _ability_timer = 0.3 if _bull_anim: - _bull_anim.speed_scale = 1.0 + _bull_anim.speed_scale = 2.0 if _bull_anim and _bull_anim.has_animation(_ANIM_KICK): _bull_anim.play(_ANIM_KICK) _ability_timer = _bull_anim.get_animation(_ANIM_KICK).length @@ -321,11 +374,12 @@ func _activate_kick() -> void: func _activate_slam() -> void: - ability_cd[1] = DP.f("slam_cooldown") + ability_cd[2] = DP.f("slam_cooldown") _ability_active = true - _active_ability = 1 + _active_ability = 2 _legs.set(&"tail_ragdoll", true) _slam_pending = true + SpearProjectile.shed_all.call_deferred(self) # the leap shakes off any stuck spears var jump_vel := DP.f("slam_jump_velocity") velocity.y = jump_vel # Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)). @@ -339,9 +393,9 @@ func _activate_slam() -> void: func _activate_dash() -> void: - ability_cd[2] = DP.f("dash_cooldown") + ability_cd[1] = DP.f("dash_cooldown") _ability_active = true - _active_ability = 2 + _active_ability = 1 _ability_timer = 0.45 if _bull_anim: _bull_anim.speed_scale = 1.0 @@ -350,8 +404,9 @@ func _activate_dash() -> void: _ability_timer = _bull_anim.get_animation(_ANIM_DASH).length var raw := cube_guy.global_transform.basis.z _dash_dir = Vector3(raw.x, 0.0, raw.z).normalized() - velocity.x = _dash_dir.x * DP.f("dash_speed") - velocity.z = _dash_dir.z * DP.f("dash_speed") + _dash_speed_cur = DP.f("dash_speed") + velocity.x = _dash_dir.x * _dash_speed_cur + velocity.z = _dash_dir.z * _dash_speed_cur _spawn_dash_burst() _legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) if _huff_player: @@ -366,10 +421,16 @@ func _activate_roll() -> void: _ability_timer = DP.f("roll_duration") _roll_ramp = 0.0 _roll_spin = 0.0 - # Curl into the ball along the current heading (or facing when standing still) - # and launch at the base speed — it ramps up from here the longer you roll. + _roll_chains_left = int(DP.f("roll_chain_count")) + _roll_hit.clear() + # Lock onto the closest matador and curl into the ball aimed at it; if the arena + # is empty, launch along the current heading (or facing when standing still). + _roll_target = _roll_pick_target() var flat := Vector3(velocity.x, 0.0, velocity.z) - if flat.length() > 1.0: + if _roll_target != null: + var to_t := _roll_target.global_position - global_position + _roll_dir = Vector3(to_t.x, 0.0, to_t.z).normalized() + elif flat.length() > 1.0: _roll_dir = flat.normalized() else: var f := cube_guy.global_transform.basis.z @@ -392,14 +453,20 @@ func _activate_roll() -> void: _huff_player.play() -# One rolling frame (Rammus Powerball): steer with momentum, accelerate the longer -# the roll lasts, move (walls just slide), then pop the ball on the first matador we -# ram. Fully replaces the normal locomotion path while active. -func _tick_roll(delta: float, steer: Vector3) -> void: - var steer_flat := Vector3(steer.x, 0.0, steer.z) - if steer_flat.length() > 0.1: - var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0) - _roll_dir = _roll_dir.slerp(steer_flat.normalized(), t).normalized() +# One rolling frame: home on the current target matador, accelerate the longer the +# roll lasts, move (walls just slide), then pop the ball on contact and chain to the +# next closest. Fully replaces the normal locomotion path while active. `steer` is +# ignored — the roll auto-targets — but kept so the caller stays uniform. +func _tick_roll(delta: float, _steer: Vector3) -> void: + # Reacquire if the target ragdolled or was freed before we reached it. + if not _roll_target_valid(): + _roll_target = _roll_pick_target() + if _roll_target != null: + var to_t := _roll_target.global_position - global_position + var flat := Vector3(to_t.x, 0.0, to_t.z) + if flat.length() > 0.1: + var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0) + _roll_dir = _roll_dir.slerp(flat.normalized(), t).normalized() # Speed builds over time: base → max across roll_rampup_time, so a long roll # winds up into a runaway boulder while a quick tap barely gets going. @@ -431,25 +498,64 @@ func _tick_roll(delta: float, steer: Vector3) -> void: # Pop the ball on ramming a matador: launch every matador in the hit radius up and -# outward, then end the roll (Rammus stops when Powerball connects). Returns true if -# it popped so the caller can stop touching roll state this frame. +# outward, then either CHAIN to the next closest (if chains remain and a target is +# left) or end the roll. Returns true if it popped so the caller can stop touching +# roll state this frame. func _roll_try_pop(speed: float) -> bool: var radius := DP.f("roll_hit_radius") for mat: Node in get_tree().get_nodes_in_group(&"matador"): + if _roll_hit.has(mat) or not _mat_active(mat): + continue var to_mat: Vector3 = (mat as Node3D).global_position - global_position to_mat.y = 0.0 if to_mat.length() > radius: continue + # Record everyone this pop reaches so the chain never re-picks a downed body. + for m: Node in get_tree().get_nodes_in_group(&"matador"): + var d: Vector3 = (m as Node3D).global_position - global_position + d.y = 0.0 + if d.length() <= radius and not _roll_hit.has(m): + _roll_hit.append(m) _hit_matadors_radius(radius, DP.f("roll_hit_strength"), DP.f("roll_knockup")) camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0)) if _huff_player: _huff_player.pitch_scale = randf_range(1.05, 1.35) _huff_player.play() + if _roll_chains_left > 0: + var next := _roll_pick_target() + if next != null: + _roll_chains_left -= 1 + _roll_target = next + return true # keep rolling — the ball carries on to the next target _end_roll() return true return false +# Closest active matador we haven't already popped this roll, or null if none left. +func _roll_pick_target() -> Node3D: + var best: Node3D = null + var best_d := INF + for mat: Node in get_tree().get_nodes_in_group(&"matador"): + if _roll_hit.has(mat) or not _mat_active(mat): + continue + var d: float = (mat as Node3D).global_position.distance_squared_to(global_position) + if d < best_d: + best_d = d + best = mat as Node3D + return best + + +func _roll_target_valid() -> bool: + return is_instance_valid(_roll_target) and _mat_active(_roll_target) + + +# A matador still in play — valid, not ragdolled/consumed. Ragdolled bodies report +# is_active() == false, so a popped target is skipped on the next chain search. +func _mat_active(mat: Node) -> bool: + return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active") + + # End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to # the normal locomotion path next frame. func _end_roll() -> void: @@ -457,6 +563,9 @@ func _end_roll() -> void: _active_ability = -1 _ability_timer = 0.0 _roll_ramp = 0.0 + _roll_target = null + _roll_chains_left = 0 + _roll_hit.clear() _legs.set(&"charge_pitch_target", 0.0) _legs.set(&"tail_ragdoll", false) _legs.set(&"external_rotation", false) @@ -805,6 +914,19 @@ func _make_slam_ring_mesh() -> ArrayMesh: func _physics_process(delta: float) -> void: + # ── Dead bull ───────────────────────────────────────────────────────────── + # The run is over the instant HP hits zero, but the lose screen holds off a + # beat before it appears (see HUD._show_game_over). Freeze the carcass through + # that delay — no input, no abilities, just coast to a stop under gravity — so a + # dead bull can't keep charging around while the screen is pending. + if _dead: + velocity.x = move_toward(velocity.x, 0.0, 40.0 * delta) + velocity.z = move_toward(velocity.z, 0.0, 40.0 * delta) + if not is_on_floor(): + velocity += get_gravity() * delta + move_and_slide() + return + # ── Gravity ─────────────────────────────────────────────────────────────── if not is_on_floor(): var gravity := get_gravity() @@ -824,6 +946,7 @@ func _physics_process(delta: float) -> void: var on_floor := is_on_floor() # ── Ability cooldowns ──────────────────────────────────────────────────────── + _hit_iframes = maxf(0.0, _hit_iframes - delta) for i: int in 4: ability_cd[i] = maxf(0.0, ability_cd[i] - delta) if _ability_active: @@ -856,7 +979,13 @@ func _physics_process(delta: float) -> void: # input is released. var flat_vel := Vector3(velocity.x, 0.0, velocity.z) var max_speed := DP.f("max_speed") - if direction.length_squared() > 0.01: + if _active_ability == 1 and _ability_active: + # Dash: leave the barrel fast like a bullet, then bleed exponentially back + # down to cruise speed (fastest drop right at the start), so it settles into + # normal running by the time the clip ends instead of holding top speed. + _dash_speed_cur = lerpf(_dash_speed_cur, max_speed, 1.0 - exp(-DP.f("dash_decel") * delta)) + flat_vel = _dash_dir * _dash_speed_cur + elif direction.length_squared() > 0.01: var cap := maxf(max_speed, flat_vel.length()) flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta) else: @@ -871,6 +1000,9 @@ func _physics_process(delta: float) -> void: cube_guy.rotation.y = lerp_angle( cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed")) + # Looping DASH clip while genuinely moving on the ground, idle otherwise. + _update_locomotion_anim(on_floor and flat_speed > 1.0) + _pre_slide_vel_y = velocity.y var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z) move_and_slide() @@ -925,11 +1057,76 @@ func _physics_process(delta: float) -> void: # ── Hoof sounds ─────────────────────────────────────────────────────────── -# A single clean hit — thrown or swung blade — ends the run. `cause` ("gored" / -# "thrown") tags how it happened for the death notice. Latches so the lose screen is -# raised exactly once even if several blades land on the same frame. -func take_sword_hit(cause: String = "") -> void: - if _dead: +func get_hp() -> int: + return _hp + + +func get_max_hp() -> int: + return _max_hp + + +# A clean hit — thrown or swung blade — costs the bull one HP pip; the run ends only +# when the last pip is gone. `cause` ("gored" / "thrown") tags how it happened for the +# death notice. I-frames absorb a flurry so several blades in one pass drop one pip, +# not the whole bar; `_dead` latches so the lose screen is raised exactly once. +func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void: + if _dead or _hit_iframes > 0.0: return - _dead = true - died.emit(cause) + _hp -= 1 + _hit_iframes = DP.f("bull_hit_iframes") + health_changed.emit(_hp, _max_hp) + hit_taken.emit(cause) + camera_pivot.call(&"trigger_hit", 0.7) + # A spear gores a spot — blood spurts from the entry wound (where it struck the + # bull). Melee gores pass no position and skip the spurt; gore can be turned off. + if cause == "thrown" and Settings.gore: + _spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position) + if _huff_player: + _huff_player.pitch_scale = randf_range(0.7, 0.9) + _huff_player.play() + if _hp <= 0: + _dead = true + died.emit(cause) + + +# Blood spurt from a spear wound: a short-lived spray of dark-red gobs flung out of the +# entry point, biased outward from the bull's centre (so it reads as bursting from the +# hide) with a touch of lift before gravity drags it down. +func _spawn_blood(hit_pos: Vector3) -> void: + var out := hit_pos - global_position + out.y = 0.0 + out = out.normalized() if out.length() > 0.05 else Vector3(0.0, 0.0, 1.0) + var dir := (out + Vector3.UP * 0.8).normalized() + + var sphere := _particle_sphere(0.05, 4, 2, _unshaded_material()) + + var ramp := Gradient.new() + ramp.set_color(0, Color(0.55, 0.02, 0.02, 1.0)) + ramp.set_color(1, Color(0.28, 0.0, 0.0, 0.0)) + + var scale_curve := Curve.new() + scale_curve.add_point(Vector2(0.0, 1.0)) + scale_curve.add_point(Vector2(0.6, 0.7)) + scale_curve.add_point(Vector2(1.0, 0.0)) + + var p := CPUParticles3D.new() + p.one_shot = true + p.explosiveness = 0.9 + p.amount = 34 + p.lifetime = 0.7 + p.randomness = 0.5 + p.local_coords = false + p.direction = dir + p.spread = 42.0 + p.gravity = Vector3(0.0, -14.0, 0.0) + p.initial_velocity_min = 3.5 + p.initial_velocity_max = 8.0 + p.scale_amount_min = 0.5 + p.scale_amount_max = 1.6 + p.mesh = sphere + p.color_ramp = ramp + p.scale_amount_curve = scale_curve + get_parent().add_child(p) + p.global_position = hit_pos + p.restart() + _free_after(p, 1.5) diff --git a/project.godot b/project.godot index 8f82754..2a0407f 100644 --- a/project.godot +++ b/project.godot @@ -11,11 +11,15 @@ config_version=5 [application] config/name="Bullosseum" -config/version="0.1.0" +config/version="0.2.0" run/main_scene="res://MainMenu.tscn" config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" config/run/main_scene="res://MainMenu.tscn" +boot_splash/bg_color=Color(0, 0, 0, 1) +boot_splash/image="res://Assets/loading_screen.png" +boot_splash/fullsize=true +boot_splash/use_filter=true [autoload] @@ -23,6 +27,7 @@ DP="*res://debug_params.gd" Console="*res://console.gd" Controls="*res://controls_manager.gd" DebugDraw="*res://debug_draw.gd" +Settings="*res://settings.gd" [editor_plugins] @@ -102,6 +107,10 @@ ability_roll={ ] } +[input_devices] + +pointing/emulate_touch_from_mouse=true + [physics] 3d/physics_engine="Jolt Physics" diff --git a/settings.gd b/settings.gd new file mode 100644 index 0000000..59eaecd --- /dev/null +++ b/settings.gd @@ -0,0 +1,49 @@ +extends Node +## Player-facing game options (autoload `Settings`), persisted to user://settings.cfg. +## Distinct from DP (debug tunables) and Controls (keybinds): this is the small set of +## comfort/accessibility toggles the options menu exposes to the player. + +signal changed(key: String, value: Variant) + +const SAVE_PATH := "user://settings.cfg" +const SECTION := "options" + +## Master screen-shake toggle — gates every camera_iso.trigger_hit (hits and ability FX). +var screen_shake: bool = true +## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed). +var gore: bool = true + + +func _ready() -> void: + load_saved() + + +func set_screen_shake(value: bool) -> void: + if screen_shake == value: + return + screen_shake = value + changed.emit("screen_shake", value) + save() + + +func set_gore(value: bool) -> void: + if gore == value: + return + gore = value + changed.emit("gore", value) + save() + + +func save() -> void: + var cfg := ConfigFile.new() + cfg.set_value(SECTION, "screen_shake", screen_shake) + cfg.set_value(SECTION, "gore", gore) + cfg.save(SAVE_PATH) + + +func load_saved() -> void: + var cfg := ConfigFile.new() + if cfg.load(SAVE_PATH) != OK: + return + screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool + gore = cfg.get_value(SECTION, "gore", true) as bool diff --git a/settings.gd.uid b/settings.gd.uid new file mode 100644 index 0000000..b8df580 --- /dev/null +++ b/settings.gd.uid @@ -0,0 +1 @@ +uid://dyspgr7m238la diff --git a/spear_projectile.gd b/spear_projectile.gd new file mode 100644 index 0000000..510ebcf --- /dev/null +++ b/spear_projectile.gd @@ -0,0 +1,206 @@ +class_name SpearProjectile +extends RigidBody3D + +# A thrown spear. It flies a ballistic arc (gravity does the work — the launch +# velocity is solved by the matador to land on the bull) and keeps its shaft aimed +# along its own velocity, so it noses over at the apex and drives in point-first. +# On impact it embeds where it struck: the tip buried just past the contact point +# with the shaft trailing back along the flight line, so it reads as stuck-in rather +# than floating alongside. A hit on the bull leaves the spear riding it like a spine +# (see shed_all, which the bull's slam calls to fling them all off); other hits pin +# it in place until the despawn timer clears it. + +const _DESPAWN_SEC: float = 8.0 +const _STUCK_GROUP: StringName = &"stuck_spear" +# Shaft geometry in spear.glb local space: tip at +Z, butt behind the origin. +const _TIP_Z: float = 1.915 +const _EMBED_DEPTH: float = 0.45 # how far past the surface the tip buries + +var _mesh: Node3D = null +var _stopped: bool = false +var _flight_dir: Vector3 = Vector3.ZERO # last pre-impact travel direction + + +# Build, seat and launch a spear from an already-instantiated mesh (the one that was +# in the matador's hand). `origin` is the release point; `velocity` is the full +# ballistic launch vector; `gravity` is the RigidBody gravity_scale for the arc. +static func launch(parent: Node, mesh: Node3D, origin: Vector3, velocity: Vector3, + gravity: float) -> SpearProjectile: + var s := SpearProjectile.new() + s.gravity_scale = gravity + s.collision_layer = 0 + s.collision_mask = 1 + s.contact_monitor = true + s.max_contacts_reported = 6 + parent.add_child(s) + # Seat the body so its local +Z (the shaft, see spear.glb) points along the throw. + s.global_transform = Transform3D(_aim_basis(velocity), origin) + mesh.reparent(s, false) + mesh.transform = Transform3D.IDENTITY + s._mesh = mesh + s.add_child(_shaft_collider()) + s.linear_velocity = velocity + _despawn(s) + return s + + +# Shed every spear stuck in `bull` — pop each off as a tumbling body that flies clear +# and clears itself on the despawn timer. Called by the bull's slam. +static func shed_all(bull: Node) -> void: + if not (bull is Node3D): + return + var scene: Node = bull.get_tree().current_scene + if scene == null: + scene = bull + var center: Vector3 = (bull as Node3D).global_position + for child in bull.get_children(): + if not child.is_in_group(_STUCK_GROUP): + continue + var mesh := child as Node3D + var gx := mesh.global_transform + var rb := RigidBody3D.new() + rb.collision_layer = 0 + rb.collision_mask = 1 + scene.add_child(rb) + rb.global_transform = gx + mesh.reparent(rb, false) + mesh.transform = Transform3D.IDENTITY + mesh.remove_from_group(_STUCK_GROUP) + rb.add_child(_shaft_collider()) + # Fling outward from the bull with a bit of lift and spin. + var out := gx.origin - center + out.y = 0.0 + out = out.normalized() if out.length() > 0.01 else Vector3(randf() - 0.5, 0.0, randf() - 0.5) + rb.linear_velocity = out * randf_range(3.0, 6.0) + Vector3.UP * randf_range(3.0, 5.0) + rb.angular_velocity = Vector3(randf_range(-10.0, 10.0), + randf_range(-10.0, 10.0), randf_range(-10.0, 10.0)) + _despawn(rb) + + +static func _shaft_collider() -> CollisionShape3D: + # Collider along local +Z (mesh runs z≈-0.34 … 1.92), fattened so a fast throw + # can't tunnel past the bull's collision spheres. + var cap := CapsuleShape3D.new() + cap.radius = 0.13 + cap.height = 2.2 + var cs := CollisionShape3D.new() + cs.shape = cap + cs.position = Vector3(0.0, 0.0, 0.79) + cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) + return cs + + +# Continuous bleed from a spear lodged in the bull: a steady spurt of dark-red gobs out +# of the entry wound. Parented to the stuck mesh so it tracks the wound as the bull +# moves; world-space particles (local_coords off) so they arc off and fall behind as a +# trail. Dies with the mesh when the spear is shed or despawned. +static func _wound_emitter() -> CPUParticles3D: + var ramp := Gradient.new() + ramp.set_color(0, Color(0.5, 0.02, 0.02, 1.0)) + ramp.set_color(1, Color(0.22, 0.0, 0.0, 0.0)) + + var sc := Curve.new() + sc.add_point(Vector2(0.0, 1.0)) + sc.add_point(Vector2(0.6, 0.7)) + sc.add_point(Vector2(1.0, 0.0)) + + var p := CPUParticles3D.new() + # Seat at the wound (where the shaft meets the hide) and spray back out along the + # shaft: mesh local -Z is the exit direction, rotated into world by the node basis. + p.position = Vector3(0.0, 0.0, _TIP_Z - _EMBED_DEPTH) + p.emitting = true + p.amount = 22 + p.lifetime = 0.6 + p.explosiveness = 0.0 + p.randomness = 0.6 + p.local_coords = false + p.direction = Vector3(0.0, 0.55, -1.0).normalized() + p.spread = 32.0 + p.gravity = Vector3(0.0, -12.0, 0.0) + p.initial_velocity_min = 1.6 + p.initial_velocity_max = 4.0 + p.scale_amount_min = 0.5 + p.scale_amount_max = 1.4 + p.mesh = _blood_mesh() + p.color_ramp = ramp + p.scale_amount_curve = sc + return p + + +# Small unshaded, vertex-coloured sphere for blood gobs. +static func _blood_mesh() -> SphereMesh: + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.vertex_color_use_as_albedo = true + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + var s := SphereMesh.new() + s.radius = 0.045 + s.height = 0.09 + s.radial_segments = 4 + s.rings = 2 + s.material = mat + return s + + +static func _despawn(node: Node) -> void: + node.get_tree().create_timer(_DESPAWN_SEC).timeout.connect(func() -> void: + if is_instance_valid(node): + node.queue_free() + ) + + +# Basis whose local +Z points along `v` (looking_at points -Z at its target). +static func _aim_basis(v: Vector3) -> Basis: + var dir := v.normalized() + if dir.length_squared() < 0.0001: + return Basis.IDENTITY + var up := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD + return Basis.looking_at(-dir, up) + + +# While flying, keep the shaft aligned with the velocity; on the first contact freeze +# and hand off to _embed so the spear sticks where it struck instead of bouncing off. +func _integrate_forces(state: PhysicsDirectBodyState3D) -> void: + if _stopped: + return + if state.get_contact_count() > 0: + _stopped = true + # Use the remembered pre-impact heading — state.linear_velocity is already the + # bounced (post-collision) velocity by now, which would aim the shaft wrong. + var contact := state.get_contact_collider_position(0) + var other := state.get_contact_collider_object(0) + state.linear_velocity = Vector3.ZERO + state.angular_velocity = Vector3.ZERO + _embed.call_deferred(other, contact, _flight_dir) + return + var v := state.linear_velocity + if v.length_squared() > 0.04: + _flight_dir = v.normalized() + var t := state.transform + t.basis = _aim_basis(v) + state.transform = t + state.angular_velocity = Vector3.ZERO + + +# Compose the stuck pose (tip buried _EMBED_DEPTH past the contact, shaft along the +# flight line) and plant the spear. A bull hit hands the mesh to the bull so it rides +# along as a spine; anything else pins the body in place for the despawn timer. +func _embed(other: Object, contact: Vector3, flight: Vector3) -> void: + var f := flight.normalized() + if f.length_squared() < 0.0001: + f = global_transform.basis.z.normalized() + var pose := Transform3D(_aim_basis(f), contact + f * (_EMBED_DEPTH - _TIP_Z)) + var bull := other as Node + if bull != null and bull.is_in_group(&"player"): + bull.call(&"take_sword_hit", "thrown", contact) + if is_instance_valid(_mesh) and is_instance_valid(bull): + _mesh.reparent(bull, false) + _mesh.global_transform = pose + _mesh.add_to_group(_STUCK_GROUP) + if Settings.gore: + _mesh.add_child(_wound_emitter()) # keep bleeding from the wound while lodged + queue_free() + return + freeze = true + global_transform = pose diff --git a/spear_projectile.gd.uid b/spear_projectile.gd.uid new file mode 100644 index 0000000..d82a9ae --- /dev/null +++ b/spear_projectile.gd.uid @@ -0,0 +1 @@ +uid://dwjakh1is1jtj diff --git a/tests/gameplay_test.gd b/tests/gameplay_test.gd index fcdf1f0..dead65f 100644 --- a/tests/gameplay_test.gd +++ b/tests/gameplay_test.gd @@ -243,9 +243,21 @@ func _check_game_over_wiring(scene: Node) -> void: _assert_true(spawners.is_empty() or spawners[0].has_signal(&"all_defeated"), "spawner exposes an all_defeated signal (win route)") + # The bull now soaks bull_max_hp hits. Zero the i-frame window so the test can + # land consecutive blades, then confirm it survives every hit but the last. + var dp: Node = root.get_node_or_null("/root/DP") + if dp != null: + dp.set_value("bull_hit_iframes", 0.0) + var max_hp: int = player.call(&"get_max_hp") + for i: int in max_hp - 1: + player.take_sword_hit() + await create_timer(0.05).timeout + _assert_true(not hud._game_over, "hit %d of %d does not end the run" % [i + 1, max_hp]) + _assert_true(player.call(&"get_hp") == 1, "bull sits at 1 HP before the killing blow") + player.take_sword_hit() await create_timer(0.05).timeout - _assert_true(hud._game_over, "a single sword hit raises the game-over screen") + _assert_true(hud._game_over, "draining the last HP raises the game-over screen") _assert_true(not hud._result_win, "a bull hit is a loss, not a win") diff --git a/tools/preview/aerial.png b/tools/preview/aerial.png new file mode 100644 index 0000000..d233dd3 Binary files /dev/null and b/tools/preview/aerial.png differ diff --git a/tools/preview/aerial.png.import b/tools/preview/aerial.png.import new file mode 100644 index 0000000..60b9dd7 --- /dev/null +++ b/tools/preview/aerial.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cf0mk32r40fbb" +path="res://.godot/imported/aerial.png-6f0c31ec14797100c056557b8d5f36d8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://tools/preview/aerial.png" +dest_files=["res://.godot/imported/aerial.png-6f0c31ec14797100c056557b8d5f36d8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/tools/preview/top.png b/tools/preview/top.png new file mode 100644 index 0000000..c16c717 Binary files /dev/null and b/tools/preview/top.png differ diff --git a/tools/preview/top.png.import b/tools/preview/top.png.import new file mode 100644 index 0000000..3850144 --- /dev/null +++ b/tools/preview/top.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://qx85guumsl7p" +path="res://.godot/imported/top.png-1fab202f4dd35d955a9e4ea2d0fefe58.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://tools/preview/top.png" +dest_files=["res://.godot/imported/top.png-1fab202f4dd35d955a9e4ea2d0fefe58.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/tools/preview_topdown.gd b/tools/preview_topdown.gd new file mode 100644 index 0000000..fea7074 --- /dev/null +++ b/tools/preview_topdown.gd @@ -0,0 +1,65 @@ +extends SceneTree +## Top-down + angled aerial of the arena with the crowd, plus axis markers, to locate +## the ruined section and any billboards left floating over it. +## godot --script tools/preview_topdown.gd +## Output: tools/preview/top.png (ortho top-down), tools/preview/aerial.png + +const OUT := "res://tools/preview" + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + DirAccess.make_dir_recursive_absolute(OUT) + var scene := (load("res://scene.tscn") as PackedScene).instantiate() + root.add_child(scene) + current_scene = scene + + # Axis markers: red pillar at +X (east), green at +Z (south), so directions read. + _marker(scene, Vector3(18, 5, 0), Color(1, 0.2, 0.2)) + _marker(scene, Vector3(0, 5, 18), Color(0.2, 1, 0.2)) + + await create_timer(0.4).timeout + var crowd := scene.get_node_or_null(^"CrowdBillboards") as MultiMeshInstance3D + if crowd != null and crowd.multimesh != null: + print("crowd instances = ", crowd.multimesh.instance_count) + + var top := Camera3D.new() + top.projection = Camera3D.PROJECTION_ORTHOGONAL + top.size = 46.0 + scene.add_child(top) + top.look_at_from_position(Vector3(0, 60, 0.01), Vector3.ZERO, Vector3(0, 0, -1)) + top.current = true + await create_timer(0.2).timeout + _shot("top.png") + + var air := Camera3D.new() + air.fov = 60.0 + scene.add_child(air) + air.look_at_from_position(Vector3(0, 26, 34), Vector3(0, 3, 0), Vector3.UP) + air.current = true + await create_timer(0.2).timeout + _shot("aerial.png") + quit(0) + + +func _marker(scene: Node, pos: Vector3, col: Color) -> void: + var m := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3(1.5, 10.0, 1.5) + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.albedo_color = col + box.material = mat + m.mesh = box + scene.add_child(m) + m.global_position = pos + + +func _shot(name: String) -> void: + var img := root.get_viewport().get_texture().get_image() + if img != null: + img.save_png(OUT + "/" + name) + print("captured ", name) diff --git a/tools/preview_topdown.gd.uid b/tools/preview_topdown.gd.uid new file mode 100644 index 0000000..badb9f3 --- /dev/null +++ b/tools/preview_topdown.gd.uid @@ -0,0 +1 @@ +uid://dfcfb205scjvt diff --git a/touch_controls.gd b/touch_controls.gd new file mode 100644 index 0000000..6d0faca --- /dev/null +++ b/touch_controls.gd @@ -0,0 +1,315 @@ +extends CanvasLayer +## On-screen controls for touch devices: a floating movement joystick on the left +## and a cluster of ability buttons on the right. Instantiated by hud.gd. +## +## The joystick feeds the same `move_*` input actions the keyboard/gamepad drive +## (via Input.action_press with an analog strength), so player.gd needs no touch +## awareness — it just reads Input.get_vector() as always. Ability buttons call the +## player's public try_activate_ability(), which shares the keyboard's cooldown gate. +## +## Shown only on touch platforms (see Controls.use_touch_ui), or when DP +## "force_touch_controls" is on so the layout can be exercised on a desktop build. +## emulate_touch_from_mouse (project.godot) lets a mouse stand in for a finger while +## testing. + +# move_* actions, indexed by the four joystick push directions. +const _MOVE_RIGHT: StringName = &"move_right" +const _MOVE_LEFT: StringName = &"move_left" +const _MOVE_BACK: StringName = &"move_back" +const _MOVE_FORWARD: StringName = &"move_forward" + +# Ability slots in player.gd order: kick=0, dash=1, slam=2, roll=3. +const _ABILITY_ICONS: Array[String] = [ + "res://HUD/KICK.png", + "res://HUD/DASH.png", + "res://HUD/SLAM.png", + "res://HUD/ROLL.png", +] +const _ABILITY_LETTERS: Array[String] = ["K", "D", "S", "R"] +const _ABILITY_ACCENT: Array[Color] = [ + Color(1.00, 0.62, 0.16), # Kick — ember orange + Color(0.45, 0.75, 1.00), # Dash — sky blue + Color(0.98, 0.34, 0.20), # Slam — crimson + Color(1.00, 0.82, 0.32), # Roll — gold +] + +var _root: Control +var _stick: Control +var _cd_overlays: Array[ColorRect] = [] + +var _player: Node = null + +# Floating-joystick state. _origin is where the thumb first touched (base centre), +# _knob is the current thumb position; both in _stick-local coordinates. _touch_index +# is the finger owning the stick (-1 = none, -2 = mouse), so a second finger tapping +# an ability button never steals the stick. +var _stick_active: bool = false +var _origin: Vector2 = Vector2.ZERO +var _knob: Vector2 = Vector2.ZERO +var _touch_index: int = -3 +var _stick_radius: float = 110.0 + + +func _ready() -> void: + layer = 10 # below the HUD (11) so the game-over screen always draws on top + process_mode = Node.PROCESS_MODE_ALWAYS + + _root = Control.new() + _root.set_anchors_preset(Control.PRESET_FULL_RECT) + _root.mouse_filter = Control.MOUSE_FILTER_IGNORE + _root.visible = false + add_child(_root) + + _build_joystick() + _build_ability_buttons() + _build_menu_button() + _relayout() + get_viewport().size_changed.connect(_relayout) + + +# ── Joystick ──────────────────────────────────────────────────────────────── +# The stick zone is the whole left half of the screen: touching anywhere in it +# plants the base under the thumb, so the player never has to find a fixed pad. + +func _build_joystick() -> void: + _stick = Control.new() + _stick.mouse_filter = Control.MOUSE_FILTER_STOP + _stick.gui_input.connect(_on_stick_input) + _stick.draw.connect(_draw_stick) + _root.add_child(_stick) + + +func _on_stick_input(event: InputEvent) -> void: + if event is InputEventScreenTouch: + var t := event as InputEventScreenTouch + if t.pressed and not _stick_active: + _begin_stick(t.position, t.index) + elif not t.pressed and _touch_index == t.index: + _end_stick() + elif event is InputEventScreenDrag: + var d := event as InputEventScreenDrag + if _touch_index == d.index: + _update_stick(d.position) + elif event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT: + if mb.pressed and not _stick_active: + _begin_stick(mb.position, -2) + elif not mb.pressed and _touch_index == -2: + _end_stick() + elif event is InputEventMouseMotion and _touch_index == -2: + _update_stick((event as InputEventMouseMotion).position) + + +func _begin_stick(pos: Vector2, index: int) -> void: + _stick_active = true + _touch_index = index + _origin = pos + _knob = pos + _stick.queue_redraw() + + +func _update_stick(pos: Vector2) -> void: + if not _stick_active: + return + var offset := pos - _origin + if offset.length() > _stick_radius: + offset = offset.normalized() * _stick_radius + _knob = _origin + offset + _apply_move(offset / _stick_radius) + _stick.queue_redraw() + + +func _end_stick() -> void: + _stick_active = false + _touch_index = -3 + _release_move() + _stick.queue_redraw() + + +# Translate a normalised stick offset (screen space: +x right, +y down) into analog +# strengths on the four movement actions. Opposite pairs are mutually exclusive, so +# only one of each axis is ever pressed at a time. +func _apply_move(v: Vector2) -> void: + Input.action_press(_MOVE_RIGHT, maxf(0.0, v.x)) + Input.action_press(_MOVE_LEFT, maxf(0.0, -v.x)) + Input.action_press(_MOVE_BACK, maxf(0.0, v.y)) + Input.action_press(_MOVE_FORWARD, maxf(0.0, -v.y)) + + +func _release_move() -> void: + for action: StringName in [_MOVE_RIGHT, _MOVE_LEFT, _MOVE_BACK, _MOVE_FORWARD]: + Input.action_release(action) + + +func _draw_stick() -> void: + if not _stick_active: + return + var ring := Color(0.93, 0.88, 0.72, 0.30) + var knob := Color(1.00, 0.82, 0.32, 0.55) + _stick.draw_circle(_origin, _stick_radius, Color(0.0, 0.0, 0.0, 0.20)) + _stick.draw_arc(_origin, _stick_radius, 0.0, TAU, 48, ring, 4.0, true) + _stick.draw_circle(_knob, _stick_radius * 0.42, knob) + + +# ── Ability buttons ───────────────────────────────────────────────────────── +# A 2×2 cluster in the bottom-right thumb reach. Each button taps the matching +# ability; a dark overlay wipes down over it while that ability is cooling down. + +func _build_ability_buttons() -> void: + var grid := GridContainer.new() + grid.name = "AbilityGrid" + grid.columns = 2 + grid.add_theme_constant_override("h_separation", 14) + grid.add_theme_constant_override("v_separation", 14) + grid.mouse_filter = Control.MOUSE_FILTER_IGNORE + grid.anchor_left = 1.0 + grid.anchor_right = 1.0 + grid.anchor_top = 1.0 + grid.anchor_bottom = 1.0 + grid.grow_horizontal = Control.GROW_DIRECTION_BEGIN + grid.grow_vertical = Control.GROW_DIRECTION_BEGIN + grid.offset_left = -220.0 + grid.offset_top = -220.0 + grid.offset_right = -24.0 + grid.offset_bottom = -24.0 + _root.add_child(grid) + + for slot: int in 4: + grid.add_child(_build_ability_button(slot)) + + +func _build_ability_button(slot: int) -> Control: + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(96.0, 96.0) + panel.add_theme_stylebox_override("panel", _button_style(slot)) + panel.mouse_filter = Control.MOUSE_FILTER_STOP + + var stack := Control.new() + stack.set_anchors_preset(Control.PRESET_FULL_RECT) + panel.add_child(stack) + + if ResourceLoader.exists(_ABILITY_ICONS[slot]): + var icon := TextureRect.new() + icon.texture = load(_ABILITY_ICONS[slot]) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.set_anchors_preset(Control.PRESET_FULL_RECT) + icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + stack.add_child(icon) + else: + var lbl := Label.new() + lbl.text = _ABILITY_LETTERS[slot] + lbl.set_anchors_preset(Control.PRESET_FULL_RECT) + lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[slot]) + lbl.add_theme_font_size_override("font_size", 40) + stack.add_child(lbl) + + # Cooldown wipe — anchored to the bottom, its top edge driven in _process so it + # shrinks away as the ability comes off cooldown (mirrors the desktop HUD tiles). + var cd := ColorRect.new() + cd.color = Color(0.0, 0.0, 0.0, 0.62) + cd.anchor_left = 0.0 + cd.anchor_right = 1.0 + cd.anchor_top = 1.0 + cd.anchor_bottom = 1.0 + cd.mouse_filter = Control.MOUSE_FILTER_IGNORE + stack.add_child(cd) + _cd_overlays.append(cd) + + # Fire on press (not release) so abilities feel instant under the thumb. + panel.gui_input.connect(_on_ability_input.bind(slot)) + return panel + + +func _on_ability_input(event: InputEvent, slot: int) -> void: + var pressed := false + if event is InputEventScreenTouch: + pressed = (event as InputEventScreenTouch).pressed + elif event is InputEventMouseButton: + var mb := event as InputEventMouseButton + pressed = mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT + if pressed and _player != null: + _player.call(&"try_activate_ability", slot) + + +func _button_style(slot: int) -> StyleBoxFlat: + var s := StyleBoxFlat.new() + s.bg_color = Color(0.10, 0.07, 0.03, 0.80) + s.set_corner_radius_all(18) + s.corner_detail = 6 + s.set_border_width_all(3) + s.border_color = _ABILITY_ACCENT[slot] + return s + + +# ── Menu button ────────────────────────────────────────────────────────────── +# Top-right "☰" — the touch stand-in for the keyboard's Esc-to-menu, so a phone +# player has a way out of a run. + +func _build_menu_button() -> void: + var btn := Button.new() + btn.text = "☰" + btn.custom_minimum_size = Vector2(56.0, 56.0) + btn.focus_mode = Control.FOCUS_NONE + btn.anchor_left = 1.0 + btn.anchor_right = 1.0 + btn.grow_horizontal = Control.GROW_DIRECTION_BEGIN + btn.offset_left = -72.0 + btn.offset_top = 16.0 + btn.offset_right = -16.0 + btn.offset_bottom = 72.0 + var style := StyleBoxFlat.new() + style.bg_color = Color(0.10, 0.07, 0.03, 0.80) + style.set_corner_radius_all(12) + style.set_border_width_all(2) + style.border_color = Color(1.00, 0.78, 0.22, 0.8) + btn.add_theme_stylebox_override("normal", style) + btn.add_theme_stylebox_override("hover", style) + btn.add_theme_stylebox_override("pressed", style) + btn.add_theme_color_override("font_color", Color(1.00, 0.78, 0.22)) + btn.add_theme_font_size_override("font_size", 28) + btn.pressed.connect(_return_to_menu) + _root.add_child(btn) + + +func _return_to_menu() -> void: + get_tree().paused = false + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + get_tree().change_scene_to_file("res://MainMenu.tscn") + + +# ── Layout & lifecycle ────────────────────────────────────────────────────── + +func _relayout() -> void: + var vp := get_viewport().get_visible_rect().size + # Left half is the joystick zone; base radius scales with the shorter screen edge. + _stick.position = Vector2.ZERO + _stick.size = Vector2(vp.x * 0.5, vp.y) + _stick_radius = clampf(minf(vp.x, vp.y) * 0.16, 80.0, 150.0) + + +func _process(_delta: float) -> void: + var active := Controls.use_touch_ui() + # The game-over / pause screen owns the display when paused — stand down and drop + # any held movement so the bull doesn't coast on a stuck joystick. + if not active or get_tree().paused: + if _root.visible: + _root.visible = false + if _stick_active: + _end_stick() + return + _root.visible = true + + if _player == null: + var players := get_tree().get_nodes_in_group(&"player") + if not players.is_empty(): + _player = players[0] + return + + for slot: int in _cd_overlays.size(): + var fraction: float = _player.call(&"ability_cooldown_fraction", slot) + _cd_overlays[slot].anchor_top = 1.0 - clampf(fraction, 0.0, 1.0) diff --git a/ui_fonts.gd b/ui_fonts.gd index 7dd8b93..eaaa8d6 100644 --- a/ui_fonts.gd +++ b/ui_fonts.gd @@ -11,6 +11,7 @@ const _ROMAN := "res://fonts/Cinzel.ttf" static var _title: FontFile = null static var _body: FontFile = null +static var _body_bold: FontVariation = null ## Ornate display face — big banners, menu title, win/lose text. @@ -27,6 +28,17 @@ static func body() -> FontFile: return _body +## Emboldened Roman face for small, high-legibility bits (keycaps, badges) where +## Cinzel's thin regular strokes wash out. Faked via FontVariation since only the +## regular weight ships, so no extra .ttf is needed. +static func body_bold() -> FontVariation: + if _body_bold == null: + _body_bold = FontVariation.new() + _body_bold.base_font = body() + _body_bold.variation_embolden = 0.5 + return _body_bold + + static func _load(path: String) -> FontFile: if ResourceLoader.exists(path): var res := load(path) diff --git a/ui_theme.gd b/ui_theme.gd index d39dec3..e930f82 100644 --- a/ui_theme.gd +++ b/ui_theme.gd @@ -21,15 +21,15 @@ const SHADOW := Color(0.00, 0.00, 0.00, 0.55) ## HUD tiles or CREAM for a hot/hover state; a transparent accent draws a frameless ## plate (still shadowed). static func plaque(bg: Color, accent: Color = BORDER, - corner: int = 3, h_margin: float = 16.0, v_margin: float = 8.0, + corner: int = 1, h_margin: float = 16.0, v_margin: float = 8.0, shadow: bool = true) -> StyleBoxFlat: var s := StyleBoxFlat.new() s.bg_color = bg s.set_corner_radius_all(corner) - s.corner_detail = 6 + s.corner_detail = 1 if accent.a > 0.0: - s.set_border_width_all(2) - s.border_width_bottom = 5 + s.set_border_width_all(1) + s.border_width_bottom = 2 s.border_color = accent s.content_margin_left = h_margin s.content_margin_right = h_margin