diff --git a/CLAUDE.md b/CLAUDE.md index c1f461e..88addd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,8 +96,22 @@ godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, # Gameplay test with real rendering (inspect frame strip visually): godot --script tests/gameplay_test.gd # opens a window, saves real screenshots # Output: tests/output/gameplay/motion_00..04.png, ragdoll_trigger.png, ragdoll_result.png + +# Difficulty simulation (balance tool, ~2 min): +godot --headless --script tests/difficulty_sim.gd # drives a bot bull vs one matador ``` +### Difficulty simulation (`tests/difficulty_sim.gd`) +A balance tool, not a pass/fail test. It drives a kinematic "bot bull" against one +real matador across behaviour profiles (CHARGER / KITER / PASSIVE) and reports the +matador's win rate (how often it gores the bull). Use it to tune matador difficulty +from data. The bot bull has NO abilities (it only kills by ramming), so it can't use +roll/slam/kick — real skilled play beats the matador more than these numbers imply; +read the rates as "how well the matador threatens each movement pattern." A rough +target is CHARGER high (blind charging is punished), KITER moderate (throws are a +dodgeable threat), PASSIVE high (standing still dies). Note: as a `--script` entry it +reads the `DP` autoload via `/root/DP`, not the global identifier. + ### What the gameplay test catches | Check | What it detects | |---|---| diff --git a/camera_iso.gd b/camera_iso.gd index ed5d50d..b3f6ae9 100644 --- a/camera_iso.gd +++ b/camera_iso.gd @@ -16,6 +16,12 @@ var _shake_amount: float = 0.0 var _shake_decay: float = 0.0 var _user_fov: float = 0.0 +# Trailing-camera state: _focus is the smoothed point the camera frames (it lags +# the bull), _drag_offset is the velocity-driven trail that slides it behind. +var _focus: Vector3 = Vector3.ZERO +var _drag_offset: Vector3 = Vector3.ZERO +var _focus_init: bool = false + func _ready() -> void: set_as_top_level(true) @@ -48,11 +54,12 @@ func _process(delta: float) -> void: ) var target := get_parent() as Node3D + var focus := _drag_focus(target, delta) - global_position = target.global_position + offset - look_at(target.global_position, Vector3.UP) + global_position = focus + offset + look_at(focus, Vector3.UP) - _update_dof(target.global_position) + _update_dof(focus) if _shake_amount > 0.005: global_position += Vector3( @@ -64,6 +71,26 @@ func _process(delta: float) -> void: _shake_amount = move_toward(_shake_amount, 0.0, _shake_decay * delta) +# Smoothed follow point that drags behind the bull. The target trail sits opposite +# the bull's flat velocity (cam_drag metres per m/s); the offset and the focus both +# ease toward their targets so the camera lags and settles rather than snapping. +func _drag_focus(target: Node3D, delta: float) -> Vector3: + var pos := target.global_position + if not _focus_init: + _focus = pos + _focus_init = true + + var vel := Vector3.ZERO + if target is CharacterBody3D: + vel = (target as CharacterBody3D).velocity + vel.y = 0.0 + + var drag_target := -vel * DP.f("cam_drag") + _drag_offset = _drag_offset.lerp(drag_target, clampf(DP.f("cam_drag_speed") * delta, 0.0, 1.0)) + _focus = _focus.lerp(pos + _drag_offset, clampf(DP.f("cam_follow_lerp") * delta, 0.0, 1.0)) + return _focus + + func trigger_hit(strength: float) -> void: _shake_amount = strength * 0.5 _shake_decay = _shake_amount / 0.4 diff --git a/debug_params.gd b/debug_params.gd index bac4fc1..f3a9b21 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -96,9 +96,17 @@ func _register_all() -> void: _reg_f("Camera", "cam_spring_length", 7.0, 1.0, 30.0) _reg_f("Camera", "cam_min_vert_angle", -60.0, -90.0, 0.0, 1.0) _reg_f("Camera", "cam_max_vert_angle", 45.0, 0.0, 90.0, 1.0) + # Trailing "drag" camera: the focus point lags the bull and slides opposite to + # its velocity, so the camera visibly drags behind during a fast charge. + # cam_drag = metres of trail per m/s of bull speed (0 = rigid follow) + # cam_drag_speed = how fast the trail offset eases toward its target + # cam_follow_lerp = how tightly the focus point chases the bull (higher = snappier) + _reg_f("Camera", "cam_drag", 0.12, 0.0, 1.0, 0.01) + _reg_f("Camera", "cam_drag_speed", 3.5, 0.5, 20.0) + _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) - _reg_f("Matador", "mat_walk_speed", 4.0, 0.5, 10.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) _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) @@ -110,33 +118,55 @@ func _register_all() -> void: _reg_f("Matador", "mat_joint_twist", 40.0, 0.0, 180.0, 1.0) _reg_f("Matador", "mat_ragdoll_damp", 1.5, 0.0, 10.0, 0.1) _reg_f("Matador", "mat_flee_range", 3.5, 2.0, 40.0) - _reg_f("Matador", "mat_flee_speed", 5.5, 0.5, 12.0) + _reg_f("Matador", "mat_flee_speed", 7.0, 0.5, 12.0) _reg_f("Matador", "mat_charge_speed", 10.0, 1.0, 30.0) _reg_f("Matador", "mat_brace_range", 5.0, 2.0, 20.0) _reg_f("Matador", "mat_brace_duration", 0.7, 0.05, 1.5, 0.05) _reg_f("Matador", "mat_commit_dist", 2.5, 0.5, 6.0, 0.1) - _reg_f("Matador", "mat_step_speed", 3.5, 2.0, 20.0) + _reg_f("Matador", "mat_step_speed", 6.5, 2.0, 20.0) _reg_f("Matador", "mat_step_duration", 0.35, 0.1, 1.0, 0.05) # How strongly the sidestep biases toward the bull so the blade sweeps through # its path during the pass (0 = pure lateral dodge, 1 = straight at the bull). _reg_f("Matador", "mat_pass_lunge", 0.35, 0.0, 1.0, 0.05) _reg_f("Matador", "mat_dodge_cooldown", 2.5, 0.5, 12.0, 0.25) - _reg_f("Matador", "mat_dodge_chance", 0.55, 0.0, 1.0, 0.05) + _reg_f("Matador", "mat_dodge_chance", 0.72, 0.0, 1.0, 0.05) _reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0) - _reg_f("Matador", "mat_attack_speed", 5.5, 1.0, 12.0) + _reg_f("Matador", "mat_attack_speed", 7.5, 1.0, 12.0) _reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1) - _reg_f("Matador", "mat_attack_min_dist", 3.0, 0.5, 6.0, 0.1) + _reg_f("Matador", "mat_attack_min_dist", 3.2, 0.5, 6.0, 0.1) # Forward drive during the opening of a melee swing — turns the stationary stab # into an estocada lunge so the blade covers ground toward the bull. - _reg_f("Matador", "mat_lunge_speed", 9.0, 0.0, 20.0) - _reg_f("Matador", "mat_lunge_time", 0.18, 0.0, 0.6, 0.01) + _reg_f("Matador", "mat_lunge_speed", 13.0, 0.0, 20.0) + _reg_f("Matador", "mat_lunge_time", 0.22, 0.0, 0.6, 0.01) + # Reach of the reach-based stab (the reliable melee hit) and its re-strike gap. + # The bull dies if it comes inside mat_stab_reach of a matador that's attacking, + # bracing, or passing with a drawn blade — closing in is genuinely deadly. + _reg_f("Matador", "mat_stab_reach", 1.7, 0.5, 4.0, 0.1) + _reg_f("Matador", "mat_stab_cooldown", 0.6, 0.1, 3.0, 0.1) + # Half-angle (deg) the matador must be facing the bull within for a stab to land. + # Stops "sword's off to the side but I still died" — you're only gored when the + # blade is actually presented at you. + _reg_f("Matador", "mat_stab_arc", 55.0, 5.0, 180.0, 5.0) _reg_f("Matador", "mat_roll_chance", 0.35, 0.0, 1.0, 0.05) _reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05) # Playback rate of the Draw_weapon clip used for both drawing and sheathing — # higher = snappier unholster/holster. - _reg_f("Matador", "mat_draw_speed", 1.25, 0.25, 3.0, 0.05) + _reg_f("Matador", "mat_draw_speed", 1.9, 0.25, 3.0, 0.05) # Cross-fade between matador animation clips — smooths Run/Attack/Taunt cuts. _reg_f("Matador", "mat_anim_blend", 0.12, 0.0, 0.5, 0.01) + # ── Matador heuristics (utility AI) ─────────────────────────────────────── + # Every mat_decide_interval seconds the matador scores its options — dodge the + # pass, close for a strike, retreat, throw the blade, or strut/taunt — and picks + # the highest. These weights bias the personality: crank aggression for a reckless + # 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_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) + # 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 ───────────────────────────────────────────────────────────────── # Local seating of the sword in the right hand (drawn / fighting). # Defaults are identity — the bone is oriented in Blender to seat the sword @@ -157,17 +187,20 @@ func _register_all() -> void: _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) ────────────────────────────────── - # Probability of committing to a throw each time the matador becomes eligible; - # kept low so they favour closing in for a melee Attack over throwing. - _reg_f("Sword", "mat_throw_chance", 0.15, 0.0, 1.0, 0.05) # Delay before a fresh sword appears in the holster after one is thrown. _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.7, 0.2, 2.0, 0.05) + _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", 18.0, 5.0, 60.0) + _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) + _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) _reg_f("Sword", "mat_throw_arm_cock", 70.0,-180.0, 180.0, 1.0) _reg_f("Sword", "mat_throw_arm_release", -90.0,-180.0, 180.0, 1.0) _reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0) @@ -186,23 +219,20 @@ func _register_all() -> void: _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_speed", 66.0, 5.0, 200.0) - # ── Roll (boulder charge) ───────────────────────────────────────────────── - # A held-momentum roll: inherits your current charge, ramps toward roll_max_speed, - # and RICOCHETS off walls with a speed boost (bank shots build ludicrous speed). - _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) - _reg_f("Roll", "roll_min_speed", 30.0, 5.0, 120.0) # launch floor (combines charge) - _reg_f("Roll", "roll_max_speed", 95.0, 10.0, 200.0) # natural top from acceleration - _reg_f("Roll", "roll_accel", 55.0, 0.0, 300.0) - # Per-second speed retained above roll_max_speed — low friction so wall-boosted - # momentum bleeds off slowly rather than snapping back to the cruise speed. - _reg_f("Roll", "roll_momentum", 0.9, 0.1, 0.99, 0.01) - _reg_f("Roll", "roll_turn", 3.5, 0.2, 20.0) # steer rate (sluggish = momentum feel) - _reg_f("Roll", "roll_wall_boost", 1.25, 1.0, 2.0, 0.05) # speed × on each ricochet - _reg_f("Roll", "roll_wall_cap", 150.0, 10.0, 300.0) # ceiling reachable via boosts - _reg_f("Roll", "roll_hit_radius", 2.4, 0.5, 6.0, 0.1) - _reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) - _reg_f("Roll", "roll_hit_up", 4.5, 0.0, 15.0) + # ── Roll (Rammus Powerball) ─────────────────────────────────────────────── + # 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. + _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_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", 3.5, 0.2, 20.0) # steer rate (low = wide momentum turns) + _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 + _reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop # ── Debug overlays (see debug_draw.gd) ─────────────────────────────────── _reg_b("Debug", "show_collisions", false) _reg_b("Debug", "show_hitboxes", false) diff --git a/fonts/Cinzel.ttf b/fonts/Cinzel.ttf new file mode 100644 index 0000000..d218a0b Binary files /dev/null and b/fonts/Cinzel.ttf differ diff --git a/fonts/Cinzel.ttf.import b/fonts/Cinzel.ttf.import new file mode 100644 index 0000000..c4f6775 --- /dev/null +++ b/fonts/Cinzel.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://fu84fchl1im6" +path="res://.godot/imported/Cinzel.ttf-b9b78caa7497d2ec78b922cb77f34333.fontdata" + +[deps] + +source_file="res://fonts/Cinzel.ttf" +dest_files=["res://.godot/imported/Cinzel.ttf-b9b78caa7497d2ec78b922cb77f34333.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=3 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/fonts/CinzelDecorative-Bold.ttf b/fonts/CinzelDecorative-Bold.ttf new file mode 100644 index 0000000..7bb0f35 Binary files /dev/null and b/fonts/CinzelDecorative-Bold.ttf differ diff --git a/fonts/CinzelDecorative-Bold.ttf.import b/fonts/CinzelDecorative-Bold.ttf.import new file mode 100644 index 0000000..76779d6 --- /dev/null +++ b/fonts/CinzelDecorative-Bold.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://rcfn6xhs7fur" +path="res://.godot/imported/CinzelDecorative-Bold.ttf-fd8dd0c7ef2ceb6acaeb4b78a3c0a47b.fontdata" + +[deps] + +source_file="res://fonts/CinzelDecorative-Bold.ttf" +dest_files=["res://.godot/imported/CinzelDecorative-Bold.ttf-fd8dd0c7ef2ceb6acaeb4b78a3c0a47b.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=3 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/fonts/CinzelDecorative-Regular.ttf b/fonts/CinzelDecorative-Regular.ttf new file mode 100644 index 0000000..2308d34 Binary files /dev/null and b/fonts/CinzelDecorative-Regular.ttf differ diff --git a/fonts/CinzelDecorative-Regular.ttf.import b/fonts/CinzelDecorative-Regular.ttf.import new file mode 100644 index 0000000..ea9c6dd --- /dev/null +++ b/fonts/CinzelDecorative-Regular.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://cgppsmy8krkhw" +path="res://.godot/imported/CinzelDecorative-Regular.ttf-818749a2a58435ecbb00dae02eb6079a.fontdata" + +[deps] + +source_file="res://fonts/CinzelDecorative-Regular.ttf" +dest_files=["res://.godot/imported/CinzelDecorative-Regular.ttf-818749a2a58435ecbb00dae02eb6079a.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=3 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/fonts/OFL.txt b/fonts/OFL.txt new file mode 100644 index 0000000..afdfd14 --- /dev/null +++ b/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/game_version.gd b/game_version.gd new file mode 100644 index 0000000..a8f8439 --- /dev/null +++ b/game_version.gd @@ -0,0 +1,33 @@ +class_name GameVersion +extends RefCounted +## Single source of truth for the game's version string. +## +## The numeric version lives in project.godot (application/config/version) so it +## also feeds export metadata; here we read it back and layer on the pre-release +## CHANNEL and a human-readable STAGE label. We're pre-1.0 — every 0.x build is +## understood to be unstable, in-development software (see the recommendations in +## the commit / PR notes for why 0.x is the convention for early game dev). + +## Pre-release channel appended to the number, SemVer-style (e.g. 0.1.0-alpha). +const CHANNEL: String = "alpha" + +## Friendly stage banner shown to players so it's unmistakably an early build. +const STAGE: String = "Early Development Build" + + +## Bare numeric version, e.g. "0.1.0". Read from project settings. +static func number() -> String: + return str(ProjectSettings.get_setting("application/config/version", "0.0.0")) + + +## Full version tag, e.g. "v0.1.0-alpha". +static func string() -> String: + var s := "v" + number() + if not CHANNEL.is_empty(): + s += "-" + CHANNEL + return s + + +## Version tag + stage banner, e.g. "v0.1.0-alpha · Early Development Build". +static func full() -> String: + return "%s · %s" % [string(), STAGE] diff --git a/game_version.gd.uid b/game_version.gd.uid new file mode 100644 index 0000000..aa6371c --- /dev/null +++ b/game_version.gd.uid @@ -0,0 +1 @@ +uid://f4bvu1mnvpiu diff --git a/hud.gd b/hud.gd index af4363c..988c3fc 100644 --- a/hud.gd +++ b/hud.gd @@ -7,11 +7,6 @@ const _BG := Color(0.06, 0.04, 0.02, 0.78) const _BADGE := Color(0.16, 0.10, 0.03, 0.90) const _BORDER := Color(0.65, 0.48, 0.15, 0.55) -# Scoring: each kill scores _KILL_BASE × the live combo count; the combo grows with -# every kill inside _COMBO_WINDOW seconds of the last and resets when that lapses. -const _KILL_BASE: int = 100 -const _COMBO_WINDOW: float = 3.0 - const _ABILITY_ICONS: Array[String] = [ "res://HUD/HUD_0000_ability_JALG.png", "res://HUD/HUD_0001_ability_SLAM.png", @@ -30,20 +25,19 @@ var _toggle_btn: Button var _tab_hint: Label var _spawner: Node var _player: Node = null +var _died_connected: bool = false var _cd_overlays: Array[ColorRect] = [] var _cd_labels: Array[Label] = [] -var _health_segments: Array[ColorRect] = [] -var _health_connected: bool = false -var _score: int = 0 -var _combo: int = 0 -var _combo_timer: float = 0.0 -var _score_label: Label -var _wave_label: Label -var _combo_label: Label -var _combo_track: ColorRect -var _combo_fill: ColorRect -var _banner_label: Label +# 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 + +var _game_over: bool = false +var _result_win: bool = false +var _over_root: Control +var _over_video: VideoStreamPlayer func _ready() -> void: @@ -52,22 +46,35 @@ func _ready() -> void: _build_controls_panel() _build_tab_hint() _build_ability_bar() - _build_health_bar() - _build_scoreboard() + _build_version_label() + _build_game_over() _find_spawner.call_deferred() -func _process(delta: float) -> void: - _update_combo(delta) +# Unobtrusive early-build tag in the bottom-right corner. +func _build_version_label() -> void: + var lbl := Label.new() + lbl.text = GameVersion.full() + lbl.add_theme_color_override("font_color", _MUTED) + lbl.add_theme_font_size_override("font_size", 12) + lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN + lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN + lbl.offset_right = -14.0 + lbl.offset_bottom = -10.0 + lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(lbl) + + +func _process(_delta: float) -> void: if _player == null: var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): _player = players[0] return - if not _health_connected: - _health_connected = true - _player.health_changed.connect(_on_health_changed) - _on_health_changed(_player.health) + if not _died_connected: + _died_connected = true + _player.died.connect(_on_player_died) for i: int in _cd_overlays.size(): var fraction: float = _player.call(&"ability_cooldown_fraction", i) var ov := _cd_overlays[i] @@ -87,14 +94,14 @@ func _find_spawner() -> void: if spawners.is_empty(): return _spawner = spawners[0] - _spawner.matador_killed.connect(_on_matador_killed) - _spawner.wave_changed.connect(_on_wave_changed) - _on_wave_changed(_spawner.current_wave()) + _spawner.all_defeated.connect(_on_all_defeated) func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo: - if event.physical_keycode == KEY_TAB: + if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]: + _restart() + elif event.physical_keycode == KEY_TAB and not _game_over: _toggle_controls() elif event.physical_keycode == KEY_ESCAPE: _return_to_menu() @@ -106,6 +113,12 @@ func _return_to_menu() -> void: get_tree().change_scene_to_file("res://MainMenu.tscn") +func _restart() -> void: + get_tree().paused = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + get_tree().reload_current_scene() + + func _toggle_controls() -> void: _controls_visible = not _controls_visible _controls_panel.visible = _controls_visible @@ -113,14 +126,79 @@ func _toggle_controls() -> void: _toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" -func _on_reset_pressed() -> void: - if get_tree().paused: - get_tree().paused = false - if _spawner: - _spawner.reset_spawn() - _score = 0 - _score_label.text = "SCORE 0" - _reset_combo() +# ── Win / lose ────────────────────────────────────────────────────────────── +# The bull takes one clean blade and it's over; clear the last matador and it's a +# victory. Both routes end here — the arena freezes and the result is proclaimed. + +func _on_player_died(cause: String = "") -> void: + _show_game_over(false, cause) + + +func _on_all_defeated() -> void: + _show_game_over(true) + + +func _show_game_over(win: bool, _cause: String = "") -> void: + if _game_over: + return + _game_over = true + _result_win = win + + if _controls_panel: + _controls_panel.visible = false + 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 + if ResourceLoader.exists(path): + _over_video.stream = load(path) + _over_video.play() + + _over_root.visible = true + _over_root.modulate.a = 0.0 + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + get_tree().paused = true + + create_tween().tween_property(_over_root, "modulate:a", 1.0, 0.5) + + +func _build_game_over() -> void: + _over_root = Control.new() + _over_root.set_anchors_preset(Control.PRESET_FULL_RECT) + _over_root.process_mode = Node.PROCESS_MODE_ALWAYS + _over_root.visible = false + add_child(_over_root) + + # Full-screen outcome clip (bull win / matador win), looping. + _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) + + # Buttons overlaid at the bottom of the clip. + var row := HBoxContainer.new() + row.alignment = BoxContainer.ALIGNMENT_CENTER + row.add_theme_constant_override("separation", 16) + row.anchor_left = 0.5 + row.anchor_right = 0.5 + row.anchor_top = 1.0 + row.anchor_bottom = 1.0 + row.grow_horizontal = Control.GROW_DIRECTION_BOTH + row.grow_vertical = Control.GROW_DIRECTION_BEGIN + row.offset_bottom = -48.0 + _over_root.add_child(row) + + var again := _make_button("Go Again (Enter)") + again.pressed.connect(_restart) + row.add_child(again) + + var menu := _make_button("Main Menu") + menu.pressed.connect(_return_to_menu) + row.add_child(menu) # A bordered, rounded, gold-trimmed panel background — the HUD's one repeated look. @@ -140,174 +218,6 @@ func _panel_style(bg: Color, corner: int, h_margin: float, v_margin: float, return s -func _build_health_bar() -> void: - var bar := HBoxContainer.new() - bar.add_theme_constant_override("separation", 4) - bar.anchor_left = 0.5 - bar.anchor_right = 0.5 - bar.anchor_top = 0.0 - bar.anchor_bottom = 0.0 - bar.grow_horizontal = Control.GROW_DIRECTION_BOTH - bar.offset_left = -90.0 - bar.offset_top = 16.0 - bar.offset_bottom = 16.0 - add_child(bar) - - var panel := PanelContainer.new() - panel.add_theme_stylebox_override("panel", _panel_style(_BG, 4, 6.0, 6.0)) - bar.add_child(panel) - - var inner := HBoxContainer.new() - inner.add_theme_constant_override("separation", 3) - panel.add_child(inner) - - for i: int in 10: - var seg := ColorRect.new() - seg.custom_minimum_size = Vector2(14.0, 14.0) - seg.color = Color(0.72, 0.08, 0.08) - inner.add_child(seg) - _health_segments.append(seg) - - -func _on_health_changed(new_health: int) -> void: - for i: int in _health_segments.size(): - _health_segments[i].color = Color(0.72, 0.08, 0.08) if i < new_health \ - else Color(0.20, 0.10, 0.10) - - -# ── Scoreboard: score · wave · kill-combo ────────────────────────────────────── - -func _build_scoreboard() -> void: - var panel := PanelContainer.new() - panel.mouse_filter = Control.MOUSE_FILTER_IGNORE - panel.anchor_left = 1.0 - panel.anchor_right = 1.0 - panel.anchor_top = 0.0 - panel.anchor_bottom = 0.0 - panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN - panel.offset_left = -172.0 - panel.offset_right = -14.0 - panel.offset_top = 14.0 - panel.add_theme_stylebox_override("panel", _panel_style(_BG, 6, 12.0, 8.0)) - add_child(panel) - - var vbox := VBoxContainer.new() - vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE - vbox.add_theme_constant_override("separation", 2) - panel.add_child(vbox) - - _score_label = _score_line(vbox, "SCORE 0", _GOLD, 20) - _wave_label = _score_line(vbox, "WAVE 1", _CREAM, 14) - _combo_label = _score_line(vbox, "", _CREAM, 16) - - _combo_track = ColorRect.new() - _combo_track.color = Color(0.0, 0.0, 0.0, 0.4) - _combo_track.custom_minimum_size = Vector2(0.0, 4.0) - _combo_track.mouse_filter = Control.MOUSE_FILTER_IGNORE - _combo_track.visible = false - vbox.add_child(_combo_track) - - _combo_fill = ColorRect.new() - _combo_fill.color = _GOLD - _combo_fill.set_anchors_preset(Control.PRESET_FULL_RECT) - _combo_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE - _combo_track.add_child(_combo_fill) - - # Centre banner that flashes on each new wave. - _banner_label = Label.new() - _banner_label.mouse_filter = Control.MOUSE_FILTER_IGNORE - _banner_label.anchor_left = 0.5 - _banner_label.anchor_right = 0.5 - _banner_label.anchor_top = 0.26 - _banner_label.anchor_bottom = 0.26 - _banner_label.grow_horizontal = Control.GROW_DIRECTION_BOTH - _banner_label.grow_vertical = Control.GROW_DIRECTION_BOTH - _banner_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _banner_label.add_theme_color_override("font_color", _GOLD) - _banner_label.add_theme_font_size_override("font_size", 44) - _banner_label.modulate.a = 0.0 - add_child(_banner_label) - - -func _score_line(parent: VBoxContainer, text: String, color: Color, size: int) -> Label: - var lbl := Label.new() - lbl.text = text - lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - lbl.add_theme_color_override("font_color", color) - lbl.add_theme_font_size_override("font_size", size) - parent.add_child(lbl) - return lbl - - -# Every kill lengthens the combo; scoring is base × combo so a fast chain is worth -# far more than the same kills spread out — rewarding aggressive, uninterrupted play. -func _on_matador_killed() -> void: - _combo += 1 - _combo_timer = _COMBO_WINDOW - _score += _KILL_BASE * _combo - _score_label.text = "SCORE %d" % _score - _pulse(_score_label, 1.15) - if _combo >= 2: - _combo_label.text = "%d× COMBO" % _combo - _combo_label.add_theme_color_override("font_color", _combo_color()) - _combo_fill.color = _combo_color() - _combo_track.visible = true - _pulse(_combo_label, 1.25) - else: - _combo_label.text = "" - _combo_track.visible = false - - -func _on_wave_changed(wave: int) -> void: - _wave_label.text = "WAVE %d" % wave - if wave > 1: - _flash_banner("WAVE %d" % wave) - - -func _update_combo(delta: float) -> void: - if _combo <= 0: - return - _combo_timer -= delta - if _combo_timer <= 0.0: - _reset_combo() - elif _combo_track.visible: - _combo_fill.anchor_right = clampf(_combo_timer / _COMBO_WINDOW, 0.0, 1.0) - - -func _reset_combo() -> void: - _combo = 0 - _combo_timer = 0.0 - _combo_label.text = "" - _combo_track.visible = false - - -func _combo_color() -> Color: - if _combo >= 8: - return Color(1.0, 0.25, 0.10) - if _combo >= 5: - return Color(1.0, 0.50, 0.10) - if _combo >= 3: - return Color(1.0, 0.85, 0.20) - return _CREAM - - -func _pulse(ctrl: Control, amount: float) -> void: - ctrl.pivot_offset = ctrl.size * 0.5 - ctrl.scale = Vector2.ONE * amount - create_tween().tween_property(ctrl, "scale", Vector2.ONE, 0.25) \ - .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) - - -func _flash_banner(text: String) -> void: - _banner_label.text = text - _banner_label.modulate.a = 0.0 - var tw := create_tween() - tw.tween_property(_banner_label, "modulate:a", 1.0, 0.2) - tw.tween_interval(0.7) - tw.tween_property(_banner_label, "modulate:a", 0.0, 0.6) - - func _build_ability_bar() -> void: var bar := HBoxContainer.new() bar.add_theme_constant_override("separation", 10) @@ -430,9 +340,8 @@ func _build_controls_panel() -> void: _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_roll"), "Roll (bank off walls!)", _CREAM) + _row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (Powerball: ram the matador!)", _CREAM) _row(vbox, "Scroll", "Zoom", _CREAM) - _row(vbox, "R", "Respawn matadors (cooldown)", _MUTED) _row(vbox, "Tab", "Toggle controls", _MUTED) _row(vbox, "Esc", "Back to menu", _MUTED) @@ -444,17 +353,9 @@ func _build_controls_panel() -> void: sep.add_theme_stylebox_override("separator", sep_style) vbox.add_child(sep) - var hbox := HBoxContainer.new() - hbox.add_theme_constant_override("separation", 8) - vbox.add_child(hbox) - _toggle_btn = _make_button("Show controls") _toggle_btn.pressed.connect(_toggle_controls) - hbox.add_child(_toggle_btn) - - var reset_btn := _make_button("Reset") - reset_btn.pressed.connect(_on_reset_pressed) - hbox.add_child(reset_btn) + vbox.add_child(_toggle_btn) func _make_button(label_text: String) -> Button: @@ -462,15 +363,17 @@ func _make_button(label_text: String) -> Button: btn.text = label_text btn.process_mode = Node.PROCESS_MODE_ALWAYS - var normal := _panel_style(Color(0.18, 0.12, 0.04, 0.90), 5, 12.0, 6.0) - var hover := _panel_style(Color(0.30, 0.20, 0.06, 0.95), 5, 12.0, 6.0, _GOLD) + var normal := _panel_style(Color(0.18, 0.12, 0.04, 0.90), 5, 16.0, 8.0) + var hover := _panel_style(Color(0.30, 0.20, 0.06, 0.95), 5, 16.0, 8.0, _GOLD) btn.add_theme_stylebox_override("normal", normal) btn.add_theme_stylebox_override("hover", hover) btn.add_theme_stylebox_override("pressed", hover) + btn.add_theme_stylebox_override("focus", hover) + btn.add_theme_font_override("font", UiFonts.body()) btn.add_theme_color_override("font_color", _GOLD) - btn.add_theme_color_override("font_hover_color", _GOLD) - btn.add_theme_font_size_override("font_size", 12) + btn.add_theme_color_override("font_hover_color", _CREAM) + btn.add_theme_font_size_override("font_size", 18) return btn diff --git a/main_menu.gd b/main_menu.gd index 97bd2ef..8a6825a 100644 --- a/main_menu.gd +++ b/main_menu.gd @@ -6,6 +6,10 @@ extends Control const GAME_SCENE := "res://scene.tscn" const FADE_TIME := 0.6 +# Arena palette: aged gold on dark leather, warm cream on hover. +const MENU_GOLD := Color(0.85, 0.66, 0.20) +const MENU_CREAM := Color(0.93, 0.88, 0.72) + ## Cape centroid per frame (normalized 0-1), detected from red-pixel centroid. ## 34 frames at 24 fps = 1.4167 s loop. const VIDEO_FPS := 24.0 @@ -90,10 +94,79 @@ func _ready() -> void: exit_button.pressed.connect(_on_exit_pressed) options_back.pressed.connect(_close_subpanels) credits_back.pressed.connect(_close_subpanels) + _apply_theme() + _build_version_label() _build_rebind_rows() play_button.grab_focus() +# Version banner in the bottom-right corner so it's clear this is an early build. +func _build_version_label() -> void: + var lbl := Label.new() + lbl.text = GameVersion.full() + lbl.add_theme_font_override("font", UiFonts.body()) + lbl.add_theme_font_size_override("font_size", 16) + lbl.add_theme_color_override("font_color", MENU_CREAM) + lbl.add_theme_color_override("font_outline_color", Color.BLACK) + lbl.add_theme_constant_override("outline_size", 4) + lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN + lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN + lbl.offset_right = -16.0 + lbl.offset_bottom = -12.0 + lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(lbl) + + +# Give the whole menu the Cinzel look and swap the generic translucent boxes for +# engraved bronze plaques (dark leather fill, gold border with a heavier bottom +# edge for a chiselled-tablet feel). +func _apply_theme() -> void: + _style_button(play_button, true) + for btn: Button in [options_button, credits_button, exit_button, options_back, credits_back]: + _style_button(btn, false) + for path: String in ["OptionsPanel/VBox/TitleLabel", "CreditsPanel/VBox/TitleLabel"]: + var title := get_node_or_null(path) as Label + if title != null: + title.add_theme_font_override("font", UiFonts.title()) + title.add_theme_color_override("font_color", MENU_GOLD) + for path: String in ["OptionsPanel/VBox/Hint", "CreditsPanel/VBox/Names"]: + var lbl := get_node_or_null(path) as Label + if lbl != null: + lbl.add_theme_font_override("font", UiFonts.body()) + lbl.add_theme_color_override("font_color", MENU_CREAM) + + +func _style_button(btn: Button, big: bool) -> void: + btn.add_theme_font_override("font", UiFonts.title() if big else UiFonts.body()) + btn.add_theme_color_override("font_color", MENU_GOLD) + btn.add_theme_color_override("font_hover_color", MENU_CREAM) + btn.add_theme_color_override("font_focus_color", MENU_CREAM) + btn.add_theme_color_override("font_pressed_color", MENU_CREAM) + btn.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1)) + btn.add_theme_constant_override("outline_size", 8 if big else 5) + btn.add_theme_stylebox_override("normal", _plaque(false)) + btn.add_theme_stylebox_override("hover", _plaque(true)) + btn.add_theme_stylebox_override("pressed", _plaque(true)) + btn.add_theme_stylebox_override("focus", _plaque(true)) + + +func _plaque(hot: bool) -> StyleBoxFlat: + var s := StyleBoxFlat.new() + s.bg_color = Color(0.14, 0.09, 0.04, 0.94) if hot else Color(0.07, 0.05, 0.03, 0.82) + s.set_border_width_all(2) + s.border_width_bottom = 5 + s.border_color = MENU_CREAM if hot else MENU_GOLD + s.set_corner_radius_all(2) + s.content_margin_left = 24.0 + s.content_margin_right = 24.0 + s.content_margin_top = 8.0 + s.content_margin_bottom = 10.0 + s.shadow_color = Color(0.0, 0.0, 0.0, 0.5) + s.shadow_size = 6 + return s + + func _process(_delta: float) -> void: if not main_panel.visible or video.stream == null: return @@ -117,6 +190,9 @@ func _build_rebind_rows() -> void: var name_label := Label.new() name_label.text = ACTION_LABELS[action] 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_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", 4) row.add_child(name_label) @@ -124,6 +200,7 @@ func _build_rebind_rows() -> void: var key_button := Button.new() key_button.custom_minimum_size = Vector2(160, 0) key_button.text = Controls.get_key_label(action) + _style_button(key_button, false) key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button)) row.add_child(key_button) diff --git a/matador.gd b/matador.gd index fd072eb..8ba10f3 100644 --- a/matador.gd +++ b/matador.gd @@ -10,6 +10,10 @@ extends CharacterBody3D # RAGDOLL → hit by bull, no roll chance enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL } +# The matador's high-level *want*, chosen by a weighted utility vote (see +# _choose_intent). Each intent maps onto one or more concrete States below. +enum Intent { WANDER, FLEE, ATTACK, DODGE, THROW } + signal killed var _state: State = State.WANDER @@ -35,6 +39,8 @@ var _throw_aim_cd: float = 0.0 var _throw_dir: Vector3 = Vector3.ZERO var _steer_dir: Vector3 = Vector3.ZERO var _steer_cd: float = 0.0 +var _intent: Intent = Intent.WANDER +var _decide_cd: float = 0.0 var _ole_player: AudioStreamPlayer = null var _sword_hand_attach: BoneAttachment3D = null var _sword_rest_attach: BoneAttachment3D = null @@ -74,11 +80,12 @@ const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec # Side angles (radians) tried when the straight-ahead path is blocked. const _STEER_CANDIDATES: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] -# Movement tuning -const _ACCEL_FACTOR: float = 8.0 # velocity ramp = speed * factor * delta -const _TURN_MOVE: float = 8.0 # face the travel direction while roaming -const _TURN_FACE: float = 12.0 # face the bull while engaging -const _TURN_SHARP: float = 20.0 # snap onto a sidestep / roll direction +# Movement tuning. Ramp/turn rates are deliberately snappy so the matador reads as +# a quick, dangerous opponent rather than a sluggish one. +const _ACCEL_FACTOR: float = 13.0 # velocity ramp = speed * factor * delta +const _TURN_MOVE: float = 11.0 # face the travel direction while roaming +const _TURN_FACE: float = 16.0 # face the bull while engaging +const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction func _ready() -> void: @@ -97,7 +104,7 @@ func _ready() -> void: _anim_player.play(_ANIM_IDLE) _setup_sword() DP.any_changed.connect(_on_dp_changed) - _throw_aim_cd = randf_range(0.8, 2.5) + _throw_aim_cd = randf_range(0.3, 1.0) _hit_area.body_entered.connect(_on_body_entered) _pick_wander_target() _blood_burst = preload("res://blood_burst.gd").new() @@ -148,7 +155,7 @@ func _physics_process(delta: float) -> void: if _resword_timer <= 0.0: _spawn_sword() if _state != State.RAGDOLL and _bull != null: - _update_ai_state() + _update_ai_state(delta) match _state: State.WANDER: _tick_wander(delta) @@ -164,42 +171,110 @@ func _physics_process(delta: float) -> void: _advance_draw(delta) -func _update_ai_state() -> void: - if _state == State.THROW: +# Utility-driven brain. The physically committed states (a brace, a pass, a roll, a +# throw wind-up) run to completion on their own timers; from the free states the +# matador re-scores its options every mat_decide_interval and acts on the winner. +func _update_ai_state(delta: float) -> void: + if _state == State.BRACE or _state == State.SIDESTEP \ + or _state == State.ROLL or _state == State.THROW: return + + _decide_cd -= delta + if _decide_cd > 0.0: + return + _decide_cd = DP.f("mat_decide_interval") + + var intent := _choose_intent() + _intent = intent + if not _realizes_intent(intent): + _enter_intent(intent) + + +# Score each intent from weighted heuristics and return the winner. Weights (the +# mat_w_* params) shape the personality; a small commit bonus on the current intent +# stops it flip-flopping between two near-tied options. +func _choose_intent() -> Intent: var dist := global_position.distance_to(_bull.global_position) - if (_state == State.WANDER or _state == State.FLEE or _state == State.ATTACK) \ - and _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"): - # Roll for a throw; on a miss, wait before becoming eligible again so the - # matador commits to chasing for a melee Attack instead of throwing early. - if randf() < DP.f("mat_throw_chance"): + var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) + var bull_speed := bull_flat.length() + + # close = 1 right on top of us, 0 at attack range or beyond. `beyond` measures + # how far past throw range the bull is (0 point-blank, 1 at/outside throw range): + # the matador only idles when the bull is out there, and engages otherwise. + var reach := maxf(DP.f("mat_attack_range"), 0.5) + var throw_range := maxf(DP.f("mat_throw_range"), 1.0) + var close := clampf(1.0 - dist / reach, 0.0, 1.0) + var beyond := clampf(dist / throw_range, 0.0, 1.0) + # danger blends "the bull is fast and near" with "a charge is aimed at me". + var danger := clampf(bull_speed / maxf(DP.f("mat_charge_speed"), 0.1), 0.0, 1.0) * close + if _is_charge_incoming(): + danger = clampf(danger + 0.7, 0.0, 1.0) + + # A matador won't commit to a strike it can't bail out of, so a spent dodge + # cooldown makes attacking (and dodging outright) less attractive. + var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35 + 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") + + 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, + # 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. + Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \ + * (1.0 - 0.55 * danger) * dodge_ready, + # 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, + } + score[_intent] = float(score[_intent]) + DP.f("mat_w_commit") + + var best: Intent = Intent.WANDER + var best_score := -INF + for intent: int in score: + if float(score[intent]) > best_score: + best_score = float(score[intent]) + best = intent + return best + + +# True when the current State already carries out the intent, so we needn't restart +# it (restarting ATTACK every tick would reset its swing and stutter the animation). +func _realizes_intent(intent: Intent) -> bool: + match intent: + Intent.WANDER: + return _state == State.WANDER + Intent.FLEE: + return _state == State.FLEE + Intent.ATTACK: + return _state == State.ATTACK + Intent.DODGE: + return _state == State.BRACE or _state == State.SIDESTEP + Intent.THROW: + return _state == State.THROW + return false + + +func _enter_intent(intent: Intent) -> void: + match intent: + Intent.WANDER: + _state = State.WANDER + _steer_cd = 0.0 + _pick_wander_target() + Intent.FLEE: + _state = State.FLEE + _steer_cd = 0.0 + Intent.ATTACK: + _steer_cd = 0.0 + _start_attack() + Intent.DODGE: + _start_brace() + Intent.THROW: _start_throw() - return - _throw_aim_cd = randf_range(2.5, 5.0) - match _state: - State.WANDER: - if dist < DP.f("mat_flee_range"): - _state = State.FLEE - elif _dodge_cd <= 0.0 and dist < DP.f("mat_attack_range") and not _is_charge_incoming(): - _steer_cd = 0.0 - _start_attack() - State.FLEE: - if _dodge_cd <= 0.0 and (dist < DP.f("mat_commit_dist") or _is_charge_incoming()): - _start_brace() - elif dist > DP.f("mat_flee_range") * 1.3: - _state = State.WANDER - _steer_cd = 0.0 - _pick_wander_target() - State.BRACE: - pass # brace timer drives transition - State.SIDESTEP: - pass # step timer drives transition - State.ATTACK: - if _attack_timer <= 0.0 or dist > DP.f("mat_attack_range") * 1.5: - _state = State.FLEE - State.ROLL: - pass # roll timer drives transition # Bull is moving fast and aimed within ~30° of the matador @@ -339,16 +414,21 @@ func _tick_brace(delta: float) -> void: _brace_timer -= delta var dist := global_position.distance_to(_bull.global_position) - if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0: - if _will_dodge: + if _will_dodge: + # Dodger: wait for the bull to commit, then whip into the lateral pass. + if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0: _state = State.SIDESTEP _step_timer = DP.f("mat_step_duration") if _ole_player: _ole_player.pitch_scale = randf_range(0.88, 1.12) _ole_player.play() - else: - _state = State.FLEE + else: + # Planter (estocada): the bull only dies to an active, presented thrust, not to + # brushing a braced matador — so when it commits in, break into a real attack + # SWING (which lunges the blade forward and stabs), otherwise hold then press. + if dist < DP.f("mat_commit_dist") or (_brace_timer <= 0.0 and not _is_charge_incoming()): _dodge_cd = DP.f("mat_dodge_cooldown") + _start_attack() # Pase phase: sharp lateral step biased toward the bull so the drawn blade sweeps @@ -368,6 +448,7 @@ func _tick_sidestep(delta: float) -> void: velocity.z = dir.z * spd _face_point(_bull.global_position, delta, _TURN_SHARP) _play_anim(_ANIM_ATTACK) + _try_melee_hit(DP.f("mat_stab_reach")) move_and_slide() @@ -406,6 +487,7 @@ func _tick_attack(delta: float) -> void: else: _decelerate(22.0, delta) _play_anim(_ANIM_ATTACK) + _try_melee_hit(DP.f("mat_stab_reach")) else: var dir := _steer_clear(to_bull.normalized(), delta) _accelerate(dir, DP.f("mat_attack_speed"), delta) @@ -481,6 +563,10 @@ func _tick_throw(delta: float) -> void: func _end_throw() -> void: _state = State.FLEE _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) + _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). if _anim_player: @@ -539,15 +625,20 @@ func _release_sword() -> void: rb.collision_layer = 0 rb.collision_mask = 1 rb.contact_monitor = true - rb.max_contacts_reported = 4 + 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. + # 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.06 + cap.radius = 0.14 cap.height = 1.4 var cs := CollisionShape3D.new() cs.shape = cap @@ -555,19 +646,25 @@ func _release_sword() -> void: cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) rb.add_child(cs) - # Lead the target: aim where the bull will be when the sword arrives, so a - # moving bull isn't simply behind the throw by the time it lands. - var aim := _throw_dir + # 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) if is_instance_valid(_bull): - var speed := maxf(DP.f("mat_throw_speed"), 0.1) - var flat := _bull.global_position - gx.origin + 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 lead := (_bull.global_position + _bull.velocity * (flat.length() / speed)) - gx.origin - lead.y = 0.0 - if lead.length() > 0.1: - aim = lead.normalized() - var dir := (aim + Vector3.UP * 0.12).normalized() - rb.linear_velocity = dir * DP.f("mat_throw_speed") + 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. + 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] @@ -576,7 +673,7 @@ func _release_sword() -> void: return if body.is_in_group(&"player"): hit_done[0] = true - body.call(&"take_sword_hit") + body.call(&"take_sword_hit", "thrown") ) var cleanup := get_tree().create_timer(6.0) @@ -623,14 +720,36 @@ func _on_body_entered(body: Node3D) -> void: func _on_blade_hit(body: Node3D) -> void: - if _state != State.ATTACK and _state != State.SIDESTEP: + if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE: return if not body.is_in_group(&"player"): return if _blade_hit_cd > 0.0: return - _blade_hit_cd = 0.5 - body.call(&"take_sword_hit") + _blade_hit_cd = DP.f("mat_stab_cooldown") + body.call(&"take_sword_hit", "gored") + + +# Reach-based stab. A charging bull crosses metres per physics frame, so it easily +# tunnels through the thin blade Area between frames — this distance check is the +# reliable hit. Called from the offensive ticks (active swing / pass) whenever the +# blade is drawn: if the bull is inside reach AND the matador is facing it — the +# blade actually presented, not held off to the side — it's gored. +func _try_melee_hit(reach: float) -> void: + if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null: + return + var to_bull := _bull.global_position - global_position + to_bull.y = 0.0 + var dist := to_bull.length() + if dist > reach or dist < 0.01: + return + # Only lands within the frontal arc — you're gored on a presented blade, not by + # brushing a matador whose sword is pointing elsewhere. + var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y)) + if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))): + return + _blade_hit_cd = DP.f("mat_stab_cooldown") + _bull.call(&"take_sword_hit", "gored") func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void: @@ -833,8 +952,8 @@ func _spawn_sword() -> void: # 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). var blade_cap := CapsuleShape3D.new() - blade_cap.radius = 0.10 - blade_cap.height = 1.10 + 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) diff --git a/matador_spawn.gd b/matador_spawn.gd index 665ea82..c55615b 100644 --- a/matador_spawn.gd +++ b/matador_spawn.gd @@ -1,50 +1,24 @@ extends Node3D signal matador_killed -signal wave_changed(wave: int) +signal all_defeated const MATADOR := preload("res://Matador.tscn") -const RESPAWN_COOLDOWN := 8.0 -var _respawn_cooldown: float = 0.0 -var _spawn_count: int = 1 # matadors in the current wave — doubles on each clear -var _wave: int = 1 # wave number, surfaced on the HUD +var _spawn_count: int = 1 var _alive: int = 0 +var _resolved: bool = false func _ready() -> void: add_to_group(&"matador_spawn") _spawn_count = maxi(1, int(DP.f("mat_spawn_count"))) - _start_wave(1) - - -# Latest wave number, for a HUD that connects after the first wave has begun. -func current_wave() -> int: - return _wave - - -func _process(delta: float) -> void: - _respawn_cooldown = maxf(0.0, _respawn_cooldown - delta) - - -func _unhandled_input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and not event.echo: - if event.physical_keycode == KEY_R and _respawn_cooldown <= 0.0: - _respawn_cooldown = RESPAWN_COOLDOWN - reset_spawn() - - -# Announce a wave and spawn its matadors. Deferred spawn keeps it safe to call -# from _ready and from inside a kill handler mid-signal. -func _start_wave(number: int) -> void: - _wave = number - wave_changed.emit(_wave) + # Deferred so it's safe from _ready and matches the previous spawn timing. _spawn.call_deferred() func _spawn() -> void: - var positions := _spread_positions(_spawn_count) - for pos: Vector3 in positions: + for pos: Vector3 in _spread_positions(_spawn_count): _spawn_at(pos) @@ -68,18 +42,10 @@ func _spawn_at(pos: Vector3) -> void: _alive += 1 +# Every matador down = the bull wins. No waves, no respawns — one clean arena. func _on_matador_killed() -> void: matador_killed.emit() _alive -= 1 - if _alive <= 0: - _spawn_count *= 2 - _start_wave(_wave + 1) - - -func reset_spawn() -> void: - for m: Node in get_tree().get_nodes_in_group(&"matador"): - m.queue_free() - await get_tree().process_frame - _spawn_count = maxi(1, int(DP.f("mat_spawn_count"))) - _alive = 0 - _start_wave(1) + if _alive <= 0 and not _resolved: + _resolved = true + all_defeated.emit() diff --git a/player.gd b/player.gd index da4a6b4..1a543a0 100644 --- a/player.gd +++ b/player.gd @@ -38,6 +38,7 @@ var _ability_timer: float = 0.0 var _active_ability: int = -1 var _dash_dir: Vector3 = Vector3.ZERO var _roll_dir: Vector3 = Vector3.ZERO +var _roll_ramp: float = 0.0 var _bull_anim: AnimationPlayer = null var _charge_ready_emitter: CPUParticles3D = null @@ -54,9 +55,11 @@ var _charge_bar_ramp: Gradient = null var _charge_bar_time: float = 0.0 var _charge_bar_shown: float = 0.0 -const MAX_HEALTH: int = 10 -var health: int = MAX_HEALTH -signal health_changed(new_health: int) +# One clean hit ends the run — see take_sword_hit(). The HUD listens for `died` +# to raise the lose screen (the `cause` tags how it happened, for the death notice); +# _dead latches so a flurry of hits fires it only once. +signal died(cause: String) +var _dead: bool = false var _huff_player: AudioStreamPlayer = null var _crowd_player: AudioStreamPlayer = null @@ -460,15 +463,16 @@ func _activate_roll() -> void: _ability_active = true _active_ability = 3 _ability_timer = DP.f("roll_duration") - # Combine with any charge already underway: keep the current heading + speed, - # floored to a launch minimum so even a standing roll shoves off like a boulder. + _roll_ramp = 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. var flat := Vector3(velocity.x, 0.0, velocity.z) if flat.length() > 1.0: _roll_dir = flat.normalized() else: var f := cube_guy.global_transform.basis.z _roll_dir = Vector3(f.x, 0.0, f.z).normalized() - var launch := maxf(flat.length(), DP.f("roll_min_speed")) + var launch := DP.f("roll_base_speed") velocity.x = _roll_dir.x * launch velocity.z = _roll_dir.z * launch if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL): @@ -483,23 +487,21 @@ func _activate_roll() -> void: _huff_player.play() -# One rolling frame: steer, ramp/retain speed, move, then ricochet off walls and -# bowl through matadors. Fully replaces the normal locomotion path while active. +# 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() - # Ramp toward the cruise top speed; anything above it (from wall boosts) bleeds - # off slowly via roll_momentum instead of snapping back — that's the fun carry. - var speed := Vector2(velocity.x, velocity.z).length() + # 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. + _roll_ramp += delta + var ramp := clampf(_roll_ramp / maxf(DP.f("roll_rampup_time"), 0.01), 0.0, 1.0) var top := DP.f("roll_max_speed") - if speed < top: - speed = move_toward(speed, top, DP.f("roll_accel") * delta) - else: - speed = maxf(top, speed * pow(DP.f("roll_momentum"), delta)) - speed = minf(speed, DP.f("roll_wall_cap")) + var speed := lerpf(DP.f("roll_base_speed"), top, ramp) velocity.x = _roll_dir.x * speed velocity.z = _roll_dir.z * speed @@ -511,40 +513,43 @@ func _tick_roll(delta: float, steer: Vector3) -> void: if _charge_trail_emitter: _charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized() - var pre_wall := Vector3(velocity.x, 0.0, velocity.z) move_and_slide() _was_on_floor = is_on_floor() - _roll_wall_ricochet(pre_wall) - # Bowling strike: fling matadors we plow into up and outward like pins (and the - # resulting kills feed the HUD combo meter). - _hit_matadors_radius(DP.f("roll_hit_radius"), DP.f("roll_hit_strength"), DP.f("roll_hit_up")) + _roll_try_pop(speed) -# On hitting a wall mid-roll, mirror the heading and BOOST speed (capped) instead of -# bleeding it — banking off walls is how you build ludicrous momentum. -func _roll_wall_ricochet(pre_wall: Vector3) -> void: - for i in get_slide_collision_count(): - var col := get_slide_collision(i) - var normal := col.get_normal() - if absf(normal.y) >= 0.5: +# 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. +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"): + var to_mat: Vector3 = (mat as Node3D).global_position - global_position + to_mat.y = 0.0 + if to_mat.length() > radius: continue - var flat_n := Vector3(normal.x, 0.0, normal.z).normalized() - var refl := pre_wall - 2.0 * pre_wall.dot(flat_n) * flat_n - refl.y = 0.0 - if refl.length() < 0.01: - return - _roll_dir = refl.normalized() - var boosted := minf(pre_wall.length() * DP.f("roll_wall_boost"), DP.f("roll_wall_cap")) - velocity.x = _roll_dir.x * boosted - velocity.z = _roll_dir.z * boosted - var hit := col.get_position() - _spawn_kick_sparks(hit, flat_n) - _spawn_kick_flash(hit) - camera_pivot.call(&"trigger_hit", clampf(boosted / 90.0, 0.25, 1.0)) + _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() - return + _end_roll() + return true + return false + + +# 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: + _ability_active = false + _active_ability = -1 + _ability_timer = 0.0 + _roll_ramp = 0.0 + _legs.set(&"charge_pitch_target", 0.0) + _legs.set(&"tail_ragdoll", false) + velocity.x *= 0.15 + velocity.z *= 0.15 + _play_idle() func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float, @@ -1065,6 +1070,11 @@ func _physics_process(delta: float) -> void: # ── Hoof sounds ─────────────────────────────────────────────────────────── -func take_sword_hit() -> void: - health = maxi(0, health - 1) - health_changed.emit(health) +# 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: + return + _dead = true + died.emit(cause) diff --git a/project.godot b/project.godot index 16a26a9..860001e 100644 --- a/project.godot +++ b/project.godot @@ -11,6 +11,7 @@ config_version=5 [application] config/name="Bullosseum" +config/version="0.1.0" run/main_scene="res://MainMenu.tscn" config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" diff --git a/tests/difficulty_sim.gd b/tests/difficulty_sim.gd new file mode 100644 index 0000000..cd0022c --- /dev/null +++ b/tests/difficulty_sim.gd @@ -0,0 +1,258 @@ +extends SceneTree +## Headless difficulty simulator. +## +## Drives a kinematic "bot bull" against ONE real matador (the actual Matador.tscn +## + matador.gd AI) across a few behaviour profiles, many trials each, and reports +## how often the matador wins — i.e. gores the bull — versus how often the bull +## wins by ramming the matador. It exists so matador difficulty can be tuned from +## measurements instead of guesswork. +## +## godot --headless --script tests/difficulty_sim.gd +## +## Read the CHARGER row: that models a player who just charges in. If the matador +## almost never wins there, blind charging is unpunished and the game is too easy. + +const CHARGER_TRIALS := 16 +const KITER_TRIALS := 16 +const PASSIVE_TRIALS := 8 +const TRIAL_TIMEOUT := 5.0 +const SPAWN_SETTLE := 0.4 + +const CHARGE_SPEED := 42.0 # a fast, committed horn-charge +const KITE_DIST := 12.0 +const KITE_SPEED := 17.0 # a real bull easily out-paces the matador; only throws threaten +const START_DIST := 12.0 + +var _scene: Node +var _dp: Node +var _mat_scene: PackedScene +var _player: CharacterBody3D +var _matador: Node3D +var _bull_dead := false +var _mat_dead := false +var _dt := 1.0 / 60.0 + +# Charger bull state +var _charge_dir: Vector3 = Vector3.ZERO +var _charge_timer: float = 0.0 +var _recover_timer: float = 0.0 +# Kiter juke state +var _juke_dir: float = 1.0 +var _juke_timer: float = 0.0 +var _juke_range: float = KITE_DIST + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _dt = 1.0 / float(Engine.physics_ticks_per_second) + _dp = root.get_node_or_null("/root/DP") + _mat_scene = load("res://Matador.tscn") as PackedScene + + _scene = (load("res://scene.tscn") as PackedScene).instantiate() + root.add_child(_scene) + current_scene = _scene + await create_timer(0.5).timeout + + var players := get_nodes_in_group(&"player") + if players.is_empty(): + push_error("no player in scene"); quit(1); return + _player = players[0] + _player.set_physics_process(false) # we drive the bull kinematically + _player.died.connect(func(_cause: String) -> void: _bull_dead = true) + + _strip_match_machinery() # no HUD pause / spawner interference + + print("=".repeat(64)) + print("DIFFICULTY SIMULATION (physics %d Hz)" % Engine.physics_ticks_per_second) + print("=".repeat(64)) + + var charger := await _run_profile("CHARGER (fast straight charges)", CHARGER_TRIALS, &"charger") + var kiter := await _run_profile("KITER (circles at ~11 m)", KITER_TRIALS, &"kiter") + var passive := await _run_profile("PASSIVE (holds at attack range)", PASSIVE_TRIALS, &"passive") + + print("\n" + "=".repeat(64)) + print("SUMMARY — matador win %% (bull gored)") + print("=".repeat(64)) + _print_row("CHARGER", charger) + _print_row("KITER ", kiter) + _print_row("PASSIVE", passive) + print("=".repeat(64)) + quit(0) + + +var _state_hist: Dictionary = {} +var _throws: int = 0 +var _diag_printed: bool = false + + +func _run_profile(label: String, trials: int, mode: StringName) -> Dictionary: + var wins := 0 + var losses := 0 + var timeouts := 0 + var total_t := 0.0 + _state_hist = {} + _throws = 0 + for _i in trials: + var res := await _run_trial(mode) + total_t += res[1] as float + match res[0] as StringName: + &"mat": wins += 1 + &"bull": losses += 1 + _: timeouts += 1 + print("\n-- %s --" % label) + print(" matador wins: %d/%d bull wins: %d timeouts: %d avg %.2fs" % [ + wins, trials, losses, timeouts, total_t / maxf(float(trials), 1.0)]) + print(" swords thrown: %d states: %s" % [_throws, _fmt_hist()]) + return {"win": wins, "loss": losses, "timeout": timeouts, "n": trials} + + +func _fmt_hist() -> String: + var parts: Array[String] = [] + var total := 0 + for k: String in _state_hist: + total += _state_hist[k] as int + for k: String in _state_hist: + parts.append("%s %.0f%%" % [k, 100.0 * float(_state_hist[k]) / maxf(float(total), 1.0)]) + return ", ".join(parts) + + +func _count_thrown_swords() -> int: + var n := 0 + for c: Node in _scene.get_children(): + if c is RigidBody3D: + n += 1 + return n + + +func _run_trial(mode: StringName) -> Array: + if is_instance_valid(_matador): + _matador.queue_free() + await physics_frame + + _matador = _mat_scene.instantiate() + _scene.add_child(_matador) + _matador.global_position = Vector3(0.0, 1.0, 0.0) + _matador.killed.connect(func() -> void: _mat_dead = true) + + var ang := randf() * TAU + _player.global_position = Vector3(cos(ang), 0.0, sin(ang)) * START_DIST + Vector3(0.0, 1.0, 0.0) + _player.velocity = Vector3.ZERO + _player._dead = false + _bull_dead = false + _mat_dead = false + _charge_dir = Vector3.ZERO + _charge_timer = 0.0 + _recover_timer = 0.0 + _juke_dir = 1.0 if randf() < 0.5 else -1.0 + _juke_timer = 0.0 + _juke_range = KITE_DIST + + # Let the matador's _ready (ragdoll rig, sword) settle before the duel counts. + var settle := SPAWN_SETTLE + while settle > 0.0: + await physics_frame + settle -= _dt + _bull_dead = false + _mat_dead = false + _player._dead = false + + if not _diag_printed: + _diag_printed = true + print(" [diag] matador has_sword=%s in_hand=%s throw_range=[%.0f,%.0f]" % [ + is_instance_valid(_matador._sword_node), _matador._sword_in_hand, + _dp.f("mat_throw_min_dist"), _dp.f("mat_throw_range")]) + + var t := 0.0 + var max_swords := 0 + while t < TRIAL_TIMEOUT: + _drive(mode) + await physics_frame + t += _dt + var s: String = _matador.ai_state_name() + _state_hist[s] = int(_state_hist.get(s, 0)) + 1 + max_swords = maxi(max_swords, _count_thrown_swords()) + if _bull_dead: # the bull was gored → matador win + _throws += max_swords + return [&"mat", t] + if _mat_dead: # the matador was rammed → bull win + _throws += max_swords + return [&"bull", t] + _throws += max_swords + return [&"timeout", t] + + +func _drive(mode: StringName) -> void: + var to_mat := _matador.global_position - _player.global_position + to_mat.y = 0.0 + var dist := to_mat.length() + var aim := to_mat.normalized() if dist > 0.05 else Vector3.FORWARD + var vel := Vector3.ZERO + + match mode: + &"charger": + if _recover_timer > 0.0: + _recover_timer -= _dt + vel = -aim * 14.0 + else: + if _charge_timer <= 0.0: + _charge_dir = aim # commit a straight line (dodgeable) + _charge_timer = 0.6 + _charge_timer -= _dt + vel = _charge_dir * CHARGE_SPEED + if dist < 0.8 or _charge_timer <= 0.0: + _recover_timer = 0.35 + _charge_timer = 0.0 + &"kiter": + # Juke: flip strafe direction and shift the hold distance at random so the + # matador can't perfectly lead the throw — models an evasive player. + _juke_timer -= _dt + if _juke_timer <= 0.0: + _juke_timer = randf_range(0.4, 0.9) + _juke_dir = -_juke_dir if randf() < 0.6 else _juke_dir + _juke_range = randf_range(8.0, 13.0) + var inward := aim * clampf(dist - _juke_range, -1.0, 1.0) + var tangent := Vector3(-aim.z, 0.0, aim.x) * _juke_dir + vel = (tangent + inward).normalized() * KITE_SPEED + &"passive": + # Sit just inside the matador's attack range so it commits to a strike. + if dist > 6.5: + vel = aim * 6.0 + elif dist < 5.0: + vel = -aim * 4.0 + + _player.velocity = Vector3(vel.x, 0.0, vel.z) + _player.move_and_slide() + var p := _player.global_position + p.y = 1.0 + _player.global_position = p + + +func _strip_match_machinery() -> void: + for m: Node in get_nodes_in_group(&"matador"): + m.queue_free() + for s: Node in get_nodes_in_group(&"matador_spawn"): + s.queue_free() + var hud := _find_hud(_scene) + if hud != null: + hud.queue_free() + + +func _find_hud(n: Node) -> Node: + if n is CanvasLayer and n.has_method(&"_show_game_over"): + return n + for c: Node in n.get_children(): + var r := _find_hud(c) + if r != null: + return r + return null + + +func _print_row(label: String, r: Dictionary) -> void: + var n: int = r["n"] + var win: int = r["win"] + var pct := 100.0 * float(win) / maxf(float(n), 1.0) + print(" %s %5.1f%% (%d win / %d loss / %d timeout)" % [ + label, pct, win, r["loss"], r["timeout"]]) diff --git a/tests/difficulty_sim.gd.uid b/tests/difficulty_sim.gd.uid new file mode 100644 index 0000000..dbe129d --- /dev/null +++ b/tests/difficulty_sim.gd.uid @@ -0,0 +1 @@ +uid://dmrwponlq3eg6 diff --git a/tests/gameplay_test.gd b/tests/gameplay_test.gd index bb185ff..fcdf1f0 100644 --- a/tests/gameplay_test.gd +++ b/tests/gameplay_test.gd @@ -22,6 +22,15 @@ const TAIL_BONES: Array[StringName] = [ var _passed: int = 0 var _failed: int = 0 var _report: PackedStringArray = [] +var _matador_scene: PackedScene = null + + +# Loaded lazily (not preloaded): as a --script SceneTree entry, a top-level +# preload of a matador scene compiles matador.gd before the DP autoload binds. +func _mat_scene() -> PackedScene: + if _matador_scene == null: + _matador_scene = load("res://Matador.tscn") + return _matador_scene func _init() -> void: @@ -44,9 +53,21 @@ func _run() -> void: # Let physics, AI, and IK warm up. await create_timer(0.5).timeout + # The matador is lethal on contact now, so a wandering one could end the match + # during the non-combat checks below. Park the bull on a temp pad far out in the + # void, well out of any matador's reach (the roll tests use this spot too). + _isolate_bull(scene) + _check_bull_animation() await _check_overlays() + # The overlay check needed live matadors; now remove them. The aggressive matador + # pursues the bull, and the ragdoll check spawns its own fresh one, so none should + # be roaming during the capture / tail / roll phases. + for m: Node in get_nodes_in_group(&"matador"): + m.queue_free() + await physics_frame + # Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle). for i in range(5): _capture_frame("motion_%02d.png" % i) @@ -55,30 +76,54 @@ func _run() -> void: # After ~2.5 s the tail Verlet chain and IK should be stable. _check_tail_integrity() - # Trigger ragdoll on the first matador and check that bones don't explode. - var matadors := get_nodes_in_group(&"matador") - if matadors.size() > 0: - var mat: Node3D = matadors[0] as Node3D + # Roll first, in open space, before any matador dies (a kill ends the match). + await _check_roll_ability(scene) + await create_timer(0.1).timeout # let the roll pop-test's throwaway matador free + + # Ragdoll check on a FRESH matador spawned at the origin (the isolated bull is far + # away, so this one can't reach it). Not wired to the spawner, so its death won't + # trip the win screen. Ragdoll it immediately and check the bones don't explode. + var mat_scene := _mat_scene() + if mat_scene != null: + var mat: Node3D = mat_scene.instantiate() + scene.add_child(mat) + mat.global_position = Vector3(0.0, 1.0, 0.0) + await create_timer(0.3).timeout var start_pos: Vector3 = mat.global_position mat._enter_ragdoll(Vector3(0.0, 0.0, 1.0), 10.0) _capture_frame("ragdoll_trigger.png") await create_timer(0.8).timeout _capture_frame("ragdoll_result.png") _check_ragdoll_sanity(mat, start_pos) - _check_score_wiring(scene) else: - _note("ragdoll check skipped — no matadors found in scene") + _note("ragdoll check skipped — Matador.tscn missing") - # Last, so any matadors the roll bowls over don't perturb the score check above. - await _check_roll_ability(scene) + # Last — a bull hit raises the lose screen and pauses the tree. + await _check_game_over_wiring(scene) _finish() -# ── Roll ability ────────────────────────────────────────────────────────────── -# Roll into a synthetic wall and confirm the bank shot: the heading reflects and the -# speed boosts past roll_max_speed (the natural ramp caps at max, so any excess is a -# wall boost). A low roll_duration lets it end within the sample window. +# Drop a small static floor at (-45, -45) and stand the bull on it — 60+ m from the +# arena, so no matador can close the gap within the test window. +func _isolate_bull(scene: Node) -> void: + var pad := StaticBody3D.new() + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(20, 1, 20) + cs.shape = box + pad.add_child(cs) + scene.add_child(pad) + pad.global_position = Vector3(-45, -0.5, -45) # top surface at y = 0 + var players := get_nodes_in_group(&"player") + if not players.is_empty(): + (players[0] as Node3D).global_position = Vector3(-45, 1, -45) + + +# ── Roll ability (Rammus Powerball) ──────────────────────────────────────────── +# Two behaviours: the speed ramps up the longer the ball rolls (base → max), capped +# at roll_max_speed with no wall-boost overshoot; and ramming a matador pops the ball +# (the roll ends). Rolled in empty space so it doesn't touch the real match matadors. func _check_roll_ability(scene: Node) -> void: print("\n-- check_roll_ability --") @@ -91,41 +136,62 @@ func _check_roll_ability(scene: Node) -> void: var ap: AnimationPlayer = _find_anim_player(player) _assert_true(player.ability_cd.size() == 4, "bull has 4 ability slots (roll added)") - var wall := StaticBody3D.new() - var cs := CollisionShape3D.new() - var box := BoxShape3D.new() - box.size = Vector3(10, 5, 1) - cs.shape = box - wall.add_child(cs) - scene.add_child(wall) - wall.global_position = Vector3(0, 1, 8) - var restore_dur: float = dp.f("roll_duration") - dp.set_value("roll_duration", 0.6) - player.global_position = Vector3(0, 1, 0) - player.cube_guy.rotation.y = 0.0 # face +Z, into the wall - player.velocity = Vector3(0, 0, 40) + var restore_ramp: float = dp.f("roll_rampup_time") + dp.set_value("roll_duration", 1.2) + dp.set_value("roll_rampup_time", 0.7) + player.global_position = Vector3(-45, 1, -45) # empty void, away from the arena + player.velocity = Vector3.ZERO + player.cube_guy.rotation.y = 0.0 player.ability_cd[3] = 0.0 player._activate_roll() _assert_true(player._active_ability == 3, "roll activates in slot 3") if ap != null: _assert_true(ap.current_animation == &"Armature|ROLL", "roll plays the ROLL clip") + var early := -1.0 var peak := 0.0 - var reflected := false - for _n in 25: + for n in 30: await create_timer(0.03).timeout - peak = maxf(peak, Vector2(player.velocity.x, player.velocity.z).length()) - if player._roll_dir.z < -0.2: - reflected = true - _assert_true(reflected, "roll ricochets its heading off the wall") - _assert_true(peak > dp.f("roll_max_speed") + 1.0, "wall ricochet boosts speed past roll_max_speed") - _assert_true(peak <= dp.f("roll_wall_cap") + 1.0, "ricochet boost stays under the cap") - if ap != null: - _assert_true(ap.current_animation == &"Armature|IDLE", "roll returns to IDLE when it ends") + var spd := Vector2(player.velocity.x, player.velocity.z).length() + if n == 1: + early = spd + peak = maxf(peak, spd) + _assert_true(peak > early + 5.0, "roll speed ramps up over time (Powerball)") + _assert_true(peak <= dp.f("roll_max_speed") + 2.0, "roll speed caps at roll_max_speed (no wall boost)") dp.set_value("roll_duration", restore_dur) - wall.queue_free() + dp.set_value("roll_rampup_time", restore_ramp) + await _check_roll_pop(scene, player) + + +# Roll into a throwaway matador (not one of the match spawns, so its death doesn't +# end the game) and confirm the ball pops: the roll ability ends on contact. +func _check_roll_pop(scene: Node, player: Node) -> void: + var mat_scene := _mat_scene() + if mat_scene == null: + _note("roll pop: Matador.tscn missing") + return + var mat: Node3D = mat_scene.instantiate() + scene.add_child(mat) + mat.global_position = Vector3(-45, 1, -38) # ~7 m ahead of the bull along +Z + await create_timer(0.2).timeout + + player.global_position = Vector3(-45, 1, -45) + player.velocity = Vector3.ZERO + player.cube_guy.rotation.y = 0.0 # face +Z, toward the matador + player.ability_cd[3] = 0.0 + player._activate_roll() + + var popped := false + for _n in 45: + await create_timer(0.03).timeout + if player._active_ability != 3: + popped = true + break + _assert_true(popped, "roll pops (ends) on ramming a matador") + if is_instance_valid(mat): + mat.queue_free() # ── Bull animation ──────────────────────────────────────────────────────────── @@ -160,22 +226,31 @@ func _find_anim_player(node: Node) -> AnimationPlayer: return null -# ── Scoreboard wiring ───────────────────────────────────────────────────────── -# The ragdoll above is one matador kill; confirm it flowed matador → spawner → -# HUD and scored base × combo-1 = 100. Guards the whole kill→score signal chain. +# ── Win / lose wiring ───────────────────────────────────────────────────────── +# One clean sword hit must end the run in a loss, and the signal chain must be in +# place for a win (spawner.all_defeated → HUD). Runs last: it pauses the tree. -func _check_score_wiring(scene: Node) -> void: - print("\n-- check_score_wiring --") +func _check_game_over_wiring(scene: Node) -> void: + print("\n-- check_game_over_wiring --") var hud: Node = _find_hud(scene) - if hud == null: - _note("score check: HUD not found in scene") + var players := get_nodes_in_group(&"player") + if hud == null or players.is_empty(): + _note("game over check: HUD / player missing") return - _assert_true(hud._score == 100, "one kill scores 100 (base × combo 1) — got %d" % hud._score) - _assert_true(hud._combo == 1, "one kill sets combo to 1 — got %d" % hud._combo) + var player: Node = players[0] + _assert_true(player.has_signal(&"died"), "player exposes a died signal") + var spawners := get_nodes_in_group(&"matador_spawn") + _assert_true(spawners.is_empty() or spawners[0].has_signal(&"all_defeated"), + "spawner exposes an all_defeated signal (win route)") + + 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(not hud._result_win, "a bull hit is a loss, not a win") func _find_hud(node: Node) -> Node: - if node is CanvasLayer and node.has_method(&"_on_matador_killed"): + if node is CanvasLayer and node.has_method(&"_show_game_over"): return node for child in node.get_children(): var r := _find_hud(child) diff --git a/tests/performance_test.gd b/tests/performance_test.gd index d3c9a58..b036218 100644 --- a/tests/performance_test.gd +++ b/tests/performance_test.gd @@ -47,11 +47,23 @@ func _run() -> void: root.add_child(scene_inst) current_scene = scene_inst # match runtime: game code (sword throw, overlays) uses current_scene + # The matadors now stab the (idle) player; drop the HUD so a resulting player + # death doesn't raise the game-over screen and pause the tree mid-measurement. + var hud := _find_hud(scene_inst) + if hud != null: + hud.queue_free() + await create_timer(WARMUP_SEC).timeout var matadors := get_nodes_in_group(&"matador") print(" matadors alive: %d" % matadors.size()) + # The mass-ragdoll below kills every matador; hold the spawner's alive count high + # so that doesn't trip the win screen (which pauses the tree) mid-measurement. + var spawners := get_nodes_in_group(&"matador_spawn") + if not spawners.is_empty(): + spawners[0]._alive = 100000 + var normal := await _sample_frames(SAMPLE_FRAMES) _report_phase("normal play", normal) @@ -99,6 +111,16 @@ func _report_phase(label: String, sample: Array) -> void: "%s: peak %.2f ms under %.0f ms budget" % [label, max_ms, MAX_BUDGET_MS]) +func _find_hud(n: Node) -> Node: + if n is CanvasLayer and n.has_method(&"_show_game_over"): + return n + for c: Node in n.get_children(): + var r := _find_hud(c) + if r != null: + return r + return null + + func _assert_true(condition: bool, desc: String) -> void: if condition: _passed += 1 diff --git a/ui_fonts.gd b/ui_fonts.gd new file mode 100644 index 0000000..7dd8b93 --- /dev/null +++ b/ui_fonts.gd @@ -0,0 +1,37 @@ +class_name UiFonts +extends RefCounted +## Shared thematic fonts for menus and HUD. Cinzel is a Roman monumental capital +## typeface — a deliberate nod to the "-osseum" (Colosseum) in Bullosseum. +## +## Fonts load via load_dynamic_font so they resolve identically in the editor, +## headless tests and exported builds, whether or not the .ttf has been imported. + +const _DECORATIVE := "res://fonts/CinzelDecorative-Bold.ttf" +const _ROMAN := "res://fonts/Cinzel.ttf" + +static var _title: FontFile = null +static var _body: FontFile = null + + +## Ornate display face — big banners, menu title, win/lose text. +static func title() -> FontFile: + if _title == null: + _title = _load(_DECORATIVE) + return _title + + +## Cleaner monumental face — buttons, labels, body copy. +static func body() -> FontFile: + if _body == null: + _body = _load(_ROMAN) + return _body + + +static func _load(path: String) -> FontFile: + if ResourceLoader.exists(path): + var res := load(path) + if res is FontFile: + return res as FontFile + var f := FontFile.new() + f.load_dynamic_font(path) + return f diff --git a/ui_fonts.gd.uid b/ui_fonts.gd.uid new file mode 100644 index 0000000..2987f3b --- /dev/null +++ b/ui_fonts.gd.uid @@ -0,0 +1 @@ +uid://dpnkwfi2ogmpx diff --git a/videos/bull_win.ogv b/videos/bull_win.ogv new file mode 100644 index 0000000..760f525 Binary files /dev/null and b/videos/bull_win.ogv differ diff --git a/videos/bull_win.ogv.uid b/videos/bull_win.ogv.uid new file mode 100644 index 0000000..0f8b340 --- /dev/null +++ b/videos/bull_win.ogv.uid @@ -0,0 +1 @@ +uid://d2p7uj5u04wjw diff --git a/videos/matador_win.ogv b/videos/matador_win.ogv new file mode 100644 index 0000000..9807945 Binary files /dev/null and b/videos/matador_win.ogv differ diff --git a/videos/matador_win.ogv.uid b/videos/matador_win.ogv.uid new file mode 100644 index 0000000..c1f7246 --- /dev/null +++ b/videos/matador_win.ogv.uid @@ -0,0 +1 @@ +uid://bmv1okx63xg33