diff --git a/debug_params.gd b/debug_params.gd index 9f424c9..78758eb 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -108,6 +108,12 @@ func _register_all() -> void: _reg_f("Matador", "mat_step_duration", 0.35, 0.1, 1.0, 0.05) _reg_f("Matador", "mat_dodge_cooldown", 5.0, 0.5, 12.0, 0.25) _reg_f("Matador", "mat_dodge_chance", 0.25, 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_duration", 1.8, 0.5, 5.0, 0.1) + _reg_f("Matador", "mat_attack_min_dist", 2.5, 0.5, 6.0, 0.1) + _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) # ── Debug ───────────────────────────────────────────────────────────────── _reg_b("Debug", "show_collisions", false) diff --git a/hud.gd b/hud.gd index af29d03..2ae39b4 100644 --- a/hud.gd +++ b/hud.gd @@ -7,26 +7,16 @@ 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) -const ROUND_DURATION := 60.0 - var _controls_visible: bool = true -var _timer_remaining: float = ROUND_DURATION -var _score: int = 0 -var _round_active: bool = true - var _controls_panel: PanelContainer -var _timer_label: Label -var _score_label: Label var _toggle_btn: Button -var _game_over_panel: Control -var _final_score_label: Label var _spawner: Node func _ready() -> void: layer = 11 process_mode = Node.PROCESS_MODE_ALWAYS - _build() + _build_controls_panel() _find_spawner.call_deferred() @@ -34,16 +24,6 @@ func _find_spawner() -> void: var spawners := get_tree().get_nodes_in_group(&"matador_spawn") if not spawners.is_empty(): _spawner = spawners[0] - _spawner.matador_killed.connect(_on_matador_killed) - - -func _process(delta: float) -> void: - if not _round_active: - return - _timer_remaining = maxf(0.0, _timer_remaining - delta) - _update_timer_display() - if _timer_remaining <= 0.0: - _end_round() func _unhandled_input(event: InputEvent) -> void: @@ -58,51 +38,13 @@ func _toggle_controls() -> void: _toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" -func _on_matador_killed() -> void: - if _round_active: - _score += 1 - _update_score_display() - - -func _end_round() -> void: - _round_active = false - _final_score_label.text = "You killed %d matadors!" % _score - _game_over_panel.visible = true - get_tree().paused = true - - -func _on_continue_pressed() -> void: - _on_reset_pressed() - - func _on_reset_pressed() -> void: if get_tree().paused: get_tree().paused = false - _game_over_panel.visible = false - _timer_remaining = ROUND_DURATION - _score = 0 - _round_active = true - _update_timer_display() - _update_score_display() if _spawner: _spawner.reset_spawn() -func _update_timer_display() -> void: - var s := int(_timer_remaining) - _timer_label.text = "%d:%02d" % [s / 60, s % 60] - - -func _update_score_display() -> void: - _score_label.text = "Kills: %d" % _score - - -func _build() -> void: - _build_controls_panel() - _build_score_panel() - _build_game_over_overlay() - - func _build_controls_panel() -> void: _controls_panel = PanelContainer.new() @@ -144,58 +86,11 @@ func _build_controls_panel() -> void: _row(vbox, "R", "Respawn matadors (cooldown)", _MUTED) _row(vbox, "H", "Toggle controls", _MUTED) - -func _build_score_panel() -> void: - var panel := PanelContainer.new() - - var bg := StyleBoxFlat.new() - bg.bg_color = _BG - bg.corner_radius_top_left = 7 - bg.corner_radius_top_right = 7 - bg.corner_radius_bottom_left = 7 - bg.corner_radius_bottom_right = 7 - bg.border_width_left = 1 - bg.border_width_right = 1 - bg.border_width_top = 1 - bg.border_width_bottom = 1 - bg.border_color = _BORDER - bg.content_margin_left = 16.0 - bg.content_margin_right = 16.0 - bg.content_margin_top = 12.0 - bg.content_margin_bottom = 12.0 - panel.add_theme_stylebox_override("panel", bg) - - panel.anchor_left = 1.0 - panel.anchor_top = 0.0 - panel.anchor_right = 1.0 - panel.anchor_bottom = 0.0 - panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN - panel.grow_vertical = Control.GROW_DIRECTION_END - panel.offset_right = -20.0 - panel.offset_top = 20.0 - add_child(panel) - - var vbox := VBoxContainer.new() - vbox.add_theme_constant_override("separation", 8) - panel.add_child(vbox) - - _timer_label = Label.new() - _timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _timer_label.add_theme_color_override("font_color", _GOLD) - _timer_label.add_theme_font_size_override("font_size", 32) - vbox.add_child(_timer_label) - - _score_label = Label.new() - _score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _score_label.add_theme_color_override("font_color", _CREAM) - _score_label.add_theme_font_size_override("font_size", 16) - vbox.add_child(_score_label) - var sep := HSeparator.new() var sep_style := StyleBoxFlat.new() sep_style.bg_color = Color(0.65, 0.48, 0.15, 0.30) - sep_style.content_margin_top = 2.0 - sep_style.content_margin_bottom = 2.0 + sep_style.content_margin_top = 4.0 + sep_style.content_margin_bottom = 4.0 sep.add_theme_stylebox_override("separator", sep_style) vbox.add_child(sep) @@ -211,71 +106,6 @@ func _build_score_panel() -> void: reset_btn.pressed.connect(_on_reset_pressed) hbox.add_child(reset_btn) - _update_timer_display() - _update_score_display() - - -func _build_game_over_overlay() -> void: - _game_over_panel = Control.new() - _game_over_panel.set_anchors_preset(Control.PRESET_FULL_RECT) - _game_over_panel.visible = false - _game_over_panel.process_mode = Node.PROCESS_MODE_ALWAYS - add_child(_game_over_panel) - - var backdrop := ColorRect.new() - backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) - backdrop.color = Color(0.0, 0.0, 0.0, 0.65) - _game_over_panel.add_child(backdrop) - - var card := PanelContainer.new() - var card_bg := StyleBoxFlat.new() - card_bg.bg_color = Color(0.08, 0.05, 0.02, 0.95) - card_bg.corner_radius_top_left = 12 - card_bg.corner_radius_top_right = 12 - card_bg.corner_radius_bottom_left = 12 - card_bg.corner_radius_bottom_right = 12 - card_bg.border_width_left = 2 - card_bg.border_width_right = 2 - card_bg.border_width_top = 2 - card_bg.border_width_bottom = 2 - card_bg.border_color = _GOLD - card_bg.content_margin_left = 48.0 - card_bg.content_margin_right = 48.0 - card_bg.content_margin_top = 36.0 - card_bg.content_margin_bottom = 36.0 - card.add_theme_stylebox_override("panel", card_bg) - card.anchor_left = 0.5 - card.anchor_top = 0.5 - card.anchor_right = 0.5 - card.anchor_bottom = 0.5 - card.grow_horizontal = Control.GROW_DIRECTION_BOTH - card.grow_vertical = Control.GROW_DIRECTION_BOTH - _game_over_panel.add_child(card) - - var vbox := VBoxContainer.new() - vbox.add_theme_constant_override("separation", 18) - vbox.alignment = BoxContainer.ALIGNMENT_CENTER - card.add_child(vbox) - - var title := Label.new() - title.text = "TIME'S UP!" - title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title.add_theme_color_override("font_color", _GOLD) - title.add_theme_font_size_override("font_size", 48) - vbox.add_child(title) - - _final_score_label = Label.new() - _final_score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _final_score_label.add_theme_color_override("font_color", _CREAM) - _final_score_label.add_theme_font_size_override("font_size", 24) - vbox.add_child(_final_score_label) - - var continue_btn := _make_button("Continue") - continue_btn.custom_minimum_size = Vector2(160.0, 44.0) - continue_btn.process_mode = Node.PROCESS_MODE_ALWAYS - continue_btn.pressed.connect(_on_continue_pressed) - vbox.add_child(continue_btn) - func _make_button(label_text: String) -> Button: var btn := Button.new() diff --git a/matador.gd b/matador.gd index 46f4bf5..707d238 100644 --- a/matador.gd +++ b/matador.gd @@ -1,11 +1,13 @@ extends CharacterBody3D # WANDER → bull outside flee range, roaming freely -# FLEE → bull within range, matador backs away +# FLEE → bull within range, matador backs away (raycast-steered around walls) # BRACE → charge detected: plant feet, face the bull (cite phase) # SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase) -# RAGDOLL → hit by bull at speed -enum State { WANDER, FLEE, BRACE, SIDESTEP, RAGDOLL } +# ATTACK → post-dodge or mid-range: chase and strike +# ROLL → hit by bull but roll chance fires: survive and recover +# RAGDOLL → hit by bull, no roll chance +enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, RAGDOLL } signal killed @@ -21,8 +23,13 @@ var _brace_timer: float = 0.0 var _step_timer: float = 0.0 var _dodge_cd: float = 0.0 var _will_dodge: bool = true -var _ole_player: AudioStreamPlayer = null -var _death_player: AudioStreamPlayer = null +var _attack_timer: float = 0.0 +var _roll_timer: float = 0.0 +var _roll_dir: Vector3 = Vector3.ZERO +var _steer_dir: Vector3 = Vector3.ZERO +var _steer_cd: float = 0.0 +var _ole_player: AudioStreamPlayer = null +var _death_player: AudioStreamPlayer = null var _blood_burst: CPUParticles3D = null @onready var _mesh: Node3D = $matador2 @@ -34,6 +41,11 @@ const _ANIM_IDLE: StringName = &"Taunt" const _ANIM_ATTACK: StringName = &"Attack" const _ANIM_ROLL: StringName = &"Roll" +# Candidate steering offsets (radians) tried in order when a wall is ahead +const _STEER_ANGLES: Array = [0.0, -0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] +const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall +const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec + func _ready() -> void: add_to_group(&"matador") @@ -82,6 +94,8 @@ func _physics_process(delta: float) -> void: State.FLEE: _tick_flee(delta) State.BRACE: _tick_brace(delta) State.SIDESTEP: _tick_sidestep(delta) + State.ATTACK: _tick_attack(delta) + State.ROLL: _tick_roll(delta) State.RAGDOLL: _tick_ragdoll(delta) @@ -91,16 +105,25 @@ func _update_ai_state() -> void: 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 @@ -115,6 +138,37 @@ func _is_charge_incoming() -> bool: return bull_flat.normalized().dot(to_me.normalized()) > 0.85 +# Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds. +# Randomises among available clear angles so juke direction is unpredictable. +func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3: + _steer_cd -= delta + if _steer_cd > 0.0 and _steer_dir != Vector3.ZERO: + return _steer_dir + _steer_cd = _STEER_INTERVAL + var space := get_world_3d().direct_space_state + var origin := global_position + Vector3.UP * 0.6 + var params := PhysicsRayQueryParameters3D.new() + params.exclude = [get_rid()] + params.collision_mask = 1 + # Try straight ahead first; if blocked, pick randomly from the side candidates. + params.from = origin + params.to = origin + desired_dir * _STEER_LOOKAHEAD + if space.intersect_ray(params).is_empty(): + _steer_dir = desired_dir + return desired_dir + var candidates: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] + candidates.shuffle() + for angle: float in candidates: + var dir := desired_dir.rotated(Vector3.UP, angle) + params.from = origin + params.to = origin + dir * _STEER_LOOKAHEAD + if space.intersect_ray(params).is_empty(): + _steer_dir = dir + return dir + _steer_dir = desired_dir + return desired_dir + + func _tick_wander(delta: float) -> void: if _idle_timer > 0.0: _idle_timer -= delta @@ -130,10 +184,10 @@ func _tick_wander(delta: float) -> void: _idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) _pick_wander_target() else: - var dir := Vector3(dx, 0.0, dz).normalized() + var dir := _steer_clear(Vector3(dx, 0.0, dz).normalized(), delta) var spd := DP.f("mat_walk_speed") - velocity.x = dir.x * spd - velocity.z = dir.z * spd + velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) + velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) _mesh.rotation.y = lerp_angle( _mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0) _play_anim(_ANIM_RUN) @@ -145,10 +199,10 @@ func _tick_flee(delta: float) -> void: away.y = 0.0 if away.length() < 0.01: away = Vector3(randf() - 0.5, 0.0, randf() - 0.5) - away = away.normalized() + var dir := _steer_clear(away.normalized(), delta) var spd := DP.f("mat_flee_speed") - velocity.x = away.x * spd - velocity.z = away.z * spd + velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) + velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0) _play_anim(_ANIM_RUN) move_and_slide() @@ -156,9 +210,9 @@ func _tick_flee(delta: float) -> void: # Cite phase: plant feet, face the bull — wait for it to commit func _start_brace() -> void: + _steer_cd = 0.0 _state = State.BRACE var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) - # Use bull velocity if it's moving, else use bull→matador direction as proxy var charge_dir: Vector3 if bull_flat.length() > 1.0: charge_dir = bull_flat.normalized() @@ -166,7 +220,6 @@ func _start_brace() -> void: charge_dir = (_bull.global_position - global_position) charge_dir.y = 0.0 charge_dir = charge_dir.normalized() - # Decide whether to dodge at all and which side — fixed before the bull arrives _will_dodge = randf() < DP.f("mat_dodge_chance") var side := 1.0 if randf() > 0.5 else -1.0 _step_dir = charge_dir.rotated(Vector3.UP, PI * 0.5 * side) @@ -174,10 +227,8 @@ func _start_brace() -> void: func _tick_brace(delta: float) -> void: - # Decelerate to a stop velocity.x = move_toward(velocity.x, 0.0, 18.0 * delta) velocity.z = move_toward(velocity.z, 0.0, 18.0 * delta) - # Face the bull var to_bull := (_bull.global_position - global_position) to_bull.y = 0.0 if to_bull.length() > 0.1: @@ -188,7 +239,6 @@ func _tick_brace(delta: float) -> void: _brace_timer -= delta var dist := global_position.distance_to(_bull.global_position) - # Execute the step when the bull is almost on top or the brace window expires if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0: if _will_dodge: _state = State.SIDESTEP @@ -197,22 +247,70 @@ func _tick_brace(delta: float) -> void: _ole_player.pitch_scale = randf_range(0.88, 1.12) _ole_player.play() else: - # Stand and eat it — no juke _state = State.FLEE _dodge_cd = DP.f("mat_dodge_cooldown") -# Pase phase: sharp lateral step as the bull commits to its line +# Pase phase: sharp lateral step, then immediately exploit with an attack func _tick_sidestep(delta: float) -> void: _step_timer -= delta if _step_timer <= 0.0: - _state = State.FLEE - _dodge_cd = DP.f("mat_dodge_cooldown") + _start_attack() return var spd := DP.f("mat_step_speed") velocity.x = _step_dir.x * spd velocity.z = _step_dir.z * spd - # Snap facing quickly to sell the movement + _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 20.0) + _play_anim(_ANIM_ROLL) + move_and_slide() + + +func _start_attack() -> void: + _state = State.ATTACK + _attack_timer = DP.f("mat_attack_duration") + _dodge_cd = DP.f("mat_dodge_cooldown") + + +func _tick_attack(delta: float) -> void: + _attack_timer -= delta + var to_bull := (_bull.global_position - global_position) + to_bull.y = 0.0 + var dist := to_bull.length() + + if dist > 0.1: + _mesh.rotation.y = lerp_angle( + _mesh.rotation.y, atan2(to_bull.x, to_bull.z), delta * 10.0) + + if dist > DP.f("mat_attack_min_dist"): + var dir := _steer_clear(to_bull.normalized(), delta) + var spd := DP.f("mat_attack_speed") + velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) + velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) + else: + velocity.x = move_toward(velocity.x, 0.0, 14.0 * delta) + velocity.z = move_toward(velocity.z, 0.0, 14.0 * delta) + + _play_anim(_ANIM_ATTACK) + move_and_slide() + + +func _enter_roll(hit_dir: Vector3) -> void: + _state = State.ROLL + _roll_timer = DP.f("mat_roll_duration") + hit_dir.y = 0.0 + var side := 1.0 if randf() > 0.5 else -1.0 + _roll_dir = hit_dir.normalized().rotated(Vector3.UP, PI * 0.5 * side) + + +func _tick_roll(delta: float) -> void: + _roll_timer -= delta + if _roll_timer <= 0.0: + _state = State.FLEE + _dodge_cd = DP.f("mat_dodge_cooldown") + return + var spd := DP.f("mat_step_speed") + velocity.x = _roll_dir.x * spd + velocity.z = _roll_dir.z * spd _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 20.0) _play_anim(_ANIM_ROLL) move_and_slide() @@ -225,7 +323,7 @@ func _tick_ragdoll(_delta: float) -> void: func _on_body_entered(body: Node3D) -> void: - if _state == State.RAGDOLL: + if _state == State.RAGDOLL or _state == State.ROLL: return if not body.is_in_group(&"player"): return @@ -235,20 +333,23 @@ func _on_body_entered(body: Node3D) -> void: var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z) var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \ (global_position - player.global_position).normalized() - _enter_ragdoll(hit_dir, player.velocity.length()) + + if randf() < DP.f("mat_roll_chance"): + _enter_roll(hit_dir) + else: + _enter_ragdoll(hit_dir, player.velocity.length()) func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: _state = State.RAGDOLL killed.emit() - var _t := get_tree().create_timer(4.0) - _t.timeout.connect(func() -> void: + var cleanup_timer := get_tree().create_timer(4.0) + cleanup_timer.timeout.connect(func() -> void: if is_instance_valid(self): queue_free() ) hit_dir.y = 0.0 hit_dir = hit_dir.normalized() - # Arc upward so the body lifts before falling — feels like a real goring throw var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized() _spawn_blood_burst(hit_dir) @@ -260,22 +361,14 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: if cam: cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0)) - # Keep the CharacterBody3D stationary: physics bones simulate relative to the - # skeleton root, so sliding the root makes every bone pose diverge. velocity = Vector3.ZERO - - # Remove from collision layer so the bull passes through, but keep the - # shape enabled — move_and_slide() still needs it to detect the floor. collision_layer = 0 if _anim_player: - _anim_player.pause() # pause keeps current frame; stop() resets to T-pose + _anim_player.pause() if not _sim: return - # Teleport each physics body to its current bone world transform, then - # enable per-bone simulation. _sim.active alone does NOT set simulate_physics; - # without it _process_modification() skips every bone and writes nothing. for child: Node in _sim.get_children(): if not (child is PhysicalBone3D): continue @@ -294,7 +387,6 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: var impulse := Vector3.ZERO match pb.bone_name: "COG", "matador": - # Core of mass takes the full hit impulse = throw_dir * strength "chest": impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9 @@ -309,10 +401,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: "arm_R": impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 _: - # Distal bones (forearms, shins, hands, feet) get no direct impulse; - # they trail naturally from the joints above them continue - # Small random noise per bone so no two look identical var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15)) pb.apply_central_impulse(impulse + noise * strength * 0.2) @@ -385,6 +474,14 @@ 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) + var bull_flat := _bull.global_position + bull_flat.y = 0.0 + _wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r) + return var radius := DP.f("mat_wander_radius") var angle := randf() * TAU var dist := randf_range(2.0, radius)