Files
soccar2d/Assets/Scripts/AiBotController.cs
T

1099 lines
34 KiB
C#

using System.Collections;
using System.Collections.Generic;
using Mirror;
using UnityEngine;
public class AiBotController : NetworkBehaviour
{
public bool isEnabled = false;
public Team aiTeam;
public List<Puck> aiPucks;
[Tooltip("Pause after turn switches before trajectory previews begin.")]
public float aiTurnDelay = 5f;
[Tooltip("How long each puck trajectory preview is shown.")]
public float trajectoryPreviewDuration = 2f;
[Tooltip("Share of preview duration used to reveal the puck path before the ball path.")]
[Range(0.1f, 0.9f)]
public float puckCastDurationRatio = 0.4f;
[Tooltip("Max simulated seconds per trajectory.")]
public float trajectorySimMaxTime = 3f;
[Header("Trajectory visuals")]
public Color puckTrajectoryColor = new Color(1f, 0.85f, 0.2f, 0.9f);
public Color ballTrajectoryColor = new Color(0.9f, 0.95f, 1f, 0.9f);
public float trajectoryLineWidth = 0.08f;
public int trajectorySortingOrder = 30;
[Header("Shot scoring weights")]
public float ballProximityWeight = 1f;
public float ballBetweenGoalWeight = 1.5f;
public float trajectoryGoalWeight = 2f;
public float obstaclePushWeight = 0.75f;
[Tooltip("Ball within this distance of a blocking puck scores obstacle-push potential.")]
public float obstacleNearBallDistance = 2f;
[Tooltip("Any predicted path point within this distance of our own goal is penalized.")]
public float ownGoalDangerDistance = 3.5f;
Coroutine aiTurnCoroutine;
Coroutine trajectoryPreviewCoroutine;
LineRenderer puckTrajectoryLine;
LineRenderer ballTrajectoryLine;
readonly List<Vector2> puckPathBuffer = new List<Vector2>();
readonly List<Vector2> ballPathBuffer = new List<Vector2>();
const float SimDt = 1f / 60f;
const float MaxLaunchDirectionMagnitude = 4f;
const int MaxPathPoints = 96;
void Awake()
{
#if UNITY_SERVER
enabled = false;
return;
#else
EnsureTrajectoryLines();
#endif
}
void OnEnable()
{
GameManager.OnTeamChanged += OnTeamChanged;
}
void OnDisable()
{
GameManager.OnTeamChanged -= OnTeamChanged;
if (aiTurnCoroutine != null)
{
StopCoroutine(aiTurnCoroutine);
aiTurnCoroutine = null;
}
if (trajectoryPreviewCoroutine != null)
{
StopCoroutine(trajectoryPreviewCoroutine);
trajectoryPreviewCoroutine = null;
}
HideTrajectoryPreview();
}
void Update()
{
if (!isEnabled || !isServer)
return;
if (aiPucks == null || aiPucks.Count == 0)
return;
AnalyzePucks();
}
void EnsureTrajectoryLines()
{
if (puckTrajectoryLine == null)
puckTrajectoryLine = CreateTrajectoryLine("PuckTrajectory", puckTrajectoryColor);
if (ballTrajectoryLine == null)
ballTrajectoryLine = CreateTrajectoryLine("BallTrajectory", ballTrajectoryColor);
}
LineRenderer CreateTrajectoryLine(string name, Color color)
{
Transform existing = transform.Find(name);
if (existing != null)
{
LineRenderer existingLine = existing.GetComponent<LineRenderer>();
if (existingLine != null)
existingLine.sortingOrder = trajectorySortingOrder;
return existingLine;
}
Shader shader = Shader.Find("Sprites/Default");
if (shader == null)
return null;
GameObject go = new GameObject(name);
go.transform.SetParent(transform, false);
LineRenderer line = go.AddComponent<LineRenderer>();
line.useWorldSpace = true;
line.startWidth = trajectoryLineWidth;
line.endWidth = trajectoryLineWidth;
line.numCapVertices = 4;
line.sortingOrder = trajectorySortingOrder;
line.material = new Material(shader);
line.startColor = color;
line.endColor = color;
line.positionCount = 0;
line.enabled = false;
return line;
}
struct PuckShotScore
{
public Puck puck;
public float totalScore;
public float ballProximityScore;
public float ballBetweenGoalScore;
public float trajectoryGoalScore;
public float obstaclePushScore;
}
/// <summary>
/// Scores all AI pucks and returns the best shot candidate.
/// </summary>
public Puck GetBestPuck()
{
PuckShotScore best = default;
best.totalScore = float.MinValue;
foreach (Puck puck in aiPucks)
{
if (puck == null)
continue;
PuckShotScore score = ScorePuck(puck);
if (score.totalScore > best.totalScore)
best = score;
}
if (best.puck != null)
{
Logger.Log(
$"AI best puck {best.puck.name}: total={best.totalScore:F2} " +
$"(prox={best.ballProximityScore:F2}, between={best.ballBetweenGoalScore:F2}, " +
$"traj={best.trajectoryGoalScore:F2}, push={best.obstaclePushScore:F2})");
}
return best.puck;
}
PuckShotScore ScorePuck(Puck puck)
{
GameManager gm = GameManager.instance;
PuckShotScore result = new PuckShotScore { puck = puck };
if (gm == null || gm.ball == null || puck == null)
return result;
Vector2 puckPos = puck.transform.position;
Vector2 ballPos = gm.ball.position;
Vector2 goalPos = GetAttackGoalPosition();
Vector2 defendedGoalPos = GetDefendedGoalPosition();
float maxPuckBallDist = Mathf.Max(gm.fieldSize * 2f, 1f);
float puckBallDist = Vector2.Distance(puckPos, ballPos);
result.ballProximityScore = 1f - Mathf.Clamp01(puckBallDist / maxPuckBallDist);
result.ballBetweenGoalScore = ScoreBallBetweenPuckAndGoal(puckPos, ballPos, goalPos);
result.obstaclePushScore = ScoreObstaclePushPotential(puck, puckPos, ballPos);
PredictTrajectory(puck, puckPathBuffer, ballPathBuffer);
result.trajectoryGoalScore = ScoreTrajectoryTowardGoal(ballPos, goalPos, defendedGoalPos);
result.totalScore =
result.ballProximityScore * ballProximityWeight +
result.ballBetweenGoalScore * ballBetweenGoalWeight +
result.trajectoryGoalScore * trajectoryGoalWeight +
result.obstaclePushScore * obstaclePushWeight;
return result;
}
/// <summary>
/// Goal this team scores into (e.g. RedGoal is where Red attacks).
/// </summary>
Vector2 GetAttackGoalPosition()
{
Goal attackGoal = aiTeam == Team.Red ? Goal.RedGoal : Goal.BlueGoal;
if (attackGoal != null)
return attackGoal.transform.position;
Goal[] goals = FindObjectsByType<Goal>(FindObjectsSortMode.None);
foreach (Goal goal in goals)
{
if (goal != null && goal.team == aiTeam)
return goal.transform.position;
}
return Vector2.zero;
}
/// <summary>
/// Own net — the goal the opponent scores into.
/// </summary>
Vector2 GetDefendedGoalPosition()
{
Goal defendedGoal = aiTeam == Team.Red ? Goal.BlueGoal : Goal.RedGoal;
if (defendedGoal != null)
return defendedGoal.transform.position;
Goal[] goals = FindObjectsByType<Goal>(FindObjectsSortMode.None);
foreach (Goal goal in goals)
{
if (goal != null && goal.team != aiTeam)
return goal.transform.position;
}
return Vector2.zero;
}
static float ScoreBallBetweenPuckAndGoal(Vector2 puckPos, Vector2 ballPos, Vector2 goalPos)
{
Vector2 puckToGoal = goalPos - puckPos;
float puckGoalDist = puckToGoal.magnitude;
if (puckGoalDist < 0.01f)
return 0f;
Vector2 puckToBall = ballPos - puckPos;
float projection = Vector2.Dot(puckToBall, puckToGoal.normalized);
if (projection <= 0f)
return 0f;
float ballGoalDist = Vector2.Distance(ballPos, goalPos);
if (ballGoalDist >= puckGoalDist)
return 0f;
float alongLine = Mathf.Clamp01(projection / puckGoalDist);
float closerToGoal = 1f - Mathf.Clamp01(ballGoalDist / puckGoalDist);
return alongLine * closerToGoal;
}
float ScoreObstaclePushPotential(Puck puck, Vector2 puckPos, Vector2 ballPos)
{
if (HasDirectLineToBall(puck, puckPos, ballPos))
return 1f;
RaycastHit2D[] hits = Physics2D.LinecastAll(puckPos, ballPos);
float bestScore = 0f;
foreach (RaycastHit2D hit in hits)
{
if (hit.collider == null)
continue;
if (hit.collider.transform == puck.transform)
continue;
if (hit.collider.GetComponent<Ball>() != null)
continue;
float distToBall = Vector2.Distance(hit.point, ballPos);
float score = 1f - Mathf.Clamp01(distToBall / Mathf.Max(obstacleNearBallDistance, 0.01f));
if (score > bestScore)
bestScore = score;
}
return bestScore;
}
float ScoreTrajectoryTowardGoal(Vector2 ballStartPos, Vector2 goalPos, Vector2 defendedGoalPos)
{
if (goalPos.sqrMagnitude < 0.0001f)
return 0f;
float attackScore = 0f;
Vector2 travelDir = Vector2.zero;
if (ballPathBuffer.Count >= 2)
{
Vector2 end = ballPathBuffer[ballPathBuffer.Count - 1];
Vector2 prev = ballPathBuffer[ballPathBuffer.Count - 2];
travelDir = end - prev;
if (travelDir.sqrMagnitude < 0.0001f)
return 0f;
travelDir.Normalize();
Vector2 toGoal = (goalPos - end).normalized;
float directionScore = Mathf.Max(0f, Vector2.Dot(travelDir, toGoal));
float startGoalDist = Vector2.Distance(ballStartPos, goalPos);
float endGoalDist = Vector2.Distance(end, goalPos);
float progressScore = startGoalDist > 0.01f
? Mathf.Clamp01(1f - endGoalDist / startGoalDist)
: 0f;
attackScore = directionScore * 0.7f + progressScore * 0.3f;
}
else if (puckPathBuffer.Count >= 2)
{
Vector2 end = puckPathBuffer[puckPathBuffer.Count - 1];
Vector2 prev = puckPathBuffer[puckPathBuffer.Count - 2];
travelDir = end - prev;
if (travelDir.sqrMagnitude < 0.0001f)
return 0f;
travelDir.Normalize();
Vector2 toGoal = (goalPos - end).normalized;
attackScore = Mathf.Max(0f, Vector2.Dot(travelDir, toGoal)) * 0.35f;
}
if (attackScore <= 0f || defendedGoalPos.sqrMagnitude < 0.0001f)
return attackScore;
Vector2 trajectoryEnd = ballPathBuffer.Count >= 2
? ballPathBuffer[ballPathBuffer.Count - 1]
: puckPathBuffer[puckPathBuffer.Count - 1];
Vector2 toOwnGoal = (defendedGoalPos - trajectoryEnd).normalized;
float ownGoalAlignment = Mathf.Max(0f, Vector2.Dot(travelDir, toOwnGoal));
float ownGoalProximityDanger = GetTrajectoryOwnGoalProximity(defendedGoalPos);
return attackScore * (1f - ownGoalAlignment) * (1f - ownGoalProximityDanger);
}
/// <summary>
/// Returns 0-1 danger based on the closest any predicted puck/ball point gets to our own goal.
/// </summary>
float GetTrajectoryOwnGoalProximity(Vector2 defendedGoalPos)
{
float radius = Mathf.Max(ownGoalDangerDistance, 0.01f);
float maxDanger = 0f;
for (int p = 0; p < puckPathBuffer.Count; p++)
maxDanger = Mathf.Max(maxDanger, GetPointOwnGoalDanger(puckPathBuffer[p], defendedGoalPos, radius));
for (int p = 0; p < ballPathBuffer.Count; p++)
maxDanger = Mathf.Max(maxDanger, GetPointOwnGoalDanger(ballPathBuffer[p], defendedGoalPos, radius));
return maxDanger;
}
static float GetPointOwnGoalDanger(Vector2 point, Vector2 defendedGoalPos, float radius)
{
float dist = Vector2.Distance(point, defendedGoalPos);
if (dist >= radius)
return 0f;
float t = 1f - dist / radius;
return t * t;
}
/// <summary>
/// Draws per-puck debug lines using lightweight heuristics (no full trajectory sim).
/// </summary>
public Puck AnalyzePucks()
{
if (GameManager.instance == null || GameManager.instance.ball == null)
return null;
Vector2 ballPoint = GameManager.instance.ball.position;
Vector2 goalPos = GetAttackGoalPosition();
float maxPuckBallDist = Mathf.Max(GameManager.instance.fieldSize * 2f, 1f);
Puck bestPuck = null;
float bestScore = float.MinValue;
foreach (Puck puck in aiPucks)
{
if (puck == null)
continue;
Vector2 puckPoint = puck.transform.position;
bool canReachBall = HasDirectLineToBall(puck, puckPoint, ballPoint);
float proximity = 1f - Mathf.Clamp01(Vector2.Distance(puckPoint, ballPoint) / maxPuckBallDist);
float between = ScoreBallBetweenPuckAndGoal(puckPoint, ballPoint, goalPos);
float obstacle = ScoreObstaclePushPotential(puck, puckPoint, ballPoint);
float quickScore = proximity * ballProximityWeight
+ between * ballBetweenGoalWeight
+ obstacle * obstaclePushWeight;
Color lineColor = Color.Lerp(Color.red, Color.green, quickScore / Mathf.Max(GetMaxPossibleScore() - trajectoryGoalWeight, 0.01f));
if (!canReachBall && obstacle > 0.25f)
lineColor = Color.Lerp(lineColor, Color.cyan, obstacle);
Debug.DrawLine(puckPoint, ballPoint, lineColor);
if (quickScore > bestScore)
{
bestScore = quickScore;
bestPuck = puck;
}
}
return bestPuck;
}
float GetMaxPossibleScore()
{
return ballProximityWeight + ballBetweenGoalWeight + trajectoryGoalWeight + obstaclePushWeight;
}
Puck GetClosestPuckToBall()
{
if (GameManager.instance == null || GameManager.instance.ball == null)
return null;
Vector3 ballPoint = GameManager.instance.ball.position;
Puck closest = null;
float closestDistance = float.MaxValue;
foreach (Puck puck in aiPucks)
{
if (puck == null)
continue;
float dist = Vector2.Distance(puck.transform.position, ballPoint);
if (dist < closestDistance)
{
closestDistance = dist;
closest = puck;
}
}
return closest;
}
bool HasDirectLineToBall(Puck puck, Vector3 from, Vector3 to)
{
RaycastHit2D[] hits = Physics2D.LinecastAll(from, to);
foreach (RaycastHit2D hit in hits)
{
if (hit.collider == null)
continue;
if (hit.collider.transform == puck.transform)
continue;
if (hit.collider.GetComponent<Ball>() != null)
continue;
return false;
}
return true;
}
void OnTeamChanged(Team team)
{
if (!isEnabled || !isServer)
return;
if (team != aiTeam)
return;
if (GameManager.instance == null || !GameManager.instance.gameStarted)
return;
if (aiTurnCoroutine != null)
StopCoroutine(aiTurnCoroutine);
aiTurnCoroutine = StartCoroutine(TakeAiTurn());
}
IEnumerator TakeAiTurn()
{
yield return null;
GameManager gm = GameManager.instance;
while (gm != null && gm.m_isMoving)
yield return null;
if (gm == null || !gm.gameStarted || gm.SelectedTeam != aiTeam)
yield break;
if (aiTurnDelay > 0f)
yield return new WaitForSeconds(aiTurnDelay);
if (gm == null || gm.SelectedTeam != aiTeam || gm.m_isMoving)
yield break;
foreach (Puck previewPuck in aiPucks)
{
if (previewPuck == null)
continue;
PredictTrajectory(previewPuck, puckPathBuffer, ballPathBuffer);
Vector2[] puckPoints = puckPathBuffer.ToArray();
Vector2[] ballPoints = ballPathBuffer.ToArray();
yield return AnimateTrajectoryPreview(puckPoints, ballPoints);
if (gm == null || gm.SelectedTeam != aiTeam || gm.m_isMoving)
{
HideTrajectoryPreview();
RpcHideTrajectoryPreview();
yield break;
}
HideTrajectoryPreview();
RpcHideTrajectoryPreview();
}
Puck puck = GetBestPuck();
if (puck == null)
puck = GetClosestPuckToBall();
if (puck == null)
yield break;
GameManager.SelectedPuck = puck;
Vector2 direction = ComputeLaunchDirection(puck);
gm.OnPointerUp(direction);
aiTurnCoroutine = null;
}
void PredictTrajectory(Puck shootingPuck, List<Vector2> puckPath, List<Vector2> ballPath)
{
puckPath.Clear();
ballPath.Clear();
GameManager gm = GameManager.instance;
if (gm == null || gm.ball == null || shootingPuck == null || shootingPuck.rb == null)
return;
Collider2D puckCollider = shootingPuck.GetComponent<Collider2D>();
Collider2D ballCollider = gm.ball.GetComponent<Collider2D>();
if (puckCollider == null || ballCollider == null)
return;
float puckRadius = GetColliderRadius(puckCollider, shootingPuck.transform);
float ballRadius = GetColliderRadius(ballCollider, gm.ball.transform);
float puckMass = Mathf.Max(shootingPuck.rb.mass, 0.01f);
float ballMass = Mathf.Max(gm.ball.mass, 0.01f);
float puckBounce = GetBounciness(puckCollider);
float ballBounce = GetBounciness(ballCollider);
Vector2 puckPos = shootingPuck.transform.position;
Vector2 puckVel = GetLaunchVelocity(shootingPuck);
Vector2 ballPos = gm.ball.position;
Vector2 ballVel = Vector2.zero;
bool ballReleased = false;
puckPath.Add(puckPos);
float elapsed = 0f;
int step = 0;
Vector2 ballImpactPos = ballPos;
Vector2 ballImpactVel = Vector2.zero;
while (elapsed < trajectorySimMaxTime && puckPath.Count < MaxPathPoints)
{
elapsed += SimDt;
step++;
bool puckActive = puckVel.sqrMagnitude > 0.0001f;
bool ballActive = ballReleased && ballVel.sqrMagnitude > 0.0001f;
if (!puckActive && !ballActive)
break;
bool wasBallReleased = ballReleased;
if (!ballReleased && puckActive)
{
AdvanceShootingPuck(
ref puckPos,
ref puckVel,
puckRadius,
shootingPuck.rb.linearDamping,
SimDt,
puckCollider,
puckMass,
puckBounce,
ref ballPos,
ref ballVel,
ref ballReleased,
ballRadius,
ballMass,
ballBounce);
}
else
{
if (puckActive)
{
AdvanceWallsOnly(
ref puckPos,
ref puckVel,
puckRadius,
shootingPuck.rb.linearDamping,
SimDt,
puckCollider,
puckBounce);
}
if (ballActive)
{
AdvanceWallsOnly(
ref ballPos,
ref ballVel,
ballRadius,
gm.ball.linearDamping,
SimDt,
ballCollider,
ballBounce);
}
}
if (ballReleased && !wasBallReleased)
{
ballImpactPos = ballPos;
ballImpactVel = ballVel;
}
if (step % 2 == 0)
puckPath.Add(puckPos);
}
if (ballReleased)
SimulateBallPathAfterImpact(gm, ballCollider, ballRadius, ballBounce, ballImpactPos, ballImpactVel, ballPath);
if (puckPath.Count == 1 && puckVel.sqrMagnitude > 0.0001f)
puckPath.Add(puckPos + puckVel.normalized * 0.5f);
}
void SimulateBallPathAfterImpact(
GameManager gm,
Collider2D ballCollider,
float ballRadius,
float ballBounce,
Vector2 ballPos,
Vector2 ballVel,
List<Vector2> ballPath)
{
ballPath.Clear();
AppendBallPathPoint(ballPath, ballPos);
float elapsed = 0f;
while (elapsed < trajectorySimMaxTime && ballPath.Count < MaxPathPoints && ballVel.sqrMagnitude > 0.0001f)
{
elapsed += SimDt;
AdvanceWallsOnly(
ref ballPos,
ref ballVel,
ballRadius,
gm.ball.linearDamping,
SimDt,
ballCollider,
ballBounce);
AppendBallPathPoint(ballPath, ballPos);
}
if (ballPath.Count == 1 && ballVel.sqrMagnitude > 0.0001f)
AppendBallPathPoint(ballPath, ballPos + ballVel.normalized * Mathf.Max(ballVel.magnitude * 0.35f, 0.75f));
}
static void AppendBallPathPoint(List<Vector2> ballPath, Vector2 point)
{
if (ballPath.Count > 0 && Vector2.Distance(ballPath[ballPath.Count - 1], point) < 0.05f)
return;
ballPath.Add(point);
}
void AdvanceShootingPuck(
ref Vector2 pos,
ref Vector2 vel,
float radius,
float damping,
float dt,
Collider2D selfCollider,
float selfMass,
float selfBounce,
ref Vector2 ballPos,
ref Vector2 ballVel,
ref bool ballReleased,
float ballRadius,
float ballMass,
float ballBounce)
{
ApplyDamping(ref vel, damping, dt);
if (vel.sqrMagnitude < 0.0001f)
return;
Vector2 delta = vel * dt;
float distance = delta.magnitude;
Vector2 direction = delta / distance;
RaycastHit2D[] hits = Physics2D.CircleCastAll(pos, radius * 0.98f, direction, distance);
RaycastHit2D? bestHit = null;
float bestDistance = float.MaxValue;
foreach (RaycastHit2D hit in hits)
{
if (hit.collider == null || hit.collider.isTrigger)
continue;
if (hit.collider == selfCollider)
continue;
if (hit.distance < bestDistance)
{
bestDistance = hit.distance;
bestHit = hit;
}
}
if (bestHit.HasValue)
{
RaycastHit2D hit = bestHit.Value;
pos = hit.point + hit.normal * radius;
Ball ball = hit.collider.GetComponent<Ball>();
if (ball != null && !ballReleased)
{
float restitution = (selfBounce + ballBounce) * 0.5f;
ResolveCircleCollision(
ref vel, selfMass,
ref ballVel, ballMass,
pos, ballPos,
restitution);
ballReleased = true;
SeparateCircles(ref pos, radius, ref ballPos, ballRadius);
}
else
{
float bounce = GetBounciness(hit.collider);
vel = Vector2.Reflect(vel, hit.normal) * bounce;
}
}
else
{
pos += delta;
if (!ballReleased && Vector2.Distance(pos, ballPos) < radius + ballRadius)
{
float restitution = (selfBounce + ballBounce) * 0.5f;
ResolveCircleCollision(
ref vel, selfMass,
ref ballVel, ballMass,
pos, ballPos,
restitution);
ballReleased = true;
SeparateCircles(ref pos, radius, ref ballPos, ballRadius);
}
}
}
void AdvanceWallsOnly(
ref Vector2 pos,
ref Vector2 vel,
float radius,
float damping,
float dt,
Collider2D selfCollider,
float selfBounce)
{
ApplyDamping(ref vel, damping, dt);
if (vel.sqrMagnitude < 0.0001f)
return;
Vector2 delta = vel * dt;
float distance = delta.magnitude;
Vector2 direction = delta / distance;
RaycastHit2D[] hits = Physics2D.CircleCastAll(pos, radius * 0.98f, direction, distance);
RaycastHit2D? bestHit = null;
float bestDistance = float.MaxValue;
foreach (RaycastHit2D hit in hits)
{
if (hit.collider == null || hit.collider.isTrigger)
continue;
if (hit.collider == selfCollider)
continue;
if (hit.collider.GetComponent<Ball>() != null)
continue;
if (hit.distance < bestDistance)
{
bestDistance = hit.distance;
bestHit = hit;
}
}
if (bestHit.HasValue)
{
RaycastHit2D hit = bestHit.Value;
pos = hit.point + hit.normal * radius;
float bounce = GetBounciness(hit.collider);
vel = Vector2.Reflect(vel, hit.normal) * bounce;
}
else
{
pos += delta;
}
}
static void ApplyDamping(ref Vector2 vel, float damping, float dt)
{
if (damping <= 0f)
return;
vel *= 1f / (1f + damping * dt);
}
static void ResolveCircleCollision(
ref Vector2 velA, float massA,
ref Vector2 velB, float massB,
Vector2 posA, Vector2 posB,
float restitution)
{
Vector2 normal = (posB - posA);
if (normal.sqrMagnitude < 0.0001f)
normal = Vector2.up;
normal.Normalize();
float velAlongNormal = Vector2.Dot(velB - velA, normal);
if (velAlongNormal > 0f)
return;
float impulse = -(1f + restitution) * velAlongNormal / (1f / massA + 1f / massB);
Vector2 impulseVector = impulse * normal;
velA -= impulseVector / massA;
velB += impulseVector / massB;
}
static void SeparateCircles(ref Vector2 posA, float radiusA, ref Vector2 posB, float radiusB)
{
Vector2 delta = posB - posA;
float overlap = radiusA + radiusB - delta.magnitude;
if (overlap <= 0f)
return;
Vector2 normal = delta.sqrMagnitude > 0.0001f ? delta.normalized : Vector2.up;
posA -= normal * (overlap * 0.5f);
posB += normal * (overlap * 0.5f);
}
static float GetColliderRadius(Collider2D collider, Transform t)
{
if (collider is CircleCollider2D circle)
{
float scale = Mathf.Max(Mathf.Abs(t.lossyScale.x), Mathf.Abs(t.lossyScale.y));
return circle.radius * scale;
}
return 0.45f;
}
static float GetBounciness(Collider2D collider)
{
if (collider != null && collider.sharedMaterial != null)
return collider.sharedMaterial.bounciness;
return 0.8f;
}
Vector2 GetLaunchVelocity(Puck puck)
{
GameManager gm = GameManager.instance;
Vector2 direction = ComputeLaunchDirection(puck);
float force = gm.puckForce * gm.puckForceCurve.Evaluate(direction.magnitude);
Vector2 impulse = -direction * force;
return impulse / Mathf.Max(puck.rb.mass, 0.01f);
}
/// <summary>
/// Catapult drag direction: opposite of the shot, matching player touch input.
/// OnPointerUp applies force as -direction.
/// </summary>
Vector2 ComputeLaunchDirection(Puck puck)
{
Vector2 puckPos = puck.transform.position;
Vector2 ballPos = GameManager.instance.ball.position;
Vector2 dragBehind = (puckPos - ballPos).normalized;
Vector2 dragWorldPoint = puckPos + dragBehind * MaxLaunchDirectionMagnitude;
Vector2 direction = puck.transform.InverseTransformPoint(dragWorldPoint);
return Vector2.ClampMagnitude(direction, MaxLaunchDirectionMagnitude);
}
IEnumerator AnimateTrajectoryPreview(Vector2[] puckPoints, Vector2[] ballPoints)
{
RpcAnimateTrajectoryPreview(puckPoints, ballPoints);
yield return new WaitForSeconds(Mathf.Max(trajectoryPreviewDuration, 0.01f));
}
IEnumerator ClientAnimateTrajectoryPreview(Vector2[] puckPoints, Vector2[] ballPoints)
{
EnsureTrajectoryLines();
HideTrajectoryPreview();
float duration = Mathf.Max(trajectoryPreviewDuration, 0.01f);
float puckPhase = Mathf.Clamp(puckCastDurationRatio, 0.1f, 0.9f);
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
if (t < puckPhase)
{
float puckT = t / puckPhase;
SetPartialLine(puckTrajectoryLine, puckPoints, puckTrajectoryColor, puckT);
SetPartialLine(ballTrajectoryLine, ballPoints, ballTrajectoryColor, 0f);
}
else
{
SetPartialLine(puckTrajectoryLine, puckPoints, puckTrajectoryColor, 1f);
float ballT = (t - puckPhase) / (1f - puckPhase);
SetPartialLine(ballTrajectoryLine, ballPoints, ballTrajectoryColor, ballT);
}
yield return null;
}
ApplyTrajectoryPreview(puckPoints, ballPoints);
trajectoryPreviewCoroutine = null;
}
static void SetPartialLine(LineRenderer line, Vector2[] points, Color color, float progress)
{
if (line == null)
return;
if (points == null || points.Length == 0)
{
line.positionCount = 0;
line.enabled = false;
return;
}
line.enabled = true;
line.startColor = color;
line.endColor = color;
if (progress <= 0f)
{
line.positionCount = 0;
line.enabled = false;
return;
}
if (points.Length == 1)
{
line.positionCount = 1;
line.SetPosition(0, new Vector3(points[0].x, points[0].y, 0f));
return;
}
if (progress >= 1f)
{
SetLinePositions(line, points, color);
return;
}
float totalLength = 0f;
for (int i = 1; i < points.Length; i++)
totalLength += Vector2.Distance(points[i - 1], points[i]);
if (totalLength < 0.0001f)
{
SetLinePositions(line, points, color);
return;
}
float targetLength = totalLength * progress;
float accumulated = 0f;
int partialCount = 1;
for (int i = 1; i < points.Length; i++)
{
float segmentLength = Vector2.Distance(points[i - 1], points[i]);
if (accumulated + segmentLength >= targetLength)
{
partialCount = i + 1;
break;
}
partialCount = i + 1;
accumulated += segmentLength;
}
line.positionCount = partialCount;
line.SetPosition(0, new Vector3(points[0].x, points[0].y, 0f));
accumulated = 0f;
for (int i = 1; i < partialCount; i++)
{
if (i < partialCount - 1)
{
line.SetPosition(i, new Vector3(points[i].x, points[i].y, 0f));
accumulated += Vector2.Distance(points[i - 1], points[i]);
continue;
}
float segmentLength = Vector2.Distance(points[i - 1], points[i]);
float remaining = targetLength - accumulated;
float segmentT = segmentLength > 0.0001f ? remaining / segmentLength : 1f;
Vector2 end = Vector2.Lerp(points[i - 1], points[i], segmentT);
line.SetPosition(i, new Vector3(end.x, end.y, 0f));
}
}
void ApplyTrajectoryPreview(Vector2[] puckPoints, Vector2[] ballPoints)
{
EnsureTrajectoryLines();
SetLinePositions(puckTrajectoryLine, puckPoints, puckTrajectoryColor);
SetLinePositions(ballTrajectoryLine, ballPoints, ballTrajectoryColor);
}
void HideTrajectoryPreview()
{
if (trajectoryPreviewCoroutine != null)
{
StopCoroutine(trajectoryPreviewCoroutine);
trajectoryPreviewCoroutine = null;
}
if (puckTrajectoryLine != null)
{
puckTrajectoryLine.positionCount = 0;
puckTrajectoryLine.enabled = false;
}
if (ballTrajectoryLine != null)
{
ballTrajectoryLine.positionCount = 0;
ballTrajectoryLine.enabled = false;
}
}
static void SetLinePositions(LineRenderer line, Vector2[] points, Color color)
{
if (line == null || points == null || points.Length == 0)
return;
line.enabled = true;
line.startColor = color;
line.endColor = color;
line.positionCount = points.Length;
for (int i = 0; i < points.Length; i++)
line.SetPosition(i, new Vector3(points[i].x, points[i].y, 0f));
}
[ClientRpc]
void RpcAnimateTrajectoryPreview(Vector2[] puckPoints, Vector2[] ballPoints)
{
if (trajectoryPreviewCoroutine != null)
StopCoroutine(trajectoryPreviewCoroutine);
trajectoryPreviewCoroutine = StartCoroutine(ClientAnimateTrajectoryPreview(puckPoints, ballPoints));
}
[ClientRpc]
void RpcShowTrajectoryPreview(Vector2[] puckPoints, Vector2[] ballPoints)
{
ApplyTrajectoryPreview(puckPoints, ballPoints);
}
[ClientRpc]
void RpcHideTrajectoryPreview()
{
HideTrajectoryPreview();
}
public void Setup(Team team)
{
#if UNITY_SERVER
return;
#else
aiTeam = team;
RefreshAiPucks();
isEnabled = true;
#endif
}
void RefreshAiPucks()
{
aiPucks = new List<Puck>();
Puck[] pucks = FindObjectsByType<Puck>(FindObjectsSortMode.None);
foreach (Puck puck in pucks)
{
if (puck.team == aiTeam)
aiPucks.Add(puck);
}
}
}