From 0271d64a1d656694a31c84bb694a203ae1a1cedc Mon Sep 17 00:00:00 2001 From: ZealanL Date: Mon, 23 Oct 2023 12:45:32 -0700 Subject: [PATCH 1/4] Add z impulse to hoops ball on kickoff --- src/RLConst.h | 1 + src/Sim/Arena/Arena.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/RLConst.h b/src/RLConst.h index 9a492b60..7f153bae 100644 --- a/src/RLConst.h +++ b/src/RLConst.h @@ -37,6 +37,7 @@ namespace RLConst { BALL_DRAG = 0.03f, // Net-velocity drag multiplier BALL_FRICTION = 0.35f, BALL_RESTITUTION = 0.6f, // Bounce factor + BALL_HOOPS_Z_VEL = 1000, // Z impulse applied to hoops ball on kickoff CAR_MAX_SPEED = 2300.f, BALL_MAX_SPEED = 6000.f, diff --git a/src/Sim/Arena/Arena.cpp b/src/Sim/Arena/Arena.cpp index df98b6b5..fab3366c 100644 --- a/src/Sim/Arena/Arena.cpp +++ b/src/Sim/Arena/Arena.cpp @@ -191,6 +191,8 @@ void Arena::ResetToRandomKickoff(int seed) { } else if (gameMode == GameMode::SNOWDAY) { // Don't freeze ballState.vel.z = FLT_EPSILON; + } else if (isHoops) { + ballState.vel.z = BALL_HOOPS_Z_VEL; } ball->SetState(ballState); From cfcb24adf774ef3cd3d62447afe59aad851784a9 Mon Sep 17 00:00:00 2001 From: ZealanL Date: Tue, 24 Oct 2023 20:45:31 -0700 Subject: [PATCH 2/4] Fix wacky boost pad coordinate (wiki was wrong) --- src/RLConst.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RLConst.h b/src/RLConst.h index 7f153bae..db611344 100644 --- a/src/RLConst.h +++ b/src/RLConst.h @@ -229,7 +229,7 @@ namespace RLConst { {-3584.f, 2484.f, 70.f }, {3584.f, 2484.f, 70.f }, {0.f, 2816.f, 70.f }, - {-940.f, 3310.f, 70.f }, + {-940.f, 3308.f, 70.f }, {940.f, 3308.f, 70.f }, {-1792.f, 4184.f, 70.f }, {1792.f, 4184.f, 70.f }, From afc5b618108a1b7fb91e0fc3e88ba1569bdd9ac9 Mon Sep 17 00:00:00 2001 From: ZealanL Date: Fri, 27 Oct 2023 21:49:41 -0700 Subject: [PATCH 3/4] Add missing auto-flip angle condition (thanks 47) --- src/RLConst.h | 1 + src/Sim/Car/Car.cpp | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/RLConst.h b/src/RLConst.h index db611344..954f1b57 100644 --- a/src/RLConst.h +++ b/src/RLConst.h @@ -113,6 +113,7 @@ namespace RLConst { CAR_AUTOFLIP_TORQUE = 50, CAR_AUTOFLIP_TIME = 0.4f, CAR_AUTOFLIP_NORMZ_THRESH = M_SQRT1_2, + CAR_AUTOFLIP_ROLL_THRESH = 2.8f, CAR_AUTOROLL_FORCE = 100, CAR_AUTOROLL_TORQUE = 80, diff --git a/src/Sim/Car/Car.cpp b/src/Sim/Car/Car.cpp index 0d0e8825..fe2ada59 100644 --- a/src/Sim/Car/Car.cpp +++ b/src/Sim/Car/Car.cpp @@ -759,12 +759,17 @@ void Car::_UpdateAutoFlip(float tickTime, const MutatorConfig& mutatorConfig, bo _internalState.worldContact.contactNormal.z > CAR_AUTOFLIP_NORMZ_THRESH ) { + // TODO: Slow :( Angle angles = Angle::FromRotMat(_internalState.rotMat); - _internalState.autoFlipTimer = CAR_AUTOFLIP_TIME * (abs(angles.roll) / M_PI); - _internalState.autoFlipTorqueScale = (angles.roll > 0) ? 1 : -1; - _internalState.isAutoFlipping = true; - _rigidBody.applyCentralImpulse(-GetUpDir() * CAR_AUTOFLIP_IMPULSE * UU_TO_BT * CAR_MASS_BT); + float absRoll = abs(angles.roll); + if (absRoll > CAR_AUTOFLIP_ROLL_THRESH) { + _internalState.autoFlipTimer = CAR_AUTOFLIP_TIME * (absRoll / M_PI); + _internalState.autoFlipTorqueScale = (angles.roll > 0) ? 1 : -1; + _internalState.isAutoFlipping = true; + + _rigidBody.applyCentralImpulse(-GetUpDir() * CAR_AUTOFLIP_IMPULSE * UU_TO_BT * CAR_MASS_BT); + } } if (_internalState.isAutoFlipping) { From 94d9a2eeefb0d077afd72bdcd966063512a1f9ba Mon Sep 17 00:00:00 2001 From: ZealanL Date: Sat, 28 Oct 2023 23:45:25 -0700 Subject: [PATCH 4/4] Add GameEventTracker, other misc changes --- src/Sim/Arena/Arena.cpp | 91 +++++----- src/Sim/Arena/Arena.h | 7 +- src/Sim/Ball/Ball.cpp | 3 + src/Sim/Ball/Ball.h | 5 + src/Sim/BallPredTracker/BallPredTracker.h | 1 + src/Sim/Car/Car.cpp | 3 + src/Sim/Car/Car.h | 8 + src/Sim/GameEventTracker/GameEventTracker.cpp | 160 ++++++++++++++++++ src/Sim/GameEventTracker/GameEventTracker.h | 94 ++++++++++ 9 files changed, 327 insertions(+), 45 deletions(-) create mode 100644 src/Sim/GameEventTracker/GameEventTracker.cpp create mode 100644 src/Sim/GameEventTracker/GameEventTracker.h diff --git a/src/Sim/Arena/Arena.cpp b/src/Sim/Arena/Arena.cpp index fab3366c..d265afdb 100644 --- a/src/Sim/Arena/Arena.cpp +++ b/src/Sim/Arena/Arena.cpp @@ -740,34 +740,9 @@ void Arena::Step(int ticksToSimulate) { ball->_FinishPhysicsTick(_mutatorConfig); - if (hasArenaStuff) { - if (_goalScoreCallback.func != NULL) { // Potentially fire goal score callback - - if (gameMode == GameMode::SOCCAR) { - float ballPosY = ball->_rigidBody.m_worldTransform.m_origin.y() * BT_TO_UU; - if (abs(ballPosY) > (RLConst::SOCCAR_GOAL_SCORE_BASE_THRESHOLD_Y + _mutatorConfig.ballRadius)) { - // Orange goal is at positive Y, so if the ball's Y is positive, it's in orange goal and thus blue scored - Team scoringTeam = (ballPosY > 0) ? Team::BLUE : Team::ORANGE; - _goalScoreCallback.func(this, scoringTeam, _goalScoreCallback.userInfo); - } - } else if (gameMode == GameMode::HOOPS) { - - if (ball->_rigidBody.m_worldTransform.m_origin.z() < RLConst::HOOPS_GOAL_SCORE_THRESHOLD_Z * UU_TO_BT) { - constexpr float - SCALE_Y = 0.9f, - OFFSET_Y = 2770.f, - RADIUS_SQ = 716 * 716; - - Vec ballPos = ball->_rigidBody.m_worldTransform.m_origin * BT_TO_UU; - float dx = ballPos.x; - float dy = abs(ballPos.y) * SCALE_Y - OFFSET_Y; - float distSq = dx * dx + dy * dy; - if (distSq < RADIUS_SQ) { - Team scoringTeam = (ballPos.y > 0) ? Team::BLUE : Team::ORANGE; - _goalScoreCallback.func(this, scoringTeam, _goalScoreCallback.userInfo); - } - } - } + if (_goalScoreCallback.func != NULL) { // Potentially fire goal score callback + if (IsBallScored()) { + _goalScoreCallback.func(this, RS_TEAM_FROM_Y(ball->_rigidBody.m_worldTransform.m_origin.y()), _goalScoreCallback.userInfo); } } @@ -775,46 +750,74 @@ void Arena::Step(int ticksToSimulate) { } } -bool Arena::IsBallProbablyGoingIn(float maxTime) const { +bool Arena::IsBallProbablyGoingIn(float maxTime, float extraMargin) const { if (gameMode == GameMode::SOCCAR) { - Vec ballPos = ball->_rigidBody.m_worldTransform.m_origin * UU_TO_BT; - Vec ballVel = ball->_rigidBody.m_linearVelocity * UU_TO_BT; + Vec ballPos = ball->_rigidBody.m_worldTransform.m_origin * BT_TO_UU; + Vec ballVel = ball->_rigidBody.m_linearVelocity * BT_TO_UU; - if (ballVel.y < FLT_EPSILON) + if (abs(ballVel.y) < FLT_EPSILON) return false; float scoreDirSgn = RS_SGN(ballVel.y); - float goalScoreY = (RLConst::SOCCAR_GOAL_SCORE_BASE_THRESHOLD_Y + _mutatorConfig.ballRadius) * scoreDirSgn; - float distToGoal = abs(ballPos.y - scoreDirSgn); + float goalY = RLConst::SOCCAR_GOAL_SCORE_BASE_THRESHOLD_Y * scoreDirSgn; + float distToGoal = abs(ballPos.y - goalY); float timeToGoal = distToGoal / abs(ballVel.y); - + if (timeToGoal > maxTime) return false; - - // Roughly account for drag - timeToGoal *= 1 + powf(1 - _mutatorConfig.ballDrag, timeToGoal); Vec extrapPosWhenScore = ballPos + (ballVel * timeToGoal) + (_mutatorConfig.gravity * timeToGoal * timeToGoal) / 2; - + // From: https://github.com/RLBot/RLBot/wiki/Useful-Game-Values constexpr float APPROX_GOAL_HALF_WIDTH = 892.755f, APPROX_GOAL_HEIGHT = 642.775; - float SCORE_MARGIN = _mutatorConfig.ballRadius * 0.64f; + float scoreMargin = _mutatorConfig.ballRadius * 0.1f + extraMargin; - if (extrapPosWhenScore.z > APPROX_GOAL_HEIGHT + SCORE_MARGIN) + if (extrapPosWhenScore.z > APPROX_GOAL_HEIGHT + scoreMargin) return false; // Too high - if (abs(extrapPosWhenScore.x) > APPROX_GOAL_HALF_WIDTH + SCORE_MARGIN) + if (abs(extrapPosWhenScore.x) > APPROX_GOAL_HALF_WIDTH + scoreMargin) return false; // Too far to the side // Ok it's probably gonna score, or at least be very close return true; } else { - // TODO: Support for hoops - RS_ERR_CLOSE("Arena::IsBallProbablyGoingIn() not supported in non-soccar gamemode"); + // TODO: Support for hoops (not as easy but reasonable), heatseeker (uhhh), and snowday (oh god) + RS_ERR_CLOSE("Arena::IsBallProbablyGoingIn() is only supported for soccar"); + return false; + } +} + +RSAPI bool Arena::IsBallScored() const { + switch (gameMode) { + case GameMode::SOCCAR: + case GameMode::HEATSEEKER: + case GameMode::SNOWDAY: + { + float ballPosY = ball->_rigidBody.m_worldTransform.m_origin.y() * BT_TO_UU; + return abs(ballPosY) > (RLConst::SOCCAR_GOAL_SCORE_BASE_THRESHOLD_Y + _mutatorConfig.ballRadius); + } + case GameMode::HOOPS: + { + if (ball->_rigidBody.m_worldTransform.m_origin.z() < RLConst::HOOPS_GOAL_SCORE_THRESHOLD_Z * UU_TO_BT) { + constexpr float + SCALE_Y = 0.9f, + OFFSET_Y = 2770.f, + RADIUS_SQ = 716 * 716; + + Vec ballPos = ball->_rigidBody.m_worldTransform.m_origin * BT_TO_UU; + float dx = ballPos.x; + float dy = abs(ballPos.y) * SCALE_Y - OFFSET_Y; + float distSq = dx * dx + dy * dy; + return distSq < RADIUS_SQ; + } else { + return false; + } + } + default: return false; } } diff --git a/src/Sim/Arena/Arena.h b/src/Sim/Arena/Arena.h index 14db7a84..1e448f36 100644 --- a/src/Sim/Arena/Arena.h +++ b/src/Sim/Arena/Arena.h @@ -139,7 +139,12 @@ class Arena { // Returns true if the ball is probably going in, does not account for wall or ceiling bounces // NOTE: Purposefully overestimates, just like the real RL's shot prediction // To check which goal it will score in, use the ball's velocity - RSAPI bool IsBallProbablyGoingIn(float maxTime = 2.f) const; + // Margin can be manually adjusted with extraMargin (negative to prevent overestimating) + RSAPI bool IsBallProbablyGoingIn(float maxTime = 2.f, float extraMargin = 0) const; + + // Returns true if the ball is in the net + // Works for all gamemodes (and does nothing in THE_VOID) + RSAPI bool IsBallScored() const; // Free all associated memory RSAPI ~Arena(); diff --git a/src/Sim/Ball/Ball.cpp b/src/Sim/Ball/Ball.cpp index a06c3f54..9b1f41ce 100644 --- a/src/Sim/Ball/Ball.cpp +++ b/src/Sim/Ball/Ball.cpp @@ -41,6 +41,7 @@ void Ball::SetState(const BallState& state) { _rigidBody.setAngularVelocity(state.angVel); _velocityImpulseCache = { 0,0,0 }; + _internalState.updateCounter = 0; } btCollisionShape* MakeBallCollisionShape(GameMode gameMode, const MutatorConfig& mutatorConfig, btVector3& localIntertia) { @@ -137,6 +138,8 @@ void Ball::_FinishPhysicsTick(const MutatorConfig& mutatorConfig) { _rigidBody.m_angularVelocity = Math::RoundVec(_rigidBody.m_angularVelocity, 0.00001); } + + _internalState.updateCounter++; } bool Ball::IsSphere() const { diff --git a/src/Sim/Ball/Ball.h b/src/Sim/Ball/Ball.h index 6ce79ab7..660d883b 100644 --- a/src/Sim/Ball/Ball.h +++ b/src/Sim/Ball/Ball.h @@ -11,6 +11,11 @@ #include "../../../libsrc/bullet3-3.24/BulletCollision/CollisionShapes/btSphereShape.h" struct BallState { + // Incremented every update, reset when SetState() is called +// Used for telling if a stateset occured +// Not serialized + uint64_t updateCounter = 0; + // Position in world space Vec pos = { 0, 0, RLConst::BALL_REST_Z }; diff --git a/src/Sim/BallPredTracker/BallPredTracker.h b/src/Sim/BallPredTracker/BallPredTracker.h index aaf84bb3..315fff21 100644 --- a/src/Sim/BallPredTracker/BallPredTracker.h +++ b/src/Sim/BallPredTracker/BallPredTracker.h @@ -1,6 +1,7 @@ #pragma once #include "../Arena/Arena.h" +// An external tool struct that predicts the ball of a given arena struct BallPredTracker { Arena* ballPredArena; std::vector predData; diff --git a/src/Sim/Car/Car.cpp b/src/Sim/Car/Car.cpp index fe2ada59..022f0bfd 100644 --- a/src/Sim/Car/Car.cpp +++ b/src/Sim/Car/Car.cpp @@ -31,6 +31,7 @@ void Car::SetState(const CarState& state) { _velocityImpulseCache = { 0, 0, 0 }; _internalState = state; + _internalState.updateCounter = 0; } void Car::Demolish(float respawnDelay) { @@ -201,6 +202,8 @@ void Car::_FinishPhysicsTick(const MutatorConfig& mutatorConfig) { _rigidBody.m_angularVelocity = Math::RoundVec(_rigidBody.m_angularVelocity, 0.00001); } + + _internalState.updateCounter++; } void Car::_BulletSetup(GameMode gameMode, btDynamicsWorld* bulletWorld, const MutatorConfig& mutatorConfig) { diff --git a/src/Sim/Car/Car.h b/src/Sim/Car/Car.h index ee51ba8a..d4c13596 100644 --- a/src/Sim/Car/Car.h +++ b/src/Sim/Car/Car.h @@ -12,6 +12,12 @@ #include "../../../src/Sim/btVehicleRL/btVehicleRL.h" struct CarState { + + // Incremented every update, reset when SetState() is called + // Used for telling if a stateset occured + // Not serialized + uint64_t updateCounter = 0; + // Position in world space (UU) Vec pos = { 0, 0, 17 }; @@ -93,6 +99,8 @@ enum class Team : byte { ORANGE = 1 }; +#define RS_TEAM_FROM_Y(y) ((y) < 0 ? Team::BLUE : Team::ORANGE) + class Car { public: // Configuration for this car diff --git a/src/Sim/GameEventTracker/GameEventTracker.cpp b/src/Sim/GameEventTracker/GameEventTracker.cpp new file mode 100644 index 00000000..74ad0216 --- /dev/null +++ b/src/Sim/GameEventTracker/GameEventTracker.cpp @@ -0,0 +1,160 @@ +#include "GameEventTracker.h" + +bool GetShooterPasser(Arena* arena, Team team, Car*& shooterOut, bool findPasser, Car*& passerOut, uint64_t maxShooterTicks, uint64_t maxPasserTicks) { + shooterOut = passerOut = NULL; + // TODO: Instead of looping over cars to find who hit it last, use persistent info + + for (Car* car : arena->_cars) { + if (car->team != team) + continue; + + if (!car->_internalState.ballHitInfo.isValid) + continue; + + if (car->_internalState.ballHitInfo.tickCountWhenHit + maxShooterTicks >= arena->tickCount) { + if (!shooterOut || car->_internalState.ballHitInfo.tickCountWhenHit > shooterOut->_internalState.ballHitInfo.tickCountWhenHit) { + shooterOut = car; + } + } + } + + if (shooterOut && findPasser) { // Look for passer + uint64_t shootTick = shooterOut->_internalState.ballHitInfo.tickCountWhenHit; + + // TODO: Repetitive code + for (Car* car : arena->_cars) { + if (car->team != team) + continue; + + if (!car->_internalState.ballHitInfo.isValid) + continue; + + if (car == shooterOut) + continue; + + if (car->_internalState.ballHitInfo.tickCountWhenHit + maxPasserTicks >= shootTick) { + if (!passerOut || car->_internalState.ballHitInfo.tickCountWhenHit > passerOut->_internalState.ballHitInfo.tickCountWhenHit) { + passerOut = car; + } + } + } + } + + return shooterOut != NULL; +} + +void GameEventTracker::Update(Arena* arena) { + bool scored = arena->IsBallScored(); + + float tickrate = arena->GetTickRate(); + uint64_t ballUpdateCount = arena->ball->_internalState.updateCounter; + + if (ballUpdateCount > _lastBallUpdateCount) { + // Game is continuing + + uint64_t deltaTicks = ballUpdateCount - _lastBallUpdateCount; + + // Time since last update + float deltaTime = deltaTicks * arena->tickTime; + + // Goal event + if (scored && _goalCallback.func && !_ballScoredLast) { + Car* shooter; + Car* passer; + if (GetShooterPasser( + arena, + RS_TEAM_FROM_Y(-arena->ball->_rigidBody.m_worldTransform.m_origin.y()), + shooter, true, passer, + config.goalMaxTouchTime * tickrate, + config.passMaxTouchTime * tickrate + )) { + + _goalCallback.func(arena, shooter, passer, _goalCallback.userInfo); + } + } else { + if (!_ballShot) { // Ball is not currently shot + + if (_shotCooldown > 0) { + _shotCooldown = RS_MAX(_shotCooldown - deltaTime, 0); + // Can't make a shot yet + } else { + + float speedSq = (arena->ball->_rigidBody.m_linearVelocity * BT_TO_UU).length2(); + if (speedSq >= config.shotMinSpeed * config.shotMinSpeed) { + if (arena->IsBallProbablyGoingIn(config.shotMinScoreTime, config.predScoreExtraMargin)) { + Team shooterTeam = RS_TEAM_FROM_Y(-arena->ball->_rigidBody.m_linearVelocity.y()); + + uint64_t shotMinTouchDelayTicks = config.shotTouchMinDelay * tickrate; + + Car* shooter; + Car* passer; + if (GetShooterPasser( + arena, + shooterTeam, + shooter, true, passer, + deltaTicks + shotMinTouchDelayTicks, + config.passMaxTouchTime * tickrate + )) { + + uint64_t ticksSinceHit = arena->tickCount - shooter->_internalState.ballHitInfo.tickCountWhenHit; + if (ticksSinceHit >= shotMinTouchDelayTicks) { + + // This is officially now a shot! + _ballShot = true; + _ballShotGoalTeam = RS_TEAM_FROM_Y(arena->ball->_rigidBody.m_linearVelocity.y()); + _shotCooldown = config.shotEventCooldown; + _shotCallback.func(arena, shooter, passer, _shotCallback.userInfo); + } + } + } + } + } + } else { + // Ball is currently shot + + bool willScore = arena->IsBallProbablyGoingIn(config.shotMinScoreTime, config.predScoreExtraMargin); + if (!willScore) { + // Ball is no longer going in + // Maybe it missed, or maybe it was saved + + Car* saver; + Car* _unused; + if (GetShooterPasser( + arena, + _ballShotGoalTeam, + saver, false, _unused, + deltaTicks, + 0 + )) { + + // A car from the team the ball has just hit the ball + // Since it's no longer scoring, this was a save + _saveCallback.func(arena, saver, _saveCallback.userInfo); + } else { + // It just stopped going in (probably missed) + } + + _ballShot = false; + } + } + } + } else if (ballUpdateCount == _lastBallUpdateCount) { + // Skip this update + return; + } else { + // Ball update count decreased + // Reset persistent info + ResetPersistentInfo(); + } + + _ballScoredLast = scored; + _lastBallUpdateCount = ballUpdateCount; +} + +void GameEventTracker::ResetPersistentInfo() { + _ballScoredLast = false; + _ballShot = false; + _shotCooldown = 0; + + // _ballShotGoalTeam doesn't need to be reset +} diff --git a/src/Sim/GameEventTracker/GameEventTracker.h b/src/Sim/GameEventTracker/GameEventTracker.h new file mode 100644 index 00000000..df815825 --- /dev/null +++ b/src/Sim/GameEventTracker/GameEventTracker.h @@ -0,0 +1,94 @@ +#pragma once +#include "../Arena/Arena.h" + +typedef std::function ShotEventFn; +typedef std::function GoalEventFn; +typedef std::function SaveEventFn; + +struct GameEventTrackerConfig { + // NOTE: These are not the same values as the game, they are just what makes sense to me + // You should probably change them to what you want/need + + // Minimum speed towards net in UU/S to be considered a shot + // For reference: + // 50kph = 1388uu/s + // 82.8kph = 2300uu/s (car max speed) + // 100kph = 2777uu/s + float shotMinSpeed = 1750; + + // Time since the shooting car last touched the ball for a shot to be considered + float shotTouchMinDelay = 0.3f; + + // Added margin for predicting if the ball is scoring or not + // By default it errs on the side of scoring (not quite to the extent of normal RL), but this value can be made negative to reverse these effects + float predScoreExtraMargin = 0; + + // Minimum time between shot events + // Prevents a scenario in which you can quickly farm shots by repeatedly hitting the ball towards the net and blocking it + float shotEventCooldown = 1.0f; + + // Minimum time to score (or hit backwall/post/crossbar/whatever) for a shot to be counted + float shotMinScoreTime = 2.0f; + + // Maximum time between a car hitting the ball and it going in the opposing net for it to be counted as a goal + float goalMaxTouchTime = 4.0f; + + // Maximum time between the touch of the shooting car and the passing car + float passMaxTouchTime = 2.0f; +}; + +#define GAMEEVENTTRACKER_CONFIG_SERIALIZATION_FIELDS \ + + +// An external tool struct that tracks saves, shots, assists, and goals +// When Update() is called and one of these events occurs, the associated callback will be called (if a callback is registered) +// Note that bumps and demos are not tracked as they are already trackable through arena callbacks +struct GameEventTracker { + GameEventTrackerConfig config = {}; + + struct { + ShotEventFn func = NULL; + void* userInfo = NULL; + } _shotCallback; + void SetShotCallback(ShotEventFn callbackFn, void* userInfo = NULL) { + _shotCallback.func = callbackFn; + _shotCallback.userInfo = userInfo; + } + + struct { + GoalEventFn func = NULL; + void* userInfo = NULL; + } _goalCallback; + void SetGoalCallback(GoalEventFn callbackFn, void* userInfo = NULL) { + _goalCallback.func = callbackFn; + _goalCallback.userInfo = userInfo; + } + + struct { + SaveEventFn func = NULL; + void* userInfo = NULL; + } _saveCallback; + void SetSaveCallback(SaveEventFn callbackFn, void* userInfo = NULL) { + _saveCallback.func = callbackFn; + _saveCallback.userInfo = userInfo; + } + + float _shotCooldown = 0; + bool _ballShot = false; + Team _ballShotGoalTeam = {}; + bool _ballScoredLast = false; + + // Used to check if the ball was stateset since last Update() + // If the update count decreased, reset persistent info + // If the update count is the same, the update is skipped + uint64_t _lastBallUpdateCount = 0; + + // Doesn't need to be every tick, but should be called pretty frequently, otherwise events may be missed + // If you're running an ML bot with tick-skip, update it at that interval + RSAPI void Update(Arena* arena); + + // Resets info that is maintained between ticks + // Automatically called from Update() when the ball's state has been set since last update + // Call this whenever you set the arena to a new state if you want to be extra safe + RSAPI void ResetPersistentInfo(); +}; \ No newline at end of file