class_name ShotSetup extends Node3D signal finished(source: ShotSetup) enum Phase { AIM, POWER_ADJUST, CURVE_ADJUST, DOWNSWING, SHOT, SHOT_RESET, FINISHED, DEAD, } const PITCH_MIN := deg_to_rad(-60.0) const PITCH_MAX := deg_to_rad(-5.0) const ZOOM_LENGTH := 0.1 const ZOOM_ELEVATION_RATIO := 0.3166 const ZOOM_MIN := 1.0 const ZOOM_MAX := 12.0 const ARROW_ACCELERATION := 8.0 const PLAYER_ACCELERATION := 2.0 const FREE_CAM_GIMBAL_TWEEN_TIME := 0.2 const FREE_CAM_RETURN_TIME := 0.618 const BALL_RETURN_TIME := 0.618 const CAMERA_SNAP_TIME := 0.3 const WASTED_BALL_RETURN_DELAY := 3.5 ## Shots above this threshold trigger a "big power" effect const BIG_POWER_THRESHOLD := 0.7 ## Amount of life lost when landing in water const WATER_DAMAGE := 10.0 ## Angle of influence that shot curve has, in radians const CURVE_INFLUENCE := PI / 3 ## Just enough to make things interesting! const SHOT_OFFSET_X_FACTOR := 20.0 const SHOT_OFFSET_Z_FACTOR := 2.0 / 45.0 ## Impulse offset multiplier due to curve, in meters const CURVE_FACTOR := 0.002 ## Maximum absolute curve for the "nice shot" animation to play const NICE_THRESHOLD := 0.2 ## Force by which explosions knock the ball away const EXPLOSIVE_FORCE_FACTOR := 0.12 const CLUB_SELECT_ORDER: Array[Club.Type] = [ Club.Type.DRIVER, Club.Type.IRON, Club.Type.WEDGE, Club.Type.SPECIAL, Club.Type.PUTTER ] ## In Driving Range mode, the ball can be retrieved in the shot phase. @export var driving_range := false ## Initially-selected club @export var initial_club_type: Club.Type = Club.Type.DRIVER ## Initially-selected ball type @export var initial_ball: GameBall.Type = GameBall.Type.BASIC @export_category("Shot Parameters") @export var base_power := 2.5 @export var base_curve := 0.0 @export_category("Debug") ## When enabled, ignore curve meter and hit a perfect shot every time. @export var perfect_aim := false ## Keep projection visible @export var keep_projection := false var player: WorldPlayer var control_disabled := false var reset_enabled := false: set(value): if value != reset_enabled: if value: hud.show_reset_prompt() else: hud.hide_reset_prompt() reset_enabled = value var phase: Phase = Phase.FINISHED: set(value): if value != phase: _on_phase_change(value) phase = value var hud: ShotHUD var club_type: Club.Type: set = _set_club_type var club: Club: get: return player.get_club(club_type) if player else null var ball_type: GameBall.Type: set(value): if value != ball_type: if hud: hud.ball_selector.value = value ball_point.spawn_ball(value) ball_type = value var shot_power: float: set(value): hud.power_bar.value = value get: return hud.power_bar.value var shot_curve: float: set(value): hud.curve_bar.value = value get: return hud.curve_bar.value var game_ball: GameBall: get: return ball_point.ball var _free_camera: FreeCamera var _returning_free_camera := false var _restart_queued := false var _tracking_camera: OrbitalCamera @onready var direction: Node3D = %Direction @onready var elevation: Node3D = %Elevation @onready var pitch: Node3D = %Pitch @onready var zoom: Node3D = %Zoom @onready var camera: Camera3D = %Camera @onready var player_pivot: Node3D = %PlayerPivot @onready var player_offset: Node3D = %PlayerOffset # TODO: genericize for selectable characters @onready var character: CharacterController = $PlayerPivot/PlayerOffset/GfolfGirl @onready var shot_animation: AnimationPlayer = %ShotAnimation @onready var shot_sfx: ShotSFX = %ShotSFX @onready var death_animation: AnimationPlayer = %DeathAnimation @onready var arrow: Node3D = %Arrow @onready var arrow_pivot: Node3D = %ArrowPivot @onready var arrow_animation: AnimationPlayer = %ArrowAnimation @onready var shot_projection: ProjectileArc = %ShotProjection @onready var ball_point: BallPoint = %BallPoint @onready var drive_arrow: Node3D = %DriveArrow @onready var wedge_arrow: Node3D = %WedgeArrow @onready var iron_arrow: Node3D = %IronArrow @onready var putt_arrow: Node3D = %PuttArrow @onready var downswing_timer: Timer = %DownswingTimer @onready var ball_return_timer: Timer = %BallReturnTimer @onready var reset_prompt_timer: Timer = %ResetPromptTimer @onready var idle_prompt_timer: Timer = %IdlePromptTimer @onready var explosion_animation: AnimationPlayer = %ExplosionAnimation @onready var player_label: Label3D = %PlayerLabel @onready var camera_distance := zoom.position.z: set = _set_camera_distance @onready var world: World = get_tree().get_first_node_in_group(World.group) @onready var game: Game = get_tree().get_first_node_in_group(Game.group) @onready var _target_rotation := Vector2(pitch.rotation.x, direction.rotation.y) static var scene := preload("res://src/player/shot_setup/shot_setup.tscn") func _ready() -> void: if player: player.on_life_changed.connect(_on_life_changed) # Create & set up HUD ball_type = initial_ball club_type = initial_club_type hud = ShotHUD.create(player) world.ui.add_player_hud(hud) character.set_color(player.color) # Set up player 3D label player_label.text = player.name player_label.modulate = player.color player_label.outline_modulate = ColorTools.get_bg_color(player.color) #_on_phase_change(phase) func _on_tree_exiting() -> void: if is_instance_valid(_tracking_camera): _tracking_camera.queue_free() if is_instance_valid(_free_camera): _free_camera.queue_free() hud.queue_free() func _set_camera_distance(value: float) -> void: var tween := get_tree().create_tween() tween.tween_property(zoom, "position:z", value, ZOOM_LENGTH).set_trans(Tween.TRANS_SINE) tween.set_parallel() ( tween . tween_property(elevation, "position:y", value * ZOOM_ELEVATION_RATIO, ZOOM_LENGTH) . set_trans(Tween.TRANS_SINE) ) camera_distance = value func _unhandled_input(event: InputEvent) -> void: # Hide idle prompts if shown & reset timer hud.hide_idle_prompts() idle_prompt_timer.start() if event is InputEventMouseButton: Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) elif event is InputEventMouseMotion: if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: camera_motion((event as InputEventMouseMotion).relative) func camera_motion(motion: Vector2) -> void: if not control_disabled and phase == Phase.AIM: # Can only control camera while aiming _target_rotation.y = _target_rotation.y - deg_to_rad(motion.x * Game.settings.x_sensitivity) _target_rotation.x = clampf( ( _target_rotation.x - ( deg_to_rad(motion.y * Game.settings.y_sensitivity) * (-1 if Game.settings.invert_pitch else 1) ) ), PITCH_MIN, PITCH_MAX ) ## Return this instance to the AIM phase the next time we process while FINISHED. func queue_restart() -> void: _restart_queued = true func is_active() -> bool: return phase != Phase.FINISHED func is_shot_good() -> bool: return abs(shot_curve) <= NICE_THRESHOLD func finish_downswing() -> void: phase = Phase.SHOT func get_shot_impulse(meter_pct: float) -> Vector3: # Basic direction of the shot var shot_vec := -direction.global_basis.rotated(direction.global_basis.x, club.angle).z # Curve rotates shot direction around the Y-axis var curved_shot := shot_vec.rotated(Vector3.UP, shot_curve * CURVE_INFLUENCE * shot_power) # Various factors attenuate shot power return curved_shot * club.power * base_power * meter_pct func take_shot() -> void: # Impact screenshake if game: if shot_power > BIG_POWER_THRESHOLD: var shake_intensity: float = ( 10.0 * (shot_power - BIG_POWER_THRESHOLD) / (1.0 - BIG_POWER_THRESHOLD) ) game.viewport.screen_shake(shake_intensity, 1.0) print_debug("WHACK!\nPower: ", shot_power, "\nCurve: ", shot_curve) # Momentary impulse applied to the ball var impulse := get_shot_impulse(shot_power) print_debug("Shot impulse: ", impulse, "; ", impulse.length(), " N*s") # Curve the curve var curve := shot_curve * CURVE_FACTOR # Position where the ball is hit (imparts spin) var offset := ( direction.global_basis.x.normalized() * -curve * game_ball.radius * SHOT_OFFSET_X_FACTOR ) print_debug("Curve offset magnitude: ", offset.length()) offset += ( direction.global_basis.z.normalized() * game_ball.radius * game_ball.radius * SHOT_OFFSET_Z_FACTOR ) print_debug("Shot offset: ", offset, "; ", offset.length(), " m") if game_ball: game_ball.magnetic = club_type == Club.Type.IRON game_ball.freeze = false game_ball.apply_impulse(impulse, offset) reset_prompt_timer.start() # Play SFX shot_sfx.play_shot_sfx(club_type, is_shot_good(), shot_power) # Use a ball if a limited type is selected if player.get_balls(ball_type) > 0: player.mutate_balls(ball_type, -1) ## Make the shot projection widget visible, with animated transition func _show_shot_projection() -> void: if not game_ball: return shot_projection.set_ball(game_ball) shot_projection.putt_projection = club_type == Club.Type.PUTTER shot_projection.initial_speed = 1 shot_projection.basis = Basis.from_euler(Vector3(club.angle, 0, 0)) var shot_speed := get_shot_impulse(1.0).length() / game_ball.mass var tween := get_tree().create_tween() tween.tween_property(shot_projection, "initial_speed", shot_speed, CAMERA_SNAP_TIME).set_trans( Tween.TRANS_QUAD ) shot_projection.show() func insert_free_cam() -> void: arrow_animation.play("hide") _show_shot_projection() hud.hide_hud() hud.show_free_cam_prompts() _free_camera = FreeCamera.create(camera) add_sibling(_free_camera) # Un-gimbal-lock ourselves: quickly tween Z rotation to 0 var tween := get_tree().create_tween() tween.tween_property(_free_camera, "rotation:z", 0, FREE_CAM_GIMBAL_TWEEN_TIME) control_disabled = true camera.current = false func return_free_cam() -> void: # TODO alter shot aim based on free camera selection arrow_animation.play("show") if not keep_projection: shot_projection.hide() hud.show_hud() hud.hide_free_cam_prompts() _free_camera.queue_free() _free_camera = null control_disabled = false _returning_free_camera = false camera.current = true func return_ball() -> void: if not game_ball: return game_ball.freeze = true var tween := get_tree().create_tween() ( tween . tween_property( game_ball, "global_transform", ball_point.global_transform, BALL_RETURN_TIME, ) . set_trans(Tween.TRANS_SINE) ) tween.tween_callback(end_shot_track) func travel_to_ball() -> void: if not game_ball: return game_ball.freeze = true global_position = game_ball.global_position # Re-orient to the ball's last contact normal if there is one. # Normally this will just be Vector3.UP or something close to it. direction.rotation.y = 0 _target_rotation.y = 0 global_basis = game_ball.get_reoriented_basis() # Adjust position downward to account for ball radius global_position -= global_basis.y.normalized() * game_ball.radius ball_point.snap() func start_shot_track() -> void: if not game_ball: return if phase == Phase.SHOT: _tracking_camera = OrbitalCamera.create(game_ball) _tracking_camera.rotation.y = randf_range(0.0, TAU) add_sibling(_tracking_camera) _tracking_camera.global_transform = ball_point.global_transform func end_shot_track() -> void: camera.make_current() if is_instance_valid(_tracking_camera): _tracking_camera.queue_free() if phase in [Phase.SHOT, Phase.SHOT_RESET]: phase = Phase.FINISHED func start_death() -> void: print_debug("starting death sequence") world.ui.play_death_sequence() death_animation.play("death") get_tree().paused = true func finish_death() -> void: print_debug("finishing death sequence") get_tree().paused = false player.die() func _set_club_type(new_club_type: Club.Type) -> void: if new_club_type == club_type: return var new_club := player.get_club(new_club_type) if not new_club: # `new_club` will be null if player has no club in the given slot # TODO play bonk return # Hide all arrows # TODO animate? drive_arrow.hide() wedge_arrow.hide() iron_arrow.hide() putt_arrow.hide() if hud: hud.club_selector.value = new_club_type # TODO club change animation character.hold_right(new_club.get_model()) match new_club_type: Club.Type.DRIVER: drive_arrow.show() Club.Type.PUTTER: putt_arrow.show() Club.Type.WEDGE: wedge_arrow.show() Club.Type.IRON: iron_arrow.show() Club.Type.SPECIAL: # TODO figure this out pass _: print_debug("Not sure how to equip club type: ", new_club) club_type = new_club_type ## Called immediately before `phase` is mutated. func _on_phase_change(new_phase: Phase) -> void: reset_prompt_timer.stop() reset_enabled = false idle_prompt_timer.start() hud.hide_idle_prompts() world.fast_forward = false match new_phase: Phase.AIM: hud.show_hud() if not arrow.visible: arrow_animation.play("show") camera.make_current() hud.power_bar.hide() hud.curve_bar.hide() character.reset() Phase.POWER_ADJUST: hud.curve_bar.hide() hud.power_bar.show() hud.reset_power_bar() # Reset if needed Phase.CURVE_ADJUST: hud.curve_bar.show() hud.start_curve_bar() Phase.DOWNSWING: hud.gauge_flourish() character.downswing() downswing_timer.start() Phase.SHOT: hud.power_bar.hide() hud.curve_bar.hide() if perfect_aim: shot_curve = 0.0 if is_shot_good(): hud.play_nice_animation() if not driving_range: shot_animation.play("shoot") arrow_animation.play("hide") hud.hide_hud() take_shot() Phase.FINISHED: hud.power_bar.hide() hud.curve_bar.hide() hud.hide_hud() finished.emit(self) func _on_game_ball_changed(ball: GameBall) -> void: var z_offset := 0.0 if ball: ball.entered_water.connect(_on_ball_entered_water) ball.sleeping_state_changed.connect(_on_ball_sleeping_state_changed) z_offset = ball.radius player_offset.position.z = z_offset func _get_relative_club(delta: int, from: int = -1) -> Club.Type: from = from if from != -1 else club_type var old_idx := CLUB_SELECT_ORDER.find(from) var new_idx := wrapi(old_idx + delta, 0, len(CLUB_SELECT_ORDER)) var new_club := CLUB_SELECT_ORDER[new_idx] # UX behavior: if there is not club in the next slot, get the NEXT next slot, etc etc # NOTE: this will enter a loop if the player has no clubs! if not player.get_club(new_club): return _get_relative_club(delta, new_club) return new_club func _process(_delta: float) -> void: # REMOVEME if Input.is_action_just_pressed("ui_menu"): print("Debugging...") if Input.is_action_just_pressed("debug_2"): player.life -= 90 ## Visual updates # Rotation direction.rotation.y = lerp_angle( direction.rotation.y, _target_rotation.y, Game.settings.x_acceleration / 60.0 ) pitch.rotation.x = lerp_angle( pitch.rotation.x, _target_rotation.x, Game.settings.y_acceleration / 60.0 ) # Arrow lags behind camera control arrow_pivot.rotation.y = lerp_angle( arrow_pivot.rotation.y, _target_rotation.y, ARROW_ACCELERATION / 60.0 ) # Player lags further behind player_pivot.rotation.y = lerp_angle( player_pivot.rotation.y, _target_rotation.y, PLAYER_ACCELERATION / 60.0 ) ## Input Handling if control_disabled: if Input.is_action_just_pressed("camera_cancel", true): if is_instance_valid(_free_camera) and not _returning_free_camera: _returning_free_camera = true var tween := get_tree().create_tween() ( tween . tween_property( _free_camera, "global_transform", camera.global_transform, FREE_CAM_RETURN_TIME ) . set_trans(Tween.TRANS_SINE) ) tween.tween_callback(return_free_cam) return match phase: Phase.AIM: # Camera zoom if Input.is_action_just_pressed("shot_zoom_in", true): camera_distance = max(camera_distance - 1.0, ZOOM_MIN) if Input.is_action_just_pressed("shot_zoom_out", true): camera_distance = min(camera_distance + 1.0, ZOOM_MAX) # Club select if Input.is_action_just_pressed("club_next", true): club_type = _get_relative_club(1) if Input.is_action_just_pressed("club_previous", true): club_type = _get_relative_club(-1) if Input.is_action_just_pressed("select_driver", true): club_type = Club.Type.DRIVER if Input.is_action_just_pressed("select_iron", true): club_type = Club.Type.IRON if Input.is_action_just_pressed("select_wedge", true): club_type = Club.Type.WEDGE if Input.is_action_just_pressed("select_special", true): club_type = Club.Type.SPECIAL if Input.is_action_just_pressed("select_putter", true): club_type = Club.Type.PUTTER # Ball select if Input.is_action_just_pressed("ball_next", true): ball_type = player.next_ball(ball_type) if Input.is_action_just_pressed("ball_previous", true): ball_type = player.prev_ball(ball_type) # Switch to free cam if ( Input.is_action_just_pressed("camera_back", true) or Input.is_action_just_pressed("camera_forward", true) or Input.is_action_just_pressed("camera_left", true) or Input.is_action_just_pressed("camera_right", true) ): insert_free_cam() # Advance to next phase if Input.is_action_just_pressed("shot_accept", true): if player.get_balls(ball_type) != 0: # Check that player has enough of the selected ball (<0 means unlimited) phase = Phase.POWER_ADJUST # TODO play UI bonk if player doesn't have balls (lmao) Phase.POWER_ADJUST: if Input.is_action_just_pressed("shot_accept", true): # TODO set power gauge parameters if needed character.start_upswing() hud.start_power_bar() if Input.is_action_just_pressed("shot_cancel", true): hud.reset_power_bar() phase = Phase.AIM if Input.is_action_just_released("shot_accept", true) and shot_power > 0: hud.stop_power_bar() phase = Phase.CURVE_ADJUST Phase.CURVE_ADJUST: if Input.is_action_just_pressed("shot_cancel", true): hud.reset_curve_bar() phase = Phase.POWER_ADJUST if Input.is_action_just_pressed("shot_accept", true): hud.stop_curve_bar() phase = Phase.DOWNSWING Phase.SHOT: # Fast-forward when input is held: world.fast_forward = Input.is_action_pressed("fast_forward", true) if reset_enabled and Input.is_action_just_pressed("shot_reset", true): phase = Phase.SHOT_RESET reset_enabled = false return_ball() if Input.is_action_just_pressed("activate_ball", true): game_ball.activate_ability() if driving_range and Input.is_action_just_pressed("shot_accept", true): phase = Phase.AIM return_ball() Phase.FINISHED: if _restart_queued: _restart_queued = false phase = Phase.AIM Phase.DEAD: start_death() phase = Phase.FINISHED func _on_ball_sleeping_state_changed() -> void: if game_ball.sleeping and phase == Phase.SHOT: travel_to_ball() end_shot_track() func _on_ball_entered_water() -> void: # Should only be possible during SHOT phase, but let's check just to be sure... if phase == Phase.SHOT: phase = Phase.SHOT_RESET game_ball.freeze = true hud.play_wasted_animation() player.life -= WATER_DAMAGE ball_return_timer.start(WASTED_BALL_RETURN_DELAY) func _on_ball_return_timer_timeout() -> void: return_ball() func _on_hitbox_ball_collision(ball: GameBall) -> void: # TODO play animation var damage := ball.get_damage() player.life -= damage game.viewport.hit_lag_small() game.viewport.screen_shake(damage, 0.4) explosion_animation.play("explode") # TODO kinematic collisions # Knock other ball away var explosion_impulse := ( ( (ball.global_position - global_position).normalized() * damage + global_basis.y.normalized() ) * EXPLOSIVE_FORCE_FACTOR ) ball.apply_central_impulse(explosion_impulse) func _on_life_changed(new_value: float) -> void: if new_value <= 0: # No action is taken till the next process phase # This is because we need to be in the main thread to run the death sequence! phase = Phase.DEAD func _on_reset_prompt_timer_timeout() -> void: reset_enabled = true ## Create a new instance for the given player. static func create(_player: WorldPlayer) -> ShotSetup: var instance: ShotSetup = ShotSetup.scene.instantiate() instance.player = _player return instance func _on_idle_prompt_timer_timeout() -> void: if _free_camera: # Don't show idle prompts while in free-cam mode return match phase: Phase.AIM: hud.show_aim_prompt() Phase.POWER_ADJUST: hud.show_power_prompt() Phase.CURVE_ADJUST: hud.show_curve_prompt() Phase.SHOT: hud.show_shot_prompt()