From b4d390b58d521922ea9283d5c58d14a651f48577 Mon Sep 17 00:00:00 2001 From: Jesse Hernandez Date: Thu, 12 Mar 2026 14:47:40 -0700 Subject: [PATCH 1/3] docs: shot recorder enablement. --- game/CourseGoalZone.cs | 16 +- game/GolfBall.cs | 1006 +++++++-------- game/ShotRecordingService.cs | 132 ++ game/ShotRecordingService.cs.uid | 1 + game/ShotTracker.cs | 476 +++---- game/SurfaceZone.cs | 276 ++-- game/hole/HoleSceneControllerBase.cs | 4 + project.godot | 1 + ui/CourseHud.cs | 1112 ++++++++--------- ui/MainMenu.cs | 240 ++-- ui/SettingsPanel.cs | 75 ++ ui/settings_panel.tscn | 82 ++ utils/Settings/AppSettings.cs | 10 +- .../Settings/AppSettingsPersistenceService.cs | 4 + 14 files changed, 1871 insertions(+), 1564 deletions(-) create mode 100644 game/ShotRecordingService.cs create mode 100644 game/ShotRecordingService.cs.uid diff --git a/game/CourseGoalZone.cs b/game/CourseGoalZone.cs index b68b928..e71b2bc 100644 --- a/game/CourseGoalZone.cs +++ b/game/CourseGoalZone.cs @@ -5,13 +5,13 @@ /// public partial class CourseGoalZone : Area3D { - public override void _Ready() - { - AddToGroup("course_goal_zone"); - } + public override void _Ready() + { + AddToGroup("course_goal_zone"); + } - public bool IsBallOnZone(GolfBall ball) - { - return ball != null && OverlapsBody(ball); - } + public bool IsBallOnZone(GolfBall ball) + { + return ball != null && OverlapsBody(ball); + } } diff --git a/game/GolfBall.cs b/game/GolfBall.cs index 5bb6478..8d586b0 100644 --- a/game/GolfBall.cs +++ b/game/GolfBall.cs @@ -10,509 +10,509 @@ public partial class GolfBall : CharacterBody3D // Player { - // Keep a small collision recovery distance so the ball can settle into terrain lows. - private const float COLLISION_SAFE_MARGIN = 0.0005f; - private const float BELOW_GROUND_RECOVERY_Y = -0.5f; - private const float FALLTHROUGH_FAILSAFE_Y = -5.0f; - private const float GROUND_SNAP_OFFSET = 0.001f; - private const float GROUND_RAYCAST_UP = 2.0f; - private const float GROUND_RAYCAST_DOWN = 8.0f; - private const float GROUND_PROBE_DISTANCE = 0.08f; - private const float PHYSICS_SUBSTEP_DT = BallPhysics.SIMULATION_DT; - private const int MAX_SUBSTEPS_PER_FRAME = 12; - - // Signals - [Signal] - public delegate void BallAtRestEventHandler(); - [Signal] - public delegate void BallLandedEventHandler(); - - // Physics instances - private readonly BallPhysics _ballPhysics = new(); - private readonly Aerodynamics _aerodynamics = new(); - private readonly PhysicsParamsFactory _physicsParamsFactory = new(); - private readonly ShotSetup _shotSetup = new(); - - // State — ball center sits one radius above ground so it rests on the surface - public const float TEE_HEIGHT = BallPhysics.RADIUS; - public static readonly Vector3 START_POSITION = new Vector3(1.0f, TEE_HEIGHT, 0.0f); - public PhysicsEnums.BallState State { get; set; } = PhysicsEnums.BallState.Rest; - public Vector3 Omega { get; set; } = Vector3.Zero; // Angular velocity (rad/s) - public bool OnGround { get; set; } = false; - public Vector3 FloorNormal { get; set; } = Vector3.Up; - - // Settings reference for signal cleanup - private GameSettings _gameSettings; - private float _substepAccumulator = 0.0f; - - // Terrain3D data reference for height queries (cached on _Ready) - private GodotObject _terrainData; - - // Cached raycast exclude array (avoids per-frame allocation) - private Array _raycastExclude; - - public PhysicsEnums.SurfaceType SurfaceType { get; private set; } = PhysicsEnums.SurfaceType.Fairway; - public BallPhysicsProfile BallProfile { get; set; } = new(); - public Func ResolveLieSurface { get; set; } - public Func DescribeLieSurfaceResolution { get; set; } - - // Environment - private float _airDensity; - private float _airViscosity; - private float _dragScale = 1.0f; - private float _liftScale = 1.0f; - - // Shot tracking - public Vector3 ShotStartPos { get; set; } = Vector3.Zero; - public Vector3 ShotDirection { get; set; } = new Vector3(1.0f, 0.0f, 0.0f); // Normalized horizontal direction - public float AimYawOffsetDeg { get; set; } = 0.0f; // Camera/world rotation offset applied at launch - public float LaunchAngleDeg { get; private set; } = 0.0f; - public float LaunchSpinRpm { get; set; } = 0.0f; // Stored for bounce calculations - public float RolloutImpactSpinRpm { get; set; } = 0.0f; // Spin when first landing (for friction calculation) - - public override void _Ready() - { - CacheTerrainData(); - ConnectSettings(); - UpdateEnvironment(); - } - - private Array GetRaycastExclude() - { - _raycastExclude ??= new Array { GetRid() }; - return _raycastExclude; - } - - private void CacheTerrainData() - { - var terrain = GetTree().Root.FindChild("Terrain3D", true, false); - if (terrain == null) - return; - - var data = terrain.Get("data"); - if (data.Obj is GodotObject obj) - _terrainData = obj; - } - - private void ConnectSettings() - { - var globalSettings = GetNodeOrNull("/root/GlobalSettings"); - if (globalSettings?.GameSettings == null) - { - GD.PushError($"{nameof(GolfBall)}: GlobalSettings.GameSettings not found at /root/GlobalSettings. Physics environment updates will be disabled."); - return; - } - - _gameSettings = globalSettings.GameSettings; - _gameSettings.Temperature.SettingChanged += OnEnvironmentChanged; - _gameSettings.Altitude.SettingChanged += OnEnvironmentChanged; - _gameSettings.GameUnits.SettingChanged += OnEnvironmentChanged; - _gameSettings.DragScale.SettingChanged += OnDragScaleChanged; - _gameSettings.LiftScale.SettingChanged += OnLiftScaleChanged; - _dragScale = (float)_gameSettings.DragScale.Value; - _liftScale = (float)_gameSettings.LiftScale.Value; - } - - public override void _ExitTree() - { - if (_gameSettings != null) - { - _gameSettings.Temperature.SettingChanged -= OnEnvironmentChanged; - _gameSettings.Altitude.SettingChanged -= OnEnvironmentChanged; - _gameSettings.GameUnits.SettingChanged -= OnEnvironmentChanged; - _gameSettings.DragScale.SettingChanged -= OnDragScaleChanged; - _gameSettings.LiftScale.SettingChanged -= OnLiftScaleChanged; - } - } - - private void UpdateEnvironment() - { - if (_gameSettings == null) - return; - - var units = (PhysicsEnums.Units)(int)_gameSettings.GameUnits.Value; - _airDensity = _aerodynamics.GetAirDensity( - (float)_gameSettings.Altitude.Value, - (float)_gameSettings.Temperature.Value, - units - ); - _airViscosity = _aerodynamics.GetDynamicViscosity( - (float)_gameSettings.Temperature.Value, - units - ); - } - - private void OnEnvironmentChanged(Variant value) - { - UpdateEnvironment(); - } - - private void OnDragScaleChanged(Variant value) - { - if (_gameSettings != null) - _dragScale = (float)_gameSettings.DragScale.Value; - } - - private void OnLiftScaleChanged(Variant value) - { - if (_gameSettings != null) - _liftScale = (float)_gameSettings.LiftScale.Value; - } - - public void SetLieSurface(PhysicsEnums.SurfaceType surface) - { - if (SurfaceType == surface) - return; - - SurfaceType = surface; - PhysicsLogger.Info($"[Surface] Active={SurfaceType}"); - } - - private void UpdateLieSurfaceFromContact(Node collider, Vector3 worldPoint) - { - if (ResolveLieSurface == null) - return; - - SetLieSurface(ResolveLieSurface(collider, worldPoint)); - } - - private void RefreshLieSurfaceFromGroundProbe() - { - if (TryProbeGround(out _, out Node groundCollider, out Vector3 groundPoint)) - UpdateLieSurfaceFromContact(groundCollider, groundPoint); - } - - private void LogLandingSurfaceReaction(PhysicsParams parameters, Vector3 velocity, Vector3 omega, Vector3 normal) - { - float speed = velocity.Length(); - float impactSpinRpm = omega.Length() / ShotSetup.RAD_PER_RPM; - float angleToNormal = velocity.AngleTo(normal); - float impactAngleDeg = Mathf.RadToDeg(Mathf.Abs(angleToNormal - Mathf.Pi / 2.0f)); - float criticalAngleDeg = Mathf.RadToDeg(parameters.CriticalAngle); - float thetaBoostDeg = Mathf.RadToDeg(parameters.SpinbackThetaBoostMax); - string resolutionSource = DescribeLieSurfaceResolution?.Invoke(); - string sourceSegment = string.IsNullOrWhiteSpace(resolutionSource) - ? string.Empty - : $" {resolutionSource}"; - - PhysicsLogger.Info( - $"[LandingSurface] surface={parameters.SurfaceType} speed={speed:F2}m/s impact_spin={impactSpinRpm:F0}rpm " + - $"rollout_spin={RolloutImpactSpinRpm:F0}rpm angle={impactAngleDeg:F1}deg theta_c={criticalAngleDeg:F1}deg " + - $"spin_scale={parameters.SpinbackResponseScale:F2} theta_boost={thetaBoostDeg:F1}deg{sourceSegment}" - ); - } - - /// - /// Get downrange distance in meters (along initial shot direction) - /// - public float GetDownrangeMeters() - { - Vector3 delta = Position - ShotStartPos; - return delta.Dot(ShotDirection); - } - - public override void _PhysicsProcess(double delta) - { - if (State == PhysicsEnums.BallState.Rest) - { - _substepAccumulator = 0.0f; - return; - } - - _substepAccumulator += (float)delta; - int substeps = 0; - while (_substepAccumulator >= PHYSICS_SUBSTEP_DT && substeps < MAX_SUBSTEPS_PER_FRAME) - { - if (!StepPhysics(PHYSICS_SUBSTEP_DT)) - { - _substepAccumulator = 0.0f; - return; - } - - _substepAccumulator -= PHYSICS_SUBSTEP_DT; - substeps++; - - if (State == PhysicsEnums.BallState.Rest) - { - _substepAccumulator = 0.0f; - return; - } - } - - // Prevent runaway catch-up loops under stalls while preserving continuity. - if (substeps == MAX_SUBSTEPS_PER_FRAME && _substepAccumulator > PHYSICS_SUBSTEP_DT) - { - _substepAccumulator = PHYSICS_SUBSTEP_DT; - } - } - - private bool StepPhysics(float dt) - { - bool wasOnGround = OnGround; - Vector3 prevVelocity = Velocity; - - var parameters = CreatePhysicsParams(); - Vector3 velocity = Velocity; - Vector3 omega = Omega; - _ballPhysics.IntegrateStep(ref velocity, ref omega, wasOnGround, parameters, dt); - Velocity = velocity; - Omega = omega; - - if (CheckOutOfBounds()) - return false; - - var collision = MoveAndCollide( - Velocity * dt, - testOnly: false, - safeMargin: COLLISION_SAFE_MARGIN - ); - HandleCollision(collision, wasOnGround, prevVelocity); - - if (Velocity.Length() < 0.1f && State != PhysicsEnums.BallState.Rest) - { - EnterRestState(); - } - - return true; - } - - private PhysicsParams CreatePhysicsParams() - { - return _physicsParamsFactory.Create( - _airDensity, - _airViscosity, - _dragScale, - _liftScale, - SurfaceType, - FloorNormal, - rolloutImpactSpin: RolloutImpactSpinRpm, - ballProfile: BallProfile, - initialLaunchAngleDeg: LaunchAngleDeg - ).ToPhysicsParams(); - } - - private bool CheckOutOfBounds() - { - if (Mathf.Abs(Position.X) > 1000.0f || Mathf.Abs(Position.Z) > 1000.0f) - { - PhysicsLogger.Info($"WARNING: Ball out of bounds at: {Position}"); - EnterRestState(); - return true; - } - - if (GlobalPosition.Y < BELOW_GROUND_RECOVERY_Y) - { - if (TryRecoverToGround()) - return false; - - if (GlobalPosition.Y > FALLTHROUGH_FAILSAFE_Y) - return false; - - // This of course depends on const values preset. In some cases, these should be higher. - // Example ball falling in a course where canyon is very high elevation? - PhysicsLogger.Info($"WARNING: Ball fell through ground at: {GlobalPosition}"); - EnterRestState(); - return true; - } - - return false; - } - - private bool TryRecoverToGround() - { - var world = GetWorld3D(); - if (world == null) - return false; - - Vector3 rayStart = GlobalPosition + Vector3.Up * GROUND_RAYCAST_UP; - Vector3 rayEnd = GlobalPosition + Vector3.Down * GROUND_RAYCAST_DOWN; - - var query = PhysicsRayQueryParameters3D.Create(rayStart, rayEnd); - query.CollideWithAreas = false; - query.CollideWithBodies = true; - query.Exclude = GetRaycastExclude(); - - var hit = world.DirectSpaceState.IntersectRay(query); - if (hit.Count == 0) - return false; - - Vector3 hitPosition = (Vector3)hit["position"]; - Vector3 hitNormal = ((Vector3)hit["normal"]).Normalized(); - Node hitCollider = hit.ContainsKey("collider") && hit["collider"].Obj is Node collider - ? collider - : null; - if (hitNormal.LengthSquared() < 0.000001f) - hitNormal = Vector3.Up; - - GlobalPosition = hitPosition + hitNormal * (BallPhysics.RADIUS + GROUND_SNAP_OFFSET); - FloorNormal = hitNormal; - Velocity = RemoveVelocityAlongNormal(Velocity, hitNormal, removeBothDirections: false); - OnGround = true; - UpdateLieSurfaceFromContact(hitCollider, hitPosition); - - if (State == PhysicsEnums.BallState.Flight) - { - State = PhysicsEnums.BallState.Rollout; - EmitSignal(SignalName.BallLanded); - } - - PhysicsLogger.Verbose($"Recovered ball-to-ground at {GlobalPosition} (normal: {hitNormal})"); - return true; - } - - private bool TryProbeGround(out Vector3 groundNormal, out Node groundCollider, out Vector3 groundPoint) - { - groundNormal = Vector3.Up; - groundCollider = null; - groundPoint = GlobalPosition; - - var world = GetWorld3D(); - if (world == null) - return false; - - Vector3 rayStart = GlobalPosition + Vector3.Up * 0.05f; - Vector3 rayEnd = GlobalPosition + Vector3.Down * (BallPhysics.RADIUS + GROUND_PROBE_DISTANCE); - - var query = PhysicsRayQueryParameters3D.Create(rayStart, rayEnd); - query.CollideWithAreas = false; - query.CollideWithBodies = true; - query.Exclude = GetRaycastExclude(); - - var hit = world.DirectSpaceState.IntersectRay(query); - if (hit.Count == 0) - return false; - - groundPoint = (Vector3)hit["position"]; - groundNormal = ((Vector3)hit["normal"]).Normalized(); - groundCollider = hit.ContainsKey("collider") && hit["collider"].Obj is Node collider - ? collider - : null; - if (groundNormal.LengthSquared() < 0.000001f) - groundNormal = Vector3.Up; - - return true; - } - - private void HandleCollision(KinematicCollision3D collision, bool wasOnGround, Vector3 prevVelocity) - { - if (collision != null) - { - Vector3 normal = collision.GetNormal(); - Node hitCollider = collision.GetCollider() as Node; - Vector3 hitPosition = collision.GetPosition(); - - if (IsGroundNormal(normal)) - { - FloorNormal = normal; - UpdateLieSurfaceFromContact(hitCollider, hitPosition); - float prevNormalVelocity = prevVelocity.Dot(normal); - bool landedFromFlight = State == PhysicsEnums.BallState.Flight; - bool isLanding = landedFromFlight || prevNormalVelocity < -0.5f; - - if (isLanding) - { - if (landedFromFlight) - { - PrintImpactDebug(); - // Capture impact spin for friction calculation during rollout - // This preserves the "bite" effect even as spin decays - RolloutImpactSpinRpm = Omega.Length() / ShotSetup.RAD_PER_RPM; - } - - var parameters = CreatePhysicsParams(); - if (landedFromFlight) - LogLandingSurfaceReaction(parameters, Velocity, Omega, normal); - var bounceResult = _ballPhysics.CalculateBounce(Velocity, Omega, normal, State, parameters); - Velocity = bounceResult.NewVelocity; - Omega = bounceResult.NewOmega; - State = bounceResult.NewState; - if (landedFromFlight && State == PhysicsEnums.BallState.Rollout) - EmitSignal(SignalName.BallLanded); - - PhysicsLogger.Verbose($" Velocity after bounce: {Velocity} ({Velocity.Length():F2} m/s)"); - - // If the bounce resulted in very low vertical velocity (damped bounce), - // keep the ball on the ground instead of letting it bounce again - float normalVelocity = Velocity.Dot(normal); - if (Mathf.Abs(normalVelocity) < 0.5f && State == PhysicsEnums.BallState.Rollout) - { - OnGround = true; - Velocity = RemoveVelocityAlongNormal(Velocity, normal, removeBothDirections: true); - PhysicsLogger.Verbose($" -> Ball grounded, continuing roll at {Velocity.Length():F2} m/s"); - } - else - { - OnGround = false; - } - } - else - { - OnGround = true; - Velocity = RemoveVelocityAlongNormal(Velocity, normal, removeBothDirections: false); - } - } - else - { - // Wall collision - damped reflection - OnGround = false; - FloorNormal = Vector3.Up; - Velocity = Velocity.Bounce(normal) * 0.30f; - } - } - else - { - // No collision - only stay grounded if terrain is still directly beneath the ball. - if (State != PhysicsEnums.BallState.Flight && - wasOnGround && - TryProbeGround(out Vector3 groundNormal, out Node groundCollider, out Vector3 groundPoint)) - { - OnGround = true; - FloorNormal = groundNormal; - UpdateLieSurfaceFromContact(groundCollider, groundPoint); - } - else - { - OnGround = false; - FloorNormal = Vector3.Up; - } - } - } - - private bool IsGroundNormal(Vector3 normal) - { - return normal.Y > 0.7f; - } - - private static Vector3 RemoveVelocityAlongNormal(Vector3 velocity, Vector3 normal, bool removeBothDirections) - { - Vector3 floorNormal = normal.LengthSquared() > 0.000001f ? normal.Normalized() : Vector3.Up; - float normalComponent = velocity.Dot(floorNormal); - - if (!removeBothDirections && normalComponent >= 0.0f) - return velocity; - - return velocity - floorNormal * normalComponent; - } - - private void PrintImpactDebug() - { - PhysicsLogger.Info($"FIRST IMPACT at pos: {Position}, downrange: {GetDownrangeMeters() * ShotSetup.YARDS_PER_METER:F2} yds"); - PhysicsLogger.Info($" Velocity at impact: {Velocity} ({Velocity.Length():F2} m/s)"); - PhysicsLogger.Info($" Spin at impact: {Omega} ({Omega.Length() / ShotSetup.RAD_PER_RPM:F0} rpm)"); - PhysicsLogger.Info($" Normal: {FloorNormal}"); - } - - private void EnterRestState() - { - State = PhysicsEnums.BallState.Rest; - Velocity = Vector3.Zero; - Omega = Vector3.Zero; - _substepAccumulator = 0.0f; - EmitSignal(SignalName.BallAtRest); - } - - /// - /// Query terrain height at the ball's current X,Z and place it + // Keep a small collision recovery distance so the ball can settle into terrain lows. + private const float COLLISION_SAFE_MARGIN = 0.0005f; + private const float BELOW_GROUND_RECOVERY_Y = -0.5f; + private const float FALLTHROUGH_FAILSAFE_Y = -5.0f; + private const float GROUND_SNAP_OFFSET = 0.001f; + private const float GROUND_RAYCAST_UP = 2.0f; + private const float GROUND_RAYCAST_DOWN = 8.0f; + private const float GROUND_PROBE_DISTANCE = 0.08f; + private const float PHYSICS_SUBSTEP_DT = BallPhysics.SIMULATION_DT; + private const int MAX_SUBSTEPS_PER_FRAME = 12; + + // Signals + [Signal] + public delegate void BallAtRestEventHandler(); + [Signal] + public delegate void BallLandedEventHandler(); + + // Physics instances + private readonly BallPhysics _ballPhysics = new(); + private readonly Aerodynamics _aerodynamics = new(); + private readonly PhysicsParamsFactory _physicsParamsFactory = new(); + private readonly ShotSetup _shotSetup = new(); + + // State — ball center sits one radius above ground so it rests on the surface + public const float TEE_HEIGHT = BallPhysics.RADIUS; + public static readonly Vector3 START_POSITION = new Vector3(1.0f, TEE_HEIGHT, 0.0f); + public PhysicsEnums.BallState State { get; set; } = PhysicsEnums.BallState.Rest; + public Vector3 Omega { get; set; } = Vector3.Zero; // Angular velocity (rad/s) + public bool OnGround { get; set; } = false; + public Vector3 FloorNormal { get; set; } = Vector3.Up; + + // Settings reference for signal cleanup + private GameSettings _gameSettings; + private float _substepAccumulator = 0.0f; + + // Terrain3D data reference for height queries (cached on _Ready) + private GodotObject _terrainData; + + // Cached raycast exclude array (avoids per-frame allocation) + private Array _raycastExclude; + + public PhysicsEnums.SurfaceType SurfaceType { get; private set; } = PhysicsEnums.SurfaceType.Fairway; + public BallPhysicsProfile BallProfile { get; set; } = new(); + public Func ResolveLieSurface { get; set; } + public Func DescribeLieSurfaceResolution { get; set; } + + // Environment + private float _airDensity; + private float _airViscosity; + private float _dragScale = 1.0f; + private float _liftScale = 1.0f; + + // Shot tracking + public Vector3 ShotStartPos { get; set; } = Vector3.Zero; + public Vector3 ShotDirection { get; set; } = new Vector3(1.0f, 0.0f, 0.0f); // Normalized horizontal direction + public float AimYawOffsetDeg { get; set; } = 0.0f; // Camera/world rotation offset applied at launch + public float LaunchAngleDeg { get; private set; } = 0.0f; + public float LaunchSpinRpm { get; set; } = 0.0f; // Stored for bounce calculations + public float RolloutImpactSpinRpm { get; set; } = 0.0f; // Spin when first landing (for friction calculation) + + public override void _Ready() + { + CacheTerrainData(); + ConnectSettings(); + UpdateEnvironment(); + } + + private Array GetRaycastExclude() + { + _raycastExclude ??= new Array { GetRid() }; + return _raycastExclude; + } + + private void CacheTerrainData() + { + var terrain = GetTree().Root.FindChild("Terrain3D", true, false); + if (terrain == null) + return; + + var data = terrain.Get("data"); + if (data.Obj is GodotObject obj) + _terrainData = obj; + } + + private void ConnectSettings() + { + var globalSettings = GetNodeOrNull("/root/GlobalSettings"); + if (globalSettings?.GameSettings == null) + { + GD.PushError($"{nameof(GolfBall)}: GlobalSettings.GameSettings not found at /root/GlobalSettings. Physics environment updates will be disabled."); + return; + } + + _gameSettings = globalSettings.GameSettings; + _gameSettings.Temperature.SettingChanged += OnEnvironmentChanged; + _gameSettings.Altitude.SettingChanged += OnEnvironmentChanged; + _gameSettings.GameUnits.SettingChanged += OnEnvironmentChanged; + _gameSettings.DragScale.SettingChanged += OnDragScaleChanged; + _gameSettings.LiftScale.SettingChanged += OnLiftScaleChanged; + _dragScale = (float)_gameSettings.DragScale.Value; + _liftScale = (float)_gameSettings.LiftScale.Value; + } + + public override void _ExitTree() + { + if (_gameSettings != null) + { + _gameSettings.Temperature.SettingChanged -= OnEnvironmentChanged; + _gameSettings.Altitude.SettingChanged -= OnEnvironmentChanged; + _gameSettings.GameUnits.SettingChanged -= OnEnvironmentChanged; + _gameSettings.DragScale.SettingChanged -= OnDragScaleChanged; + _gameSettings.LiftScale.SettingChanged -= OnLiftScaleChanged; + } + } + + private void UpdateEnvironment() + { + if (_gameSettings == null) + return; + + var units = (PhysicsEnums.Units)(int)_gameSettings.GameUnits.Value; + _airDensity = _aerodynamics.GetAirDensity( + (float)_gameSettings.Altitude.Value, + (float)_gameSettings.Temperature.Value, + units + ); + _airViscosity = _aerodynamics.GetDynamicViscosity( + (float)_gameSettings.Temperature.Value, + units + ); + } + + private void OnEnvironmentChanged(Variant value) + { + UpdateEnvironment(); + } + + private void OnDragScaleChanged(Variant value) + { + if (_gameSettings != null) + _dragScale = (float)_gameSettings.DragScale.Value; + } + + private void OnLiftScaleChanged(Variant value) + { + if (_gameSettings != null) + _liftScale = (float)_gameSettings.LiftScale.Value; + } + + public void SetLieSurface(PhysicsEnums.SurfaceType surface) + { + if (SurfaceType == surface) + return; + + SurfaceType = surface; + PhysicsLogger.Info($"[Surface] Active={SurfaceType}"); + } + + private void UpdateLieSurfaceFromContact(Node collider, Vector3 worldPoint) + { + if (ResolveLieSurface == null) + return; + + SetLieSurface(ResolveLieSurface(collider, worldPoint)); + } + + private void RefreshLieSurfaceFromGroundProbe() + { + if (TryProbeGround(out _, out Node groundCollider, out Vector3 groundPoint)) + UpdateLieSurfaceFromContact(groundCollider, groundPoint); + } + + private void LogLandingSurfaceReaction(PhysicsParams parameters, Vector3 velocity, Vector3 omega, Vector3 normal) + { + float speed = velocity.Length(); + float impactSpinRpm = omega.Length() / ShotSetup.RAD_PER_RPM; + float angleToNormal = velocity.AngleTo(normal); + float impactAngleDeg = Mathf.RadToDeg(Mathf.Abs(angleToNormal - Mathf.Pi / 2.0f)); + float criticalAngleDeg = Mathf.RadToDeg(parameters.CriticalAngle); + float thetaBoostDeg = Mathf.RadToDeg(parameters.SpinbackThetaBoostMax); + string resolutionSource = DescribeLieSurfaceResolution?.Invoke(); + string sourceSegment = string.IsNullOrWhiteSpace(resolutionSource) + ? string.Empty + : $" {resolutionSource}"; + + PhysicsLogger.Info( + $"[LandingSurface] surface={parameters.SurfaceType} speed={speed:F2}m/s impact_spin={impactSpinRpm:F0}rpm " + + $"rollout_spin={RolloutImpactSpinRpm:F0}rpm angle={impactAngleDeg:F1}deg theta_c={criticalAngleDeg:F1}deg " + + $"spin_scale={parameters.SpinbackResponseScale:F2} theta_boost={thetaBoostDeg:F1}deg{sourceSegment}" + ); + } + + /// + /// Get downrange distance in meters (along initial shot direction) + /// + public float GetDownrangeMeters() + { + Vector3 delta = Position - ShotStartPos; + return delta.Dot(ShotDirection); + } + + public override void _PhysicsProcess(double delta) + { + if (State == PhysicsEnums.BallState.Rest) + { + _substepAccumulator = 0.0f; + return; + } + + _substepAccumulator += (float)delta; + int substeps = 0; + while (_substepAccumulator >= PHYSICS_SUBSTEP_DT && substeps < MAX_SUBSTEPS_PER_FRAME) + { + if (!StepPhysics(PHYSICS_SUBSTEP_DT)) + { + _substepAccumulator = 0.0f; + return; + } + + _substepAccumulator -= PHYSICS_SUBSTEP_DT; + substeps++; + + if (State == PhysicsEnums.BallState.Rest) + { + _substepAccumulator = 0.0f; + return; + } + } + + // Prevent runaway catch-up loops under stalls while preserving continuity. + if (substeps == MAX_SUBSTEPS_PER_FRAME && _substepAccumulator > PHYSICS_SUBSTEP_DT) + { + _substepAccumulator = PHYSICS_SUBSTEP_DT; + } + } + + private bool StepPhysics(float dt) + { + bool wasOnGround = OnGround; + Vector3 prevVelocity = Velocity; + + var parameters = CreatePhysicsParams(); + Vector3 velocity = Velocity; + Vector3 omega = Omega; + _ballPhysics.IntegrateStep(ref velocity, ref omega, wasOnGround, parameters, dt); + Velocity = velocity; + Omega = omega; + + if (CheckOutOfBounds()) + return false; + + var collision = MoveAndCollide( + Velocity * dt, + testOnly: false, + safeMargin: COLLISION_SAFE_MARGIN + ); + HandleCollision(collision, wasOnGround, prevVelocity); + + if (Velocity.Length() < 0.1f && State != PhysicsEnums.BallState.Rest) + { + EnterRestState(); + } + + return true; + } + + private PhysicsParams CreatePhysicsParams() + { + return _physicsParamsFactory.Create( + _airDensity, + _airViscosity, + _dragScale, + _liftScale, + SurfaceType, + FloorNormal, + rolloutImpactSpin: RolloutImpactSpinRpm, + ballProfile: BallProfile, + initialLaunchAngleDeg: LaunchAngleDeg + ).ToPhysicsParams(); + } + + private bool CheckOutOfBounds() + { + if (Mathf.Abs(Position.X) > 1000.0f || Mathf.Abs(Position.Z) > 1000.0f) + { + PhysicsLogger.Info($"WARNING: Ball out of bounds at: {Position}"); + EnterRestState(); + return true; + } + + if (GlobalPosition.Y < BELOW_GROUND_RECOVERY_Y) + { + if (TryRecoverToGround()) + return false; + + if (GlobalPosition.Y > FALLTHROUGH_FAILSAFE_Y) + return false; + + // This of course depends on const values preset. In some cases, these should be higher. + // Example ball falling in a course where canyon is very high elevation? + PhysicsLogger.Info($"WARNING: Ball fell through ground at: {GlobalPosition}"); + EnterRestState(); + return true; + } + + return false; + } + + private bool TryRecoverToGround() + { + var world = GetWorld3D(); + if (world == null) + return false; + + Vector3 rayStart = GlobalPosition + Vector3.Up * GROUND_RAYCAST_UP; + Vector3 rayEnd = GlobalPosition + Vector3.Down * GROUND_RAYCAST_DOWN; + + var query = PhysicsRayQueryParameters3D.Create(rayStart, rayEnd); + query.CollideWithAreas = false; + query.CollideWithBodies = true; + query.Exclude = GetRaycastExclude(); + + var hit = world.DirectSpaceState.IntersectRay(query); + if (hit.Count == 0) + return false; + + Vector3 hitPosition = (Vector3)hit["position"]; + Vector3 hitNormal = ((Vector3)hit["normal"]).Normalized(); + Node hitCollider = hit.ContainsKey("collider") && hit["collider"].Obj is Node collider + ? collider + : null; + if (hitNormal.LengthSquared() < 0.000001f) + hitNormal = Vector3.Up; + + GlobalPosition = hitPosition + hitNormal * (BallPhysics.RADIUS + GROUND_SNAP_OFFSET); + FloorNormal = hitNormal; + Velocity = RemoveVelocityAlongNormal(Velocity, hitNormal, removeBothDirections: false); + OnGround = true; + UpdateLieSurfaceFromContact(hitCollider, hitPosition); + + if (State == PhysicsEnums.BallState.Flight) + { + State = PhysicsEnums.BallState.Rollout; + EmitSignal(SignalName.BallLanded); + } + + PhysicsLogger.Verbose($"Recovered ball-to-ground at {GlobalPosition} (normal: {hitNormal})"); + return true; + } + + private bool TryProbeGround(out Vector3 groundNormal, out Node groundCollider, out Vector3 groundPoint) + { + groundNormal = Vector3.Up; + groundCollider = null; + groundPoint = GlobalPosition; + + var world = GetWorld3D(); + if (world == null) + return false; + + Vector3 rayStart = GlobalPosition + Vector3.Up * 0.05f; + Vector3 rayEnd = GlobalPosition + Vector3.Down * (BallPhysics.RADIUS + GROUND_PROBE_DISTANCE); + + var query = PhysicsRayQueryParameters3D.Create(rayStart, rayEnd); + query.CollideWithAreas = false; + query.CollideWithBodies = true; + query.Exclude = GetRaycastExclude(); + + var hit = world.DirectSpaceState.IntersectRay(query); + if (hit.Count == 0) + return false; + + groundPoint = (Vector3)hit["position"]; + groundNormal = ((Vector3)hit["normal"]).Normalized(); + groundCollider = hit.ContainsKey("collider") && hit["collider"].Obj is Node collider + ? collider + : null; + if (groundNormal.LengthSquared() < 0.000001f) + groundNormal = Vector3.Up; + + return true; + } + + private void HandleCollision(KinematicCollision3D collision, bool wasOnGround, Vector3 prevVelocity) + { + if (collision != null) + { + Vector3 normal = collision.GetNormal(); + Node hitCollider = collision.GetCollider() as Node; + Vector3 hitPosition = collision.GetPosition(); + + if (IsGroundNormal(normal)) + { + FloorNormal = normal; + UpdateLieSurfaceFromContact(hitCollider, hitPosition); + float prevNormalVelocity = prevVelocity.Dot(normal); + bool landedFromFlight = State == PhysicsEnums.BallState.Flight; + bool isLanding = landedFromFlight || prevNormalVelocity < -0.5f; + + if (isLanding) + { + if (landedFromFlight) + { + PrintImpactDebug(); + // Capture impact spin for friction calculation during rollout + // This preserves the "bite" effect even as spin decays + RolloutImpactSpinRpm = Omega.Length() / ShotSetup.RAD_PER_RPM; + } + + var parameters = CreatePhysicsParams(); + if (landedFromFlight) + LogLandingSurfaceReaction(parameters, Velocity, Omega, normal); + var bounceResult = _ballPhysics.CalculateBounce(Velocity, Omega, normal, State, parameters); + Velocity = bounceResult.NewVelocity; + Omega = bounceResult.NewOmega; + State = bounceResult.NewState; + if (landedFromFlight && State == PhysicsEnums.BallState.Rollout) + EmitSignal(SignalName.BallLanded); + + PhysicsLogger.Verbose($" Velocity after bounce: {Velocity} ({Velocity.Length():F2} m/s)"); + + // If the bounce resulted in very low vertical velocity (damped bounce), + // keep the ball on the ground instead of letting it bounce again + float normalVelocity = Velocity.Dot(normal); + if (Mathf.Abs(normalVelocity) < 0.5f && State == PhysicsEnums.BallState.Rollout) + { + OnGround = true; + Velocity = RemoveVelocityAlongNormal(Velocity, normal, removeBothDirections: true); + PhysicsLogger.Verbose($" -> Ball grounded, continuing roll at {Velocity.Length():F2} m/s"); + } + else + { + OnGround = false; + } + } + else + { + OnGround = true; + Velocity = RemoveVelocityAlongNormal(Velocity, normal, removeBothDirections: false); + } + } + else + { + // Wall collision - damped reflection + OnGround = false; + FloorNormal = Vector3.Up; + Velocity = Velocity.Bounce(normal) * 0.30f; + } + } + else + { + // No collision - only stay grounded if terrain is still directly beneath the ball. + if (State != PhysicsEnums.BallState.Flight && + wasOnGround && + TryProbeGround(out Vector3 groundNormal, out Node groundCollider, out Vector3 groundPoint)) + { + OnGround = true; + FloorNormal = groundNormal; + UpdateLieSurfaceFromContact(groundCollider, groundPoint); + } + else + { + OnGround = false; + FloorNormal = Vector3.Up; + } + } + } + + private bool IsGroundNormal(Vector3 normal) + { + return normal.Y > 0.7f; + } + + private static Vector3 RemoveVelocityAlongNormal(Vector3 velocity, Vector3 normal, bool removeBothDirections) + { + Vector3 floorNormal = normal.LengthSquared() > 0.000001f ? normal.Normalized() : Vector3.Up; + float normalComponent = velocity.Dot(floorNormal); + + if (!removeBothDirections && normalComponent >= 0.0f) + return velocity; + + return velocity - floorNormal * normalComponent; + } + + private void PrintImpactDebug() + { + PhysicsLogger.Info($"FIRST IMPACT at pos: {Position}, downrange: {GetDownrangeMeters() * ShotSetup.YARDS_PER_METER:F2} yds"); + PhysicsLogger.Info($" Velocity at impact: {Velocity} ({Velocity.Length():F2} m/s)"); + PhysicsLogger.Info($" Spin at impact: {Omega} ({Omega.Length() / ShotSetup.RAD_PER_RPM:F0} rpm)"); + PhysicsLogger.Info($" Normal: {FloorNormal}"); + } + + private void EnterRestState() + { + State = PhysicsEnums.BallState.Rest; + Velocity = Vector3.Zero; + Omega = Vector3.Zero; + _substepAccumulator = 0.0f; + EmitSignal(SignalName.BallAtRest); + } + + /// + /// Query terrain height at the ball's current X,Z and place it /// TEE_HEIGHT metres above the surface. /// Uses Terrain3D data API first (works immediately, no physics frame needed), /// falls back to physics raycast. diff --git a/game/ShotRecordingService.cs b/game/ShotRecordingService.cs new file mode 100644 index 0000000..af865c9 --- /dev/null +++ b/game/ShotRecordingService.cs @@ -0,0 +1,132 @@ +using System.IO; +using System.Text.Json; +using Godot; +using Godot.Collections; + +public partial class ShotRecordingService : Node +{ + private GlobalSettings _globalSettings; + private Setting _recordingEnabledSetting; + private Setting _recordingPathSetting; + + private string _currentSessionPath = string.Empty; + private int _shotCounter; + private bool _isRecording; + + public bool IsRecording => _isRecording; + public string CurrentSessionName => _isRecording ? Path.GetFileName(_currentSessionPath) : string.Empty; + public int ShotCount => _shotCounter; + + public override void _Ready() + { + _globalSettings = GetNodeOrNull("/root/GlobalSettings"); + + if (_globalSettings?.AppSettings == null) + return; + + _recordingEnabledSetting = _globalSettings.AppSettings.ShotRecordingEnabled; + _recordingPathSetting = _globalSettings.AppSettings.ShotRecordingPath; + + // Always start disabled regardless of persisted value + _recordingEnabledSetting.SetValue(false); + + _recordingEnabledSetting.SettingChanged += OnRecordingEnabledChanged; + } + + public override void _ExitTree() + { + if (_recordingEnabledSetting != null) + _recordingEnabledSetting.SettingChanged -= OnRecordingEnabledChanged; + } + + public void RecordShot(Dictionary ballData) + { + if (!_isRecording || ballData == null) + return; + + if (string.IsNullOrWhiteSpace(_currentSessionPath) || !Directory.Exists(_currentSessionPath)) + return; + + _shotCounter++; + + var shotJson = BuildShotJson(ballData); + string filePath = Path.Combine(_currentSessionPath, $"shot_{_shotCounter}.json"); + + try + { + File.WriteAllText(filePath, shotJson); + PhysicsLogger.Info($"ShotRecordingService: recorded shot {_shotCounter} to {filePath}"); + } + catch (IOException ex) + { + PhysicsLogger.Error($"ShotRecordingService: failed to write {filePath}: {ex.Message}"); + } + } + + private void OnRecordingEnabledChanged(Variant value) + { + bool enabled = (bool)value; + if (enabled) + StartNewSession(); + else + _isRecording = false; + } + + private void StartNewSession() + { + string basePath = _recordingPathSetting?.Value.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(basePath) || !Directory.Exists(basePath)) + { + PhysicsLogger.Error("ShotRecordingService: recording path is empty or does not exist."); + _isRecording = false; + return; + } + + int nextSession = 1; + while (Directory.Exists(Path.Combine(basePath, $"shot_session_{nextSession}"))) + nextSession++; + + _currentSessionPath = Path.Combine(basePath, $"shot_session_{nextSession}"); + Directory.CreateDirectory(_currentSessionPath); + _shotCounter = 0; + _isRecording = true; + + PhysicsLogger.Info($"ShotRecordingService: started session at {_currentSessionPath}"); + } + + private static string BuildShotJson(Dictionary ballData) + { + var shot = new System.Collections.Generic.Dictionary + { + ["BallData"] = ConvertGodotDict(ballData), + ["ShotDataOptions"] = new System.Collections.Generic.Dictionary + { + ["ContainsBallData"] = true + } + }; + + return JsonSerializer.Serialize(shot, new JsonSerializerOptions + { + WriteIndented = true + }); + } + + private static System.Collections.Generic.Dictionary ConvertGodotDict(Dictionary dict) + { + var result = new System.Collections.Generic.Dictionary(); + foreach (var key in dict.Keys) + { + Variant val = dict[key]; + result[key.ToString()] = val.VariantType switch + { + Variant.Type.Int => (long)val, + Variant.Type.Float => (double)val, + Variant.Type.Bool => (bool)val, + Variant.Type.String => val.ToString(), + _ => val.ToString() + }; + } + + return result; + } +} diff --git a/game/ShotRecordingService.cs.uid b/game/ShotRecordingService.cs.uid new file mode 100644 index 0000000..9f9d097 --- /dev/null +++ b/game/ShotRecordingService.cs.uid @@ -0,0 +1 @@ +uid://dcgumpg5yw6yn diff --git a/game/ShotTracker.cs b/game/ShotTracker.cs index 7a680f5..699122d 100644 --- a/game/ShotTracker.cs +++ b/game/ShotTracker.cs @@ -9,243 +9,243 @@ /// public partial class ShotTracker : Node3D { - // Signals - [Signal] - public delegate void GoodDataEventHandler(); - [Signal] - public delegate void BadDataEventHandler(); - [Signal] - public delegate void ShotCompleteEventHandler(Dictionary data); - [Signal] - public delegate void TestHitRequestedEventHandler(); - - // Tracer settings - [Export] public int MaxTracers { get; set; } = 4; - [Export] public float TrailResolution { get; set; } = 0.01f; - - // Shot statistics - public float Apex { get; set; } = 0.0f; - public float Carry { get; set; } = 0.0f; - public float SideDistance { get; set; } = 0.0f; - public Dictionary ShotData { get; set; } = new(); - - // Internal state - private bool _trackPoints = false; - private float _trailTimer = 0.0f; - private System.Collections.Generic.List _tracers = new(); - private Node3D _currentTracer = null; - - private GolfBall _ball; - private Setting _shotTracerCountSetting; - - public override void _Ready() - { - _ball = GetNode("Ball"); - _ball.BallAtRest += OnBallRest; - _shotTracerCountSetting = GetNode("/root/GlobalSettings").GameSettings.ShotTracerCount; - MaxTracers = (int)_shotTracerCountSetting.Value; - _shotTracerCountSetting.SettingChanged += OnTracerCountChanged; - } - - public override void _ExitTree() - { - if (_ball != null) - _ball.BallAtRest -= OnBallRest; - if (_shotTracerCountSetting != null) - _shotTracerCountSetting.SettingChanged -= OnTracerCountChanged; - } - - private void OnTracerCountChanged(Variant value) - { - MaxTracers = (int)value; - // Remove excess tracers if limit lowered - while (_tracers.Count > MaxTracers) - { - var oldest = _tracers[0]; - _tracers.RemoveAt(0); - oldest.QueueFree(); - } - } - - public override void _Process(double delta) - { - if (Input.IsActionJustPressed("hit")) - { - EmitSignal(SignalName.TestHitRequested); - } - } - - public override void _PhysicsProcess(double delta) - { - if (!_trackPoints) - return; - - Apex = Mathf.Max(Apex, _ball.Position.Y); - SideDistance = _ball.Position.Z; - - if (_ball.State == PhysicsEnums.BallState.Flight) - { - float newCarry = _ball.GetDownrangeMeters(); - if (newCarry > Carry) - Carry = newCarry; - } - - // Update tracer visual if enabled - if (_currentTracer != null) - { - _trailTimer += (float)delta; - if (_trailTimer >= TrailResolution) - { - ((BallTrail)_currentTracer).AddPoint(_ball.Position); - _trailTimer = 0.0f; - } - } - } - - private void StartShot() - { - _trackPoints = false; - Apex = 0.0f; - Carry = 0.0f; - SideDistance = 0.0f; - CreateNewTracer(); - - if (_currentTracer != null) - { - ((BallTrail)_currentTracer).AddPoint(_ball.Position); - } - - _trackPoints = true; - _trailTimer = 0.0f; - } - - private Node3D CreateNewTracer() - { - if (MaxTracers == 0) - { - _currentTracer = null; - return null; - } - - // Remove oldest if at limit - if (_tracers.Count >= MaxTracers) - { - var oldest = _tracers[0]; - _tracers.RemoveAt(0); - oldest.QueueFree(); - } - - // Create new tracer - var newTracer = new BallTrail(); - AddChild(newTracer); - - _tracers.Add(newTracer); - _currentTracer = newTracer; - return newTracer; - } - - /// - /// Reset the ball and clear all tracers - /// - public void ResetBall() - { - _trackPoints = false; - _trailTimer = 0.0f; - _ball.Reset(); - ClearAllTracers(); - Apex = 0.0f; - Carry = 0.0f; - SideDistance = 0.0f; - ResetShotData(); - } - - private void ClearAllTracers() - { - foreach (var tracer in _tracers) - { - tracer.QueueFree(); - } - _tracers.Clear(); - _currentTracer = null; - } - - private void ResetShotData() - { - var keys = ShotData.Keys.ToList(); - foreach (var key in keys) - { - ShotData[key] = 0.0f; - } - } - - /// - /// Get current total distance in meters - /// - public int GetDistance() - { - return (int)_ball.GetDownrangeMeters(); - } - - /// - /// Get current side distance in meters - /// - public int GetSideDistance() - { - return (int)_ball.Position.Z; - } - - /// - /// Get current ball state - /// - public PhysicsEnums.BallState GetBallState() - { - return _ball.State; - } - - /// - /// Validate incoming shot data - /// - public bool ValidateData(Dictionary data) - { - return ShotValidator.ValidateAndClamp(data); - } - - private void OnBallRest() - { - _trackPoints = false; - ShotData["TotalDistance"] = (int)_ball.GetDownrangeMeters(); - ShotData["CarryDistance"] = (int)Carry; - ShotData["Apex"] = (int)Apex; - ShotData["SideDistance"] = (int)SideDistance; - EmitSignal(SignalName.ShotComplete, ShotData); - } - - /// - /// Handle incoming shot from TCP (launch monitor) - /// - public void OnTcpClientHitBall(Dictionary data) - { - if (!ValidateData(data)) - { - EmitSignal(SignalName.BadData); - return; - } - - EmitSignal(SignalName.GoodData); - ShotData = data.Duplicate(); - StartShot(); - _ball.CallDeferred(GolfBall.MethodName.HitFromData, data); - } - - /// - /// Handle locally injected shot from UI - /// - public void OnGameplayUiHitShot(Variant data) - { - ShotData = ((Dictionary)data).Duplicate(); - PhysicsLogger.Info($"Local shot injection payload: {Json.Stringify(ShotData)}"); - StartShot(); - _ball.CallDeferred(GolfBall.MethodName.HitFromData, data); - } + // Signals + [Signal] + public delegate void GoodDataEventHandler(); + [Signal] + public delegate void BadDataEventHandler(); + [Signal] + public delegate void ShotCompleteEventHandler(Dictionary data); + [Signal] + public delegate void TestHitRequestedEventHandler(); + + // Tracer settings + [Export] public int MaxTracers { get; set; } = 4; + [Export] public float TrailResolution { get; set; } = 0.01f; + + // Shot statistics + public float Apex { get; set; } = 0.0f; + public float Carry { get; set; } = 0.0f; + public float SideDistance { get; set; } = 0.0f; + public Dictionary ShotData { get; set; } = new(); + + // Internal state + private bool _trackPoints = false; + private float _trailTimer = 0.0f; + private System.Collections.Generic.List _tracers = new(); + private Node3D _currentTracer = null; + + private GolfBall _ball; + private Setting _shotTracerCountSetting; + + public override void _Ready() + { + _ball = GetNode("Ball"); + _ball.BallAtRest += OnBallRest; + _shotTracerCountSetting = GetNode("/root/GlobalSettings").GameSettings.ShotTracerCount; + MaxTracers = (int)_shotTracerCountSetting.Value; + _shotTracerCountSetting.SettingChanged += OnTracerCountChanged; + } + + public override void _ExitTree() + { + if (_ball != null) + _ball.BallAtRest -= OnBallRest; + if (_shotTracerCountSetting != null) + _shotTracerCountSetting.SettingChanged -= OnTracerCountChanged; + } + + private void OnTracerCountChanged(Variant value) + { + MaxTracers = (int)value; + // Remove excess tracers if limit lowered + while (_tracers.Count > MaxTracers) + { + var oldest = _tracers[0]; + _tracers.RemoveAt(0); + oldest.QueueFree(); + } + } + + public override void _Process(double delta) + { + if (Input.IsActionJustPressed("hit")) + { + EmitSignal(SignalName.TestHitRequested); + } + } + + public override void _PhysicsProcess(double delta) + { + if (!_trackPoints) + return; + + Apex = Mathf.Max(Apex, _ball.Position.Y); + SideDistance = _ball.Position.Z; + + if (_ball.State == PhysicsEnums.BallState.Flight) + { + float newCarry = _ball.GetDownrangeMeters(); + if (newCarry > Carry) + Carry = newCarry; + } + + // Update tracer visual if enabled + if (_currentTracer != null) + { + _trailTimer += (float)delta; + if (_trailTimer >= TrailResolution) + { + ((BallTrail)_currentTracer).AddPoint(_ball.Position); + _trailTimer = 0.0f; + } + } + } + + private void StartShot() + { + _trackPoints = false; + Apex = 0.0f; + Carry = 0.0f; + SideDistance = 0.0f; + CreateNewTracer(); + + if (_currentTracer != null) + { + ((BallTrail)_currentTracer).AddPoint(_ball.Position); + } + + _trackPoints = true; + _trailTimer = 0.0f; + } + + private Node3D CreateNewTracer() + { + if (MaxTracers == 0) + { + _currentTracer = null; + return null; + } + + // Remove oldest if at limit + if (_tracers.Count >= MaxTracers) + { + var oldest = _tracers[0]; + _tracers.RemoveAt(0); + oldest.QueueFree(); + } + + // Create new tracer + var newTracer = new BallTrail(); + AddChild(newTracer); + + _tracers.Add(newTracer); + _currentTracer = newTracer; + return newTracer; + } + + /// + /// Reset the ball and clear all tracers + /// + public void ResetBall() + { + _trackPoints = false; + _trailTimer = 0.0f; + _ball.Reset(); + ClearAllTracers(); + Apex = 0.0f; + Carry = 0.0f; + SideDistance = 0.0f; + ResetShotData(); + } + + private void ClearAllTracers() + { + foreach (var tracer in _tracers) + { + tracer.QueueFree(); + } + _tracers.Clear(); + _currentTracer = null; + } + + private void ResetShotData() + { + var keys = ShotData.Keys.ToList(); + foreach (var key in keys) + { + ShotData[key] = 0.0f; + } + } + + /// + /// Get current total distance in meters + /// + public int GetDistance() + { + return (int)_ball.GetDownrangeMeters(); + } + + /// + /// Get current side distance in meters + /// + public int GetSideDistance() + { + return (int)_ball.Position.Z; + } + + /// + /// Get current ball state + /// + public PhysicsEnums.BallState GetBallState() + { + return _ball.State; + } + + /// + /// Validate incoming shot data + /// + public bool ValidateData(Dictionary data) + { + return ShotValidator.ValidateAndClamp(data); + } + + private void OnBallRest() + { + _trackPoints = false; + ShotData["TotalDistance"] = (int)_ball.GetDownrangeMeters(); + ShotData["CarryDistance"] = (int)Carry; + ShotData["Apex"] = (int)Apex; + ShotData["SideDistance"] = (int)SideDistance; + EmitSignal(SignalName.ShotComplete, ShotData); + } + + /// + /// Handle incoming shot from TCP (launch monitor) + /// + public void OnTcpClientHitBall(Dictionary data) + { + if (!ValidateData(data)) + { + EmitSignal(SignalName.BadData); + return; + } + + EmitSignal(SignalName.GoodData); + ShotData = data.Duplicate(); + StartShot(); + _ball.CallDeferred(GolfBall.MethodName.HitFromData, data); + } + + /// + /// Handle locally injected shot from UI + /// + public void OnGameplayUiHitShot(Variant data) + { + ShotData = ((Dictionary)data).Duplicate(); + PhysicsLogger.Info($"Local shot injection payload: {Json.Stringify(ShotData)}"); + StartShot(); + _ball.CallDeferred(GolfBall.MethodName.HitFromData, data); + } } diff --git a/game/SurfaceZone.cs b/game/SurfaceZone.cs index 214fe35..820261a 100644 --- a/game/SurfaceZone.cs +++ b/game/SurfaceZone.cs @@ -5,142 +5,142 @@ /// public partial class SurfaceZone : Area3D { - public const string GroupName = "surface_zone"; - - [Signal] - public delegate void BallEnteredSurfaceZoneEventHandler(GolfBall ball, int surfaceTypeValue); - - [Signal] - public delegate void BallExitedSurfaceZoneEventHandler(GolfBall ball, int surfaceTypeValue); - - [Export] - public PhysicsEnums.SurfaceType SurfaceType { get; set; } = PhysicsEnums.SurfaceType.Fairway; - - [Export] - public bool ShowDebugVisual { get; set; } = true; - - [Export] - public bool LogTransitions { get; set; } = true; - - public override void _Ready() - { - AddToGroup(GroupName); - EnsureDebugVisual(); - BodyEntered += OnBodyEntered; - BodyExited += OnBodyExited; - } - - public override void _ExitTree() - { - BodyEntered -= OnBodyEntered; - BodyExited -= OnBodyExited; - } - - private void OnBodyEntered(Node3D body) - { - if (body is GolfBall ball) - { - EmitSignal(SignalName.BallEnteredSurfaceZone, ball, (int)SurfaceType); - if (LogTransitions) - PhysicsLogger.INFO( - $"[SurfaceZone] entered '{Name}' zone={SurfaceType} active={ball.SurfaceType} " + - $"downrange={ball.GetDownrangeMeters() * MeasurementUtils.MetersToYards:F2}yd speed={ball.Velocity.Length():F2}m/s pos={ball.Position}" - ); - } - } - - private void OnBodyExited(Node3D body) - { - if (body is GolfBall ball) - { - EmitSignal(SignalName.BallExitedSurfaceZone, ball, (int)SurfaceType); - if (LogTransitions) - PhysicsLogger.INFO( - $"[SurfaceZone] exited '{Name}' zone={SurfaceType} active={ball.SurfaceType} " + - $"downrange={ball.GetDownrangeMeters() * MeasurementUtils.MetersToYards:F2}yd speed={ball.Velocity.Length():F2}m/s pos={ball.Position}" - ); - } - } - - private void EnsureDebugVisual() - { - var existing = GetNodeOrNull("DebugMesh"); - if (!ShowDebugVisual) - { - if (existing != null) - existing.Visible = false; - return; - } - - var collisionShape = GetNodeOrNull("CollisionShape3D"); - if (collisionShape == null || collisionShape.Shape == null) - return; - - MeshInstance3D meshNode = existing ?? new MeshInstance3D { Name = "DebugMesh" }; - meshNode.Visible = true; - meshNode.CastShadow = GeometryInstance3D.ShadowCastingSetting.Off; - meshNode.Transform = collisionShape.Transform; - meshNode.Mesh = CreateMeshForShape(collisionShape.Shape); - meshNode.MaterialOverride = CreateDebugMaterial(GetSurfaceColor(SurfaceType)); - - if (existing == null) - AddChild(meshNode); - } - - private static Mesh CreateMeshForShape(Shape3D shape) - { - if (shape is BoxShape3D box) - { - return new BoxMesh { Size = box.Size }; - } - - if (shape is SphereShape3D sphere) - { - return new SphereMesh { Radius = sphere.Radius, Height = sphere.Radius * 2.0f }; - } - - if (shape is CylinderShape3D cylinder) - { - return new CylinderMesh - { - TopRadius = cylinder.Radius, - BottomRadius = cylinder.Radius, - Height = cylinder.Height - }; - } - - if (shape is CapsuleShape3D capsule) - { - return new CapsuleMesh { Radius = capsule.Radius, Height = capsule.Height }; - } - - return new BoxMesh { Size = new Vector3(1.0f, 1.0f, 1.0f) }; - } - - private static StandardMaterial3D CreateDebugMaterial(Color baseColor) - { - return new StandardMaterial3D - { - AlbedoColor = baseColor, - Transparency = BaseMaterial3D.TransparencyEnum.Alpha, - CullMode = BaseMaterial3D.CullModeEnum.Disabled, - ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, - EmissionEnabled = true, - Emission = new Color(baseColor.R, baseColor.G, baseColor.B), - EmissionEnergyMultiplier = 0.4f - }; - } - - private static Color GetSurfaceColor(PhysicsEnums.SurfaceType surfaceType) - { - return surfaceType switch - { - PhysicsEnums.SurfaceType.Fairway => new Color(0.2f, 0.8f, 0.25f, 0.20f), - PhysicsEnums.SurfaceType.FairwaySoft => new Color(0.1f, 0.45f, 0.95f, 0.20f), - PhysicsEnums.SurfaceType.Rough => new Color(0.9f, 0.35f, 0.08f, 0.24f), - PhysicsEnums.SurfaceType.Firm => new Color(0.75f, 0.75f, 0.75f, 0.20f), - PhysicsEnums.SurfaceType.Green => new Color(0.15f, 0.95f, 0.35f, 0.20f), - _ => new Color(1.0f, 1.0f, 1.0f, 0.20f) - }; - } + public const string GroupName = "surface_zone"; + + [Signal] + public delegate void BallEnteredSurfaceZoneEventHandler(GolfBall ball, int surfaceTypeValue); + + [Signal] + public delegate void BallExitedSurfaceZoneEventHandler(GolfBall ball, int surfaceTypeValue); + + [Export] + public PhysicsEnums.SurfaceType SurfaceType { get; set; } = PhysicsEnums.SurfaceType.Fairway; + + [Export] + public bool ShowDebugVisual { get; set; } = true; + + [Export] + public bool LogTransitions { get; set; } = true; + + public override void _Ready() + { + AddToGroup(GroupName); + EnsureDebugVisual(); + BodyEntered += OnBodyEntered; + BodyExited += OnBodyExited; + } + + public override void _ExitTree() + { + BodyEntered -= OnBodyEntered; + BodyExited -= OnBodyExited; + } + + private void OnBodyEntered(Node3D body) + { + if (body is GolfBall ball) + { + EmitSignal(SignalName.BallEnteredSurfaceZone, ball, (int)SurfaceType); + if (LogTransitions) + PhysicsLogger.INFO( + $"[SurfaceZone] entered '{Name}' zone={SurfaceType} active={ball.SurfaceType} " + + $"downrange={ball.GetDownrangeMeters() * MeasurementUtils.MetersToYards:F2}yd speed={ball.Velocity.Length():F2}m/s pos={ball.Position}" + ); + } + } + + private void OnBodyExited(Node3D body) + { + if (body is GolfBall ball) + { + EmitSignal(SignalName.BallExitedSurfaceZone, ball, (int)SurfaceType); + if (LogTransitions) + PhysicsLogger.INFO( + $"[SurfaceZone] exited '{Name}' zone={SurfaceType} active={ball.SurfaceType} " + + $"downrange={ball.GetDownrangeMeters() * MeasurementUtils.MetersToYards:F2}yd speed={ball.Velocity.Length():F2}m/s pos={ball.Position}" + ); + } + } + + private void EnsureDebugVisual() + { + var existing = GetNodeOrNull("DebugMesh"); + if (!ShowDebugVisual) + { + if (existing != null) + existing.Visible = false; + return; + } + + var collisionShape = GetNodeOrNull("CollisionShape3D"); + if (collisionShape == null || collisionShape.Shape == null) + return; + + MeshInstance3D meshNode = existing ?? new MeshInstance3D { Name = "DebugMesh" }; + meshNode.Visible = true; + meshNode.CastShadow = GeometryInstance3D.ShadowCastingSetting.Off; + meshNode.Transform = collisionShape.Transform; + meshNode.Mesh = CreateMeshForShape(collisionShape.Shape); + meshNode.MaterialOverride = CreateDebugMaterial(GetSurfaceColor(SurfaceType)); + + if (existing == null) + AddChild(meshNode); + } + + private static Mesh CreateMeshForShape(Shape3D shape) + { + if (shape is BoxShape3D box) + { + return new BoxMesh { Size = box.Size }; + } + + if (shape is SphereShape3D sphere) + { + return new SphereMesh { Radius = sphere.Radius, Height = sphere.Radius * 2.0f }; + } + + if (shape is CylinderShape3D cylinder) + { + return new CylinderMesh + { + TopRadius = cylinder.Radius, + BottomRadius = cylinder.Radius, + Height = cylinder.Height + }; + } + + if (shape is CapsuleShape3D capsule) + { + return new CapsuleMesh { Radius = capsule.Radius, Height = capsule.Height }; + } + + return new BoxMesh { Size = new Vector3(1.0f, 1.0f, 1.0f) }; + } + + private static StandardMaterial3D CreateDebugMaterial(Color baseColor) + { + return new StandardMaterial3D + { + AlbedoColor = baseColor, + Transparency = BaseMaterial3D.TransparencyEnum.Alpha, + CullMode = BaseMaterial3D.CullModeEnum.Disabled, + ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, + EmissionEnabled = true, + Emission = new Color(baseColor.R, baseColor.G, baseColor.B), + EmissionEnergyMultiplier = 0.4f + }; + } + + private static Color GetSurfaceColor(PhysicsEnums.SurfaceType surfaceType) + { + return surfaceType switch + { + PhysicsEnums.SurfaceType.Fairway => new Color(0.2f, 0.8f, 0.25f, 0.20f), + PhysicsEnums.SurfaceType.FairwaySoft => new Color(0.1f, 0.45f, 0.95f, 0.20f), + PhysicsEnums.SurfaceType.Rough => new Color(0.9f, 0.35f, 0.08f, 0.24f), + PhysicsEnums.SurfaceType.Firm => new Color(0.75f, 0.75f, 0.75f, 0.20f), + PhysicsEnums.SurfaceType.Green => new Color(0.15f, 0.95f, 0.35f, 0.20f), + _ => new Color(1.0f, 1.0f, 1.0f, 0.20f) + }; + } } diff --git a/game/hole/HoleSceneControllerBase.cs b/game/hole/HoleSceneControllerBase.cs index 6110ced..3e972b3 100644 --- a/game/hole/HoleSceneControllerBase.cs +++ b/game/hole/HoleSceneControllerBase.cs @@ -56,6 +56,7 @@ public abstract partial class HoleSceneControllerBase : Node3D private AudioStreamPlayer3D _audioBackgroundBirds; private AudioStreamPlayer3D _audioGolfBallLanding; private TcpServer _tcpServer; + private ShotRecordingService _shotRecordingService; private GameSettings _gameSettings; private AppSettings _appSettings; private Setting _cameraOrbitDistanceSetting; @@ -211,6 +212,8 @@ private void InitializeCoreStage() if (_tcpServer != null) _tcpServer.HitBall += OnTcpClientHitBall; + _shotRecordingService = GetNodeOrNull("/root/ShotRecordingService"); + var globalSettings = GetNodeOrNull("/root/GlobalSettings"); if (globalSettings == null) { @@ -518,6 +521,7 @@ private void LaunchShot(Dictionary data, bool useTcpTracker, bool logPayload) UpdateBallDisplay(); PlayDriverHitAudio(); IncrementStrokeCount(); + _shotRecordingService?.RecordShot(data); if (useTcpTracker) _shotTracker.OnTcpClientHitBall(data); diff --git a/project.godot b/project.godot index 87451b3..5296f20 100644 --- a/project.godot +++ b/project.godot @@ -27,6 +27,7 @@ config/icon="uid://brlg0cmgh2ld8" PhantomCameraManager="*uid://duq6jhf6unyis" GlobalSettings="*res://utils/Settings/GlobalSettings.cs" TcpServerService="*res://tcp/TcpServer.cs" +ShotRecordingService="*res://game/ShotRecordingService.cs" GameProgressStore="*res://game/save/GameProgressStore.cs" CourseLoadService="*res://game/loading/CourseLoadService.cs" diff --git a/ui/CourseHud.cs b/ui/CourseHud.cs index ba38448..4ae28e8 100644 --- a/ui/CourseHud.cs +++ b/ui/CourseHud.cs @@ -3,560 +3,560 @@ public partial class CourseHud : Control { - [Signal] - public delegate void HitShotEventHandler(Dictionary data); - - private static readonly Color ControlsThemeColor = new Color(0.0431373f, 0.180392f, 0.309804f, 0.8f); - private static readonly Color ControlsFontColor = new Color(0.96f, 0.98f, 1.0f, 1.0f); - private const string MainMenuScenePath = "res://ui/main_menu.tscn"; - private const string DefaultPlayerName = "JohnDoe"; - private const string DefaultCourseName = "Airways"; - private const int DefaultHoleNumber = 1; - private const int DefaultPar = 3; - private const int DefaultYardage = 203; - - private string _selectedShotPath = TestShots.DefaultShot; - private GridCanvas _gridCanvas; - private Button _settingsMenu; - private SettingsPanel _settingsPanel; - private ShotInjector _shotInjector; - private OptionButton _shotTypeOption; - private Button _hitShotButton; - private Label _courseNameLabel; - private Label _holeNumberLabel; - private Label _parHeaderLabel; - private Label _yardageHeaderLabel; - private Label _playerNameLabel; - private Label _shotLabel; - private Label _targetYardageLabel; - private Label _targetElevationLabel; - private Label _roundEndScoreOverlay; - private Tween _roundEndScoreTween; - private bool _shotControlsVisible; - private bool _isLeavingScene; - private readonly System.Collections.Generic.List _hudPanels = new(); - private Setting _shotInjectorSetting; - private Setting _testShotsEnabledSetting; - private Setting _playerNameSetting; - - private DataPanel _panelDistance; - private DataPanel _panelCarry; - private DataPanel _panelSide; - private DataPanel _panelApex; - private DataPanel _panelSpeed; - private DataPanel _panelBackSpin; - private DataPanel _panelSideSpin; - private DataPanel _panelTotalSpin; - private DataPanel _panelSpinAxis; - private DataPanel _panelVLA; - private DataPanel _panelHLA; - - public override void _Ready() - { - _isLeavingScene = false; - - _gridCanvas = GetNode("GridCanvas"); - _settingsPanel = GetNodeOrNull("SettingsPanel"); - GlobalSettings globalSettings = GetNode("/root/GlobalSettings"); - _shotInjectorSetting = globalSettings.GameSettings.ShotInjectorEnabled; - _testShotsEnabledSetting = globalSettings.AppSettings?.TestShotsEnabled; - _playerNameSetting = globalSettings.AppSettings?.PlayerName; - _shotInjectorSetting.SettingChanged += OnShotInjectorSettingChanged; - if (_testShotsEnabledSetting != null) - _testShotsEnabledSetting.SettingChanged += OnTestShotsEnabledSettingChanged; - if (_playerNameSetting != null) - _playerNameSetting.SettingChanged += OnPlayerNameSettingChanged; - - _shotInjector = GetNode("ShotInjector"); - _shotInjector.Inject += OnShotInjectorInject; - - _hitShotButton = GetNode