diff --git a/camera_iso.gd b/camera_iso.gd index 272c8dc..27c6dfd 100644 --- a/camera_iso.gd +++ b/camera_iso.gd @@ -5,12 +5,33 @@ extends Camera3D @export var horizontal_angle_deg: float = 45.0 @export var ortho_size: float = 15.0 +const _FLASH_DURATION: float = 0.30 + +var _shake_amount: float = 0.0 +var _shake_decay: float = 0.0 +var _flash_timer: float = 0.0 +var _flash_rect: ColorRect = null + + func _ready() -> void: set_as_top_level(true) projection = PROJECTION_ORTHOGONAL size = ortho_size + _setup_flash() -func _process(_delta: float) -> void: + +func _setup_flash() -> void: + var canvas := CanvasLayer.new() + canvas.layer = 20 + add_child(canvas) + _flash_rect = ColorRect.new() + _flash_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _flash_rect.color = Color(1.0, 0.25, 0.2, 0.0) + _flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + canvas.add_child(_flash_rect) + + +func _process(delta: float) -> void: var angle_rad := deg_to_rad(horizontal_angle_deg) var offset := Vector3( sin(angle_rad) * horizontal_distance, @@ -20,3 +41,23 @@ func _process(_delta: float) -> void: var target := get_parent() as Node3D global_position = target.global_position + offset look_at(target.global_position, Vector3.UP) + + if _shake_amount > 0.005: + global_position += Vector3( + randf_range(-_shake_amount, _shake_amount), + randf_range(-_shake_amount * 0.25, _shake_amount * 0.25), + randf_range(-_shake_amount, _shake_amount), + ) + _shake_amount = move_toward(_shake_amount, 0.0, _shake_decay * delta) + + if _flash_timer > 0.0: + _flash_timer = maxf(0.0, _flash_timer - delta) + _flash_rect.color.a = (_flash_timer / _FLASH_DURATION) * 0.6 + else: + _flash_rect.color.a = 0.0 + + +func trigger_hit(strength: float) -> void: + _shake_amount = strength * 0.5 + _shake_decay = _shake_amount / 0.4 + _flash_timer = _FLASH_DURATION diff --git a/matador.gd b/matador.gd index 7d2a69c..bc256f4 100644 --- a/matador.gd +++ b/matador.gd @@ -249,6 +249,10 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: _spawn_blood_burst(hit_dir) + var cam := _bull.get_node_or_null("Camera3D") + 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 diff --git a/player.gd b/player.gd index 750c8c1..de0180b 100644 --- a/player.gd +++ b/player.gd @@ -7,6 +7,7 @@ const HOOF_OFFSETS: Array = [ Vector3(-0.30, -0.25, 0.60), Vector3( 0.30, -0.25, 0.60), ] +const SNOUT_OFFSET: Vector3 = Vector3(0.0, 0.5, -1.0) @onready var camera_pivot: Node3D = $Camera3D @onready var cube_guy: Node3D = $bull_v10 @@ -21,6 +22,10 @@ var _charge_dir: Vector3 = Vector3.ZERO var _both_held: bool = false var _thrust_left_charge: float = 0.0 var _thrust_right_charge: float = 0.0 +var _charge_was_full: bool = false + +var _snort_emitter: CPUParticles3D = null +var _charge_ready_emitter: CPUParticles3D = null enum DustState { NONE, WALK, CHARGE } var _dust_state: DustState = DustState.NONE @@ -30,6 +35,7 @@ func _ready() -> void: add_to_group(&"player") _setup_hoof_dust() _setup_legs() + _setup_charge_indicators() DP.any_changed.connect(_on_dp_changed) @@ -86,6 +92,73 @@ func _setup_hoof_dust() -> void: _hoof_emitters.append(p) +func _setup_charge_indicators() -> void: + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.vertex_color_use_as_albedo = true + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + + var sphere := SphereMesh.new() + sphere.radius = 0.07 + sphere.height = 0.14 + sphere.radial_segments = 4 + sphere.rings = 2 + sphere.material = mat + + # Snort steam from nose during charge-ahead (both buttons held) + var snort_ramp := Gradient.new() + snort_ramp.set_color(0, Color(0.95, 0.95, 0.95, 0.9)) + snort_ramp.set_color(1, Color(0.80, 0.80, 0.80, 0.0)) + + _snort_emitter = CPUParticles3D.new() + _snort_emitter.position = SNOUT_OFFSET + _snort_emitter.emitting = false + _snort_emitter.amount = 6 + _snort_emitter.lifetime = 0.35 + _snort_emitter.explosiveness = 0.0 + _snort_emitter.randomness = 0.5 + _snort_emitter.local_coords = true + _snort_emitter.direction = Vector3(0.0, 0.2, -1.0).normalized() + _snort_emitter.spread = 25.0 + _snort_emitter.initial_velocity_min = 2.0 + _snort_emitter.initial_velocity_max = 4.0 + _snort_emitter.scale_amount_min = 0.3 + _snort_emitter.scale_amount_max = 0.7 + _snort_emitter.mesh = sphere + _snort_emitter.color_ramp = snort_ramp + cube_guy.add_child(_snort_emitter) + + # Orange spark burst when single-button charge hits max + var burst_ramp := Gradient.new() + burst_ramp.set_color(0, Color(1.0, 0.55, 0.0, 1.0)) + burst_ramp.set_color(1, Color(1.0, 0.15, 0.0, 0.0)) + + var burst_scale := Curve.new() + burst_scale.add_point(Vector2(0.0, 1.0)) + burst_scale.add_point(Vector2(1.0, 0.0)) + + _charge_ready_emitter = CPUParticles3D.new() + _charge_ready_emitter.position = Vector3(0.0, 0.3, 0.0) + _charge_ready_emitter.emitting = false + _charge_ready_emitter.one_shot = true + _charge_ready_emitter.amount = 16 + _charge_ready_emitter.lifetime = 0.5 + _charge_ready_emitter.explosiveness = 1.0 + _charge_ready_emitter.randomness = 0.4 + _charge_ready_emitter.local_coords = false + _charge_ready_emitter.direction = Vector3.UP + _charge_ready_emitter.spread = 120.0 + _charge_ready_emitter.initial_velocity_min = 2.5 + _charge_ready_emitter.initial_velocity_max = 5.0 + _charge_ready_emitter.scale_amount_min = 0.2 + _charge_ready_emitter.scale_amount_max = 0.5 + _charge_ready_emitter.mesh = sphere + _charge_ready_emitter.color_ramp = burst_ramp + _charge_ready_emitter.scale_amount_curve = burst_scale + cube_guy.add_child(_charge_ready_emitter) + + func _set_dust_state(new_state: DustState) -> void: if new_state == _dust_state: return @@ -225,6 +298,17 @@ func _physics_process(delta: float) -> void: _apply_thrust(Vector3(raw.x, 0.0, raw.z).normalized().rotated(Vector3.UP, angle)) _thrust_right_charge = 0.0 + # Fire spark burst the frame either single-button charge first hits max + var max_charge := DP.f("thrust_charge_time") + var either_full := _thrust_left_charge >= max_charge or _thrust_right_charge >= max_charge + if either_full and not _charge_was_full: + _charge_ready_emitter.restart() + _charge_was_full = either_full + + # Snort while charge-ahead is active + if _snort_emitter.emitting != both_held: + _snort_emitter.emitting = both_held + _both_held = both_held # Reset kick timer on landing so the first kick after a bounce is immediate