#include "Player.h"
#include "../ContentResolver.h"
#include "../ILevelHandler.h"
#include "../Events/EventMap.h"
#include "../Tiles/TileMap.h"
#include "../PreferencesCache.h"
#include "SolidObjectBase.h"
#include "Explosion.h"
#include "PlayerCorpse.h"
#include "Environment/Bird.h"
#include "Environment/BonusWarp.h"
#include "Environment/Spring.h"
#include "Enemies/EnemyBase.h"
#include "Enemies/TurtleShell.h"

#include "Weapons/BlasterShot.h"
#include "Weapons/BouncerShot.h"
#include "Weapons/ElectroShot.h"
#include "Weapons/FreezerShot.h"
#include "Weapons/PepperShot.h"
#include "Weapons/RFShot.h"
#include "Weapons/SeekerShot.h"
#include "Weapons/ShieldFireShot.h"
#include "Weapons/ShieldLightningShot.h"
#include "Weapons/ShieldWaterShot.h"
#include "Weapons/ToasterShot.h"
#include "Weapons/TNT.h"
#include "Weapons/Thunderbolt.h"

#include "../../nCine/tracy.h"
#include "../../nCine/Base/Random.h"
#include "../../nCine/Base/FrameTimer.h"
#include "../../nCine/Graphics/RenderQueue.h"

#include <Containers/GrowableArray.h>
#include <Containers/StringConcatenable.h>

using namespace Jazz2::Tiles;

namespace Jazz2::Actors
{
	// Player-specific animations
	static constexpr AnimState Shield = (AnimState)536870928;
	static constexpr AnimState ShieldFire = (AnimState)536870929;
	static constexpr AnimState ShieldLightning = (AnimState)536870931;
	static constexpr AnimState ShieldWater = (AnimState)536870930;
	static constexpr AnimState SugarRush = (AnimState)536870913;
	static constexpr AnimState WeaponFlare = (AnimState)536870950;
	static constexpr AnimState CompositeAnimMask = (AnimState)0xFFF83F60;
	static constexpr AnimState TransformFrogFromJazz = (AnimState)0x60000000;
	static constexpr AnimState TransformFrogFromSpaz = (AnimState)0x60000001;
	static constexpr AnimState TransformFrogFromLori = (AnimState)0x60000002;

	Player::Player()
		:
		_playerIndex(0),
		_playerType(PlayerType::Jazz), _playerTypeOriginal(PlayerType::Jazz),
		_isActivelyPushing(false),
		_wasActivelyPushing(false),
		_pushContactThisFrame(false),
		_controllable(true),
		_controllableExternal(true),
		_controllableTimeout(0.0f),
		_lastExitType(ExitType::None),
		_wasUpPressed(false), _wasDownPressed(false), _wasJumpPressed(false), _wasFirePressed(false), _isRunPressed(false),
		_currentSpecialMove(SpecialMoveType::None),
		_isAttachedToPole(false), _canPushFurther(false),
		_copterFramesLeft(0.0f), _fireFramesLeft(0.0f), _pushFramesLeft(0.0f), _waterCooldownLeft(0.0f),
		_levelExiting(LevelExitingState::None),
		_isFreefall(false), _inWater(false), _isLifting(false), _isSpring(false),
		_inShallowWater(-1),
		_activeModifier(Modifier::None),
		_externalForceCooldown(0.0f),
		_springCooldown(0.0f),
		_inIdleTransition(false), _inLedgeTransition(false), _canDoubleJump(true),
		_carryingObject(nullptr), _stackCarrying(false), _beingStoodOn(false),
		// Per-player recolor; 0 = use the original colors. The local player defaults to the user's profile color;
		// remote players get their color from the network (see MpLevelHandler).
		_furColor(PreferencesCache::PlayerFurColor), _paletteOffset(-1),
		_lives(0), _score(0),
		_inventory{}, _inventoryCheckpoint{},
		_checkpointLight(1.0f),
		_sugarRushLeft(0.0f), _sugarRushStarsTime(0.0f),
		_shieldSpawnTime(ShieldDisabled),
		_gemsTotal{}, _gemsPitch(0),
		_gemsTimer(0.0f),
		_bonusWarpTimer(0.0f),
		_suspendType(SuspendType::None),
		_suspendTime(0.0f),
		_invulnerableTime(0.0f),
		_invulnerableBlinkTime(0.0f),
		_jumpTime(0.0f),
		_idleTime(0.0f),
		_hitFloorTime(5.0f),
		_keepRunningTime(0.0f),
		_lastPoleTime(0.0f),
		_inTubeTime(0.0f),
		_dizzyTime(0.0f),
		_activeShield(ShieldType::None),
		_activeShieldTime(0.0f),
		_weaponFlareTime(0.0f),
		_weaponCooldown(0.0f),
		_weaponAllowed(true),
		_weaponWheelState(WeaponWheelState::Hidden)
	{
	}

	Player::~Player()
	{
		if (_spawnedBird != nullptr) {
			_spawnedBird->FlyAway();
			_spawnedBird = nullptr;
		}
#if defined(WITH_AUDIO)
		if (_copterSound != nullptr) {
			_copterSound->stop();
			_copterSound = nullptr;
		}
		if (_weaponSound != nullptr) {
			_weaponSound->stop();
			_weaponSound = nullptr;
		}
#endif
		// Release the shared palette offset back to the pool (e.g., on disconnect/level end)
		ReleasePaletteOffset();
	}

	const Player::CharacterTraits& Player::GetCharacterTraits(PlayerType type)
	{
		static const CharacterTraits Traits[] = {
			{ "Interactive/PlayerJazz"_s, 1.1f, 5 },
			{ "Interactive/PlayerSpaz"_s, 1.6f, 4 },
			{ "Interactive/PlayerLori"_s, 1.3f, 3 },
			{ "Interactive/PlayerFrog"_s, 1.0f, 0 },
			// Spectate mode and unknown types fall back to Jazz metadata, the player is invisible anyway
			{ "Interactive/PlayerJazz"_s, 1.0f, 0 }
		};

		switch (type) {
			case PlayerType::Jazz: return Traits[0];
			case PlayerType::Spaz: return Traits[1];
			case PlayerType::Lori: return Traits[2];
			case PlayerType::Frog: return Traits[3];
			default: return Traits[4];
		}
	}

	std::uint32_t Player::GetEffectiveFurColor() const
	{
		// The level handler may force a color (multiplayer team modes recolor players to their team color); this
		// applies even when the player picked no custom color of their own.
		std::uint32_t furColor = _levelHandler->GetPlayerFurColor(this, _furColor);

		// Online multiplayer always recolors the local player; local games depend on the "Apply Colors" preference
		// (and the player index for the "first player only" mode).
		if (furColor == 0) {
			return 0;
		}
		if (!_levelHandler->IsLocalSession()) {
			return furColor;
		}
		// A game-mode-forced color (e.g., team coloring) applies to every local player regardless of the cosmetic
		// "Apply Colors" preference, which only governs the player's own chosen color.
		if (_levelHandler->IsPlayerColorForced(this)) {
			return furColor;
		}
		switch (PreferencesCache::PlayerColors) {
			case PlayerColorMode::FirstLocalPlayer: return (_playerIndex == 0 ? furColor : 0);
			case PlayerColorMode::AllLocalPlayers: return furColor;
			default: return 0; // OnlineOnly
		}
	}

	std::int32_t Player::GetPaletteOffset() const
	{
		return _paletteOffset;
	}

	void Player::RefreshColorPalette()
	{
		auto& resolver = ContentResolver::Get();
		if (resolver.IsHeadless()) {
			return;
		}

		std::uint32_t furColor = GetEffectiveFurColor();

		// Recoloring needs indexed sprites (palette index in the red channel); otherwise the PaletteRemap shader
		// would treat the already-baked colors as palette indices and corrupt the sprite, so fall back to normal
		// rendering. If this warns, the player .res animations aren't being loaded indexed.
		bool isIndexed = (furColor != 0 && _metadata != nullptr && !_metadata->Animations.empty() && _metadata->Animations[0].Base != nullptr &&
			(_metadata->Animations[0].Base->Flags & GenericGraphicResourceFlags::Indexed) == GenericGraphicResourceFlags::Indexed);
		if (furColor != 0 && !isIndexed) {
			LOGW("Player sprites are not indexed - recoloring disabled");
		}

		// Acquire the new (shared, reference-counted) palette before releasing the old one, so an unchanged fur color
		// keeps its slot instead of being freed and immediately rebuilt
		std::int32_t newOffset = (isIndexed ? resolver.AcquirePaletteOffset(furColor, _playerType) : -1);
		if (_paletteOffset >= 0) {
			resolver.ReleasePaletteOffset(_paletteOffset);
		}
		_paletteOffset = newOffset;
		_renderer.SetPalette(_paletteOffset);
	}

	void Player::ReleasePaletteOffset()
	{
		if (_paletteOffset >= 0) {
			ContentResolver::Get().ReleasePaletteOffset(_paletteOffset);
			_paletteOffset = -1;
		}
	}

	Task<bool> Player::OnActivatedAsync(const ActorActivationDetails& details)
	{
		_playerTypeOriginal = (PlayerType)details.Params[0];
		_playerType = _playerTypeOriginal;
		_playerIndex = details.Params[1];

		// Load the anim set as indexed ONLY when this player is actually being recolored, so it can be recolored at
		// draw time. Loading indexed without applying a palette would render raw palette indices (glitched sprites).
		bool useIndexed = (GetEffectiveFurColor() != 0);
		// TODO: Spectate mode - loads Jazz metadata for fallback, but player will be invisible
		async_await RequestMetadataAsync(GetCharacterTraits(_playerType).Metadata, useIndexed);

		SetAnimation(AnimState::Fall);

		// Build and bind the per-player recolor palette now that the renderer has a texture
		RefreshColorPalette();

		std::memset(_inventory.WeaponAmmo, 0, sizeof(_inventory.WeaponAmmo));
		std::memset(_inventoryCheckpoint.WeaponAmmo, 0, sizeof(_inventoryCheckpoint.WeaponAmmo));
		std::memset(_inventory.WeaponUpgrades, 0, sizeof(_inventory.WeaponUpgrades));
		std::memset(_inventoryCheckpoint.WeaponUpgrades, 0, sizeof(_inventoryCheckpoint.WeaponUpgrades));

		_inventory.WeaponAmmo[(std::int32_t)WeaponType::Blaster] = UINT16_MAX;
		_inventoryCheckpoint.WeaponAmmo[(std::int32_t)WeaponType::Blaster] = UINT16_MAX;

		if (_playerType == PlayerType::Spectate) {
			// Spectate mode - no collision, no gravity, invisible
			SetState(ActorState::PreserveOnRollback | ActorState::ExcludeSimilar, true);
			SetState(ActorState::CollideWithTilesetReduced | ActorState::CollideWithSolidObjects | ActorState::IsSolidObject, false);
			_health = 0;
			_maxHealth = 0;
			_renderer.setDrawEnabled(false);
			
			// Empty hitbox for spectate mode
			AABB = {};
			AABBInner = {};
		} else {
			SetState(ActorState::PreserveOnRollback | ActorState::CollideWithTilesetReduced | ActorState::CollideWithSolidObjects |
				ActorState::IsSolidObject | ActorState::ExcludeSimilar, true);

			_health = 5;
			_maxHealth = _health;
		}

		_currentWeapon = WeaponType::Blaster;

		_checkpointPos = Vector2f((float)details.Pos.X, (float)details.Pos.Y);
		_checkpointLight = _levelHandler->GetDefaultAmbientLight();
		_trailLastPos = _checkpointPos;

		async_return true;
	}

	bool Player::CanJump() const
	{
		return (GetState(ActorState::CanJump) || _carryingObject != nullptr);
	}

	bool Player::CanBreakSolidObjects() const
	{
		if (_sugarRushLeft > 0.0f) {
			return true;
		}

		if (_currentSpecialMove == SpecialMoveType::Buttstomp && _currentTransition != nullptr) {
			// Buttstomp is probably in starting transition, do nothing yet
			return false;
		}

		return (_currentSpecialMove != SpecialMoveType::None);
	}

	bool Player::CanMoveVertically() const
	{
		return (_inWater || _activeModifier != Modifier::None);
	}

	bool Player::IsContinuousJumpAllowed() const
	{
		return PreferencesCache::EnableContinuousJump;
	}

	bool Player::IsLedgeClimbAllowed() const
	{
		return PreferencesCache::EnableLedgeClimb;
	}

	bool Player::OnTileDeactivated()
	{
		// Player cannot be deactivated
		return false;
	}

	void Player::OnUpdate(float timeMult)
	{
		ZoneScoped;

#if defined(DEATH_DEBUG)
		if (PreferencesCache::AllowCheats && _levelHandler->PlayerActionPressed(this, PlayerAction::ChangeWeapon)) {
			float moveDistance = (_levelHandler->PlayerActionPressed(this, PlayerAction::Run) ? 400.0f : 100.0f);
			if (_levelHandler->PlayerActionHit(this, PlayerAction::Left)) {
				MoveInstantly(Vector2f(-moveDistance, 0.0f), MoveType::Relative | MoveType::Force);
			}
			if (_levelHandler->PlayerActionHit(this, PlayerAction::Right)) {
				MoveInstantly(Vector2f(moveDistance, 0.0f), MoveType::Relative | MoveType::Force);
			}
			if (_levelHandler->PlayerActionHit(this, PlayerAction::Up)) {
				MoveInstantly(Vector2f(0.0f, -moveDistance), MoveType::Relative | MoveType::Force);
			}
			if (_levelHandler->PlayerActionHit(this, PlayerAction::Down)) {
				MoveInstantly(Vector2f(0.0f, moveDistance), MoveType::Relative | MoveType::Force);
			}
		}
#endif

		if DEATH_UNLIKELY(_playerType == PlayerType::Spectate) {
			OnHandleSpectate(timeMult);
			return;
		}

		// Delayed spawning
		if (_lastExitType != ExitType::None) {
			_controllableTimeout -= timeMult;
			if (_controllableTimeout > 0.0f) {
				return;
			}

			bool isFrozen = ((_lastExitType & ExitType::Frozen) == ExitType::Frozen);
			_isFreefall = (isFrozen || CanFreefall());
			SetPlayerTransition(_isFreefall ? AnimState::TransitionWarpOutFreefall : AnimState::TransitionWarpOut, false, true, SpecialMoveType::None, [this, isFrozen]() {
				SetState(ActorState::ApplyGravitation, true);
				if (isFrozen) {
					SetAnimation(AnimState::Freefall);
					Freeze(100.0f);
				} else {
					_controllable = true;
					// UpdateAnimation() was probably skipped in this step, because _controllable was false, so call it here
					UpdateAnimation(0.0f);
				}
			});

			_renderer.setDrawEnabled(true);
			PlayPlayerSfx("WarpOut"_s, 1.0f / _levelHandler->GetPlayers().size());
			_levelHandler->PlayerExecuteRumble(this, "Warp"_s);

			_lastExitType = ExitType::None;
		}

		// In local splitscreen co-op, resolve standing on another player before the physics update, so jump input
		// sees CanJump() == true (online sessions call UpdatePlayerStacking from the MpPlayer subclasses instead,
		// where CanPlayersCollide() is false)
		if (_levelHandler->CanPlayersCollide()) {
			UpdatePlayerStacking(timeMult, /*snap:*/ true);
		}

		bool canJumpPrev = CanJump();
		OnUpdatePhysics(timeMult);

		UpdateAnimation(timeMult);
		CheckSuspendState(timeMult);
		CheckEndOfSpecialMoves(timeMult);

		OnHandleWater();

		bool areaWeaponAllowed = true;
		std::int32_t areaWaterBlock = -1;
		OnHandleAreaEvents(timeMult, areaWeaponAllowed, areaWaterBlock);

		// Shallow Water
		if (areaWaterBlock != -1) {
			if (_inShallowWater == -1) {
				OnWaterSplash(Vector2f(_pos.X, (float)areaWaterBlock), true);
			}

			_inShallowWater = areaWaterBlock;
		} else if (_inShallowWater != -1) {
			OnWaterSplash(Vector2f(_pos.X, (float)_inShallowWater), false);

			_inShallowWater = -1;
		}

		OnUpdateTimers(timeMult);
		OnHandleMovement(timeMult, areaWeaponAllowed, canJumpPrev);

		// Handle weapon switching
		if (((_controllable && _controllableExternal) || !_levelHandler->IsReforged()) && _playerType != PlayerType::Frog) {
			bool isGamepad;
			if (_levelHandler->PlayerActionHit(this, PlayerAction::ChangeWeapon, true, isGamepad)) {
				if (!isGamepad || PreferencesCache::WeaponWheel == WeaponWheelStyle::Disabled) {
					SwitchToNextWeapon();
				}
			} else {
				for (std::uint32_t i = 0; i <= (std::uint32_t)PlayerAction::SwitchToThunderbolt - (std::uint32_t)PlayerAction::SwitchToBlaster; i++) {
					if (_levelHandler->PlayerActionHit(this, (PlayerAction)(i + (std::uint32_t)PlayerAction::SwitchToBlaster))) {
						SwitchToWeaponByIndex(i);
					}
				}
			}
		}
	}

	float Player::GetGravityModifier(float baseGravity, bool isRising) const
	{
		// In Reforged mode keep the engine's symmetric gravity, that jump uses the internal-force model.
		if (_levelHandler->IsReforged()) {
			return baseGravity;
		}

		// Original JJ2: in air the rise decelerates ~3x faster than the fall accelerates, which gives
		// the recognizable "snappy up, floaty down" arc. The original per-tick constants are scaled by (70/60)^2
		// to match this engine's 60 Hz timeMult baseline (the caller then multiplies by timeMult), so the descent
		// runs at the original's real-time acceleration, not ~26% slow.
		if (_pos.Y >= _levelHandler->GetWaterLevel()) {
			return 0.015625f * LegacyVerticalGravityScale;	// 1024/65536 - very weak underwater gravity (slow sink)
		}
		// Rising and falling use different scales: the original's descent accelerates slightly faster than the
		// (height-preserving) ascent slow-down, which matches the snappier initial fall after the jump apex.
		return isRising ? (0.375f * LegacyVerticalGravityScale)	// 3*8192/65536 rising
						: (0.125f * LegacyFallGravityScale);	// 8192/65536 falling
	}

	void Player::OnUpdatePhysics(float timeMult)
	{
		// Force collisions every frame even if player doesn't move
		SetState(ActorState::IsDirty, true);

		// The applied rise cap reproduces the original constant-speed rise for jumps/springs; special moves
		// (uppercut/sidekick) launch via forces and skip it. The cap stays on even in a tube, so a sucker tube
		// ascends slowly like the original - but the raised vertical-speed limit is kept while in the tube and
		// while the launch momentum is still bleeding off, so a strong suck (e.g. -30) keeps rising at the cap
		// for a long time (high) instead of being clamped to 16 the instant the tube ends.
		if (!_levelHandler->IsReforged()) {
			_maxRiseSpeed = (_currentSpecialMove == SpecialMoveType::None ? LegacyRiseSpeedCap : 0.0f);
			_verticalSpeedLimit = ((_inTubeTime > 0.0f || _speed.Y < -16.0f) ? 32.0f : 16.0f);
		}

		// Process level bounds (if not warping)
		if (_currentTransition == nullptr ||
			(_currentTransition->State != AnimState::TransitionWarpIn && _currentTransition->State != AnimState::TransitionWarpInFreefall &&
				_currentTransition->State != AnimState::TransitionWarpOut && _currentTransition->State != AnimState::TransitionWarpOutFreefall)) {
			Vector2f lastPos = _pos;
			Recti levelBounds = _levelHandler->GetLevelBounds();
			if (lastPos.X < levelBounds.X) {
				lastPos.X = float(levelBounds.X);
				_pos = lastPos;
			} else if (lastPos.X > levelBounds.X + levelBounds.W) {
				lastPos.X = float(levelBounds.X + levelBounds.W);
				_pos = lastPos;
			}
		}

		// Reset vertical speed if the position is managed by carrying object
		if (_carryingObject != nullptr) {
			_speed.Y = 0.0f;
			_externalForce.Y = 0.0f;
			_internalForceY = 0.0f;
		}

		PushSolidObjects(timeMult);

		// Custom implementation of `ActorBase::OnUpdate(timeMult)`
		TileCollisionParams params = { TileDestructType::Collapse, _speed.Y >= 0.0f };
		if (_currentSpecialMove == SpecialMoveType::Sidekick || _sugarRushLeft > 0.0f) {
			params.DestructType |= TileDestructType::Special;
		} else if (_currentSpecialMove == SpecialMoveType::Buttstomp || _currentSpecialMove == SpecialMoveType::Uppercut) {
			params.DestructType |= TileDestructType::Special | TileDestructType::VerticalMove;
		}
		if (std::abs(_speed.X) > std::numeric_limits<float>::epsilon() || std::abs(_speed.Y) > std::numeric_limits<float>::epsilon() || _sugarRushLeft > 0.0f) {
			params.DestructType |= TileDestructType::Speed;
			params.Speed = (_sugarRushLeft > 0.0f ? 64.0f : std::max(std::abs(_speed.X), std::abs(_speed.Y)));
		}

		if (timeMult * (std::abs(_speed.X + _externalForce.X) + std::abs(_speed.Y + _externalForce.Y)) > 20.0f) {
			TryStandardMovement(timeMult * 0.5f, params);
			TryStandardMovement(timeMult * 0.5f, params);
		} else {
			TryStandardMovement(timeMult, params);
		}

		if ((GetState() & ActorState::ApplyGravitation) != ActorState::ApplyGravitation) {
			_externalForce.Y = std::min(_externalForce.Y + 0.002f * timeMult, 0.0f);
		}

		// Original JJ2 terminal velocity. Only the downward cap is applied,
		// so the upward jump impulse keeps its momentum.
		if (!_levelHandler->IsReforged() && _speed.Y > 12.0f * LegacyVerticalSpeedScale) {
			_speed.Y = 12.0f * LegacyVerticalSpeedScale;
		}

		if (params.TilesDestroyed > 0) {
			AddScore(params.TilesDestroyed * 50);
			_levelHandler->PlayerExecuteRumble(this, "BreakTile"_s);
		}

		OnUpdateHitbox();

		//UpdateFrozenState(timeMult);
		if (_renderer.AnimPaused) {
			if (_frozenTimeLeft <= 0.0f) {
				_renderer.AnimPaused = false;
				_renderer.Initialize(ActorRendererType::Default);

				for (std::int32_t i = 0; i < 10; i++) {
					Explosion::Create(_levelHandler, Vector3i((std::int32_t)_pos.X, (std::int32_t)_pos.Y, _renderer.layer() + 10), Explosion::Type::IceShrapnel);
				}

				Explosion::Create(_levelHandler, Vector3i((std::int32_t)_pos.X, (std::int32_t)_pos.Y, _renderer.layer() + 90), Explosion::Type::SmokeWhite);

				_levelHandler->PlayCommonSfx("IceBreak"_s, Vector3f(_pos.X, _pos.Y, 0.0f));
				_levelHandler->PlayerExecuteRumble(this, "Hurt"_s);
			} else {
				// Cannot be directly in `ActorBase::HandleFrozenStateChange()` due to bug in `BaseSprite::updateRenderCommand()`,
				// it would be called before `BaseSprite::updateRenderCommand()` but after `SceneNode::transform()`
				_renderer.Initialize(ActorRendererType::FrozenMask);
				_frozenTimeLeft -= timeMult;
			}
		}
	}

	void Player::OnUpdateTimers(float timeMult)
	{
		// Invulnerability
		if (_invulnerableTime > 0.0f) {
			_invulnerableTime -= timeMult;

			if (_invulnerableTime <= 0.0f) {
				SetState(ActorState::IsInvulnerable, false);
				_renderer.setDrawEnabled(true);
				_shieldSpawnTime = ShieldDisabled;
			} else if (_shieldSpawnTime > ShieldDisabled) {
				_shieldSpawnTime -= timeMult;
				if (_shieldSpawnTime <= 0.0f) {
					_shieldSpawnTime += 1.0f;

					auto* tilemap = _levelHandler->TileMap();
					if (tilemap != nullptr) {
						auto* res = _metadata->FindAnimation(Shield);
						if (res != nullptr && res->Base->TextureDiffuse != nullptr) {
							Vector2i texSize = res->Base->TextureDiffuse->GetSize();
							Vector2i size = res->Base->FrameDimensions;

							Tiles::TileMap::DestructibleDebris debris = { };
							debris.Pos = _pos;
							debris.Depth = _renderer.layer() - 2;
							debris.Size = Vector2f((float)size.X, (float)size.Y);
							debris.Speed = Vector2f::Zero;
							debris.Acceleration = Vector2f::Zero;

							debris.Scale = 0.9f;
							debris.ScaleSpeed = -0.014f;
							debris.Alpha = 0.7f;
							debris.AlphaSpeed = -0.016f;

							debris.Time = 160.0f;

							debris.TexScaleX = (size.X / float(texSize.X));
							debris.TexBiasX = 0.0f;
							debris.TexScaleY = (size.Y / float(texSize.Y));
							debris.TexBiasY = 0.0f;

							debris.DiffuseTexture = res->Base->TextureDiffuse.get();
							// Recolor through the palette when the sprite is indexed (-1 = baked/RGBA, behavior unchanged)
							debris.PaletteOffset = (((res->Base->Flags & GenericGraphicResourceFlags::Indexed) == GenericGraphicResourceFlags::Indexed) ? (std::int32_t)res->PaletteOffset : -1);
							debris.Flags = Tiles::TileMap::DebrisFlags::AdditiveBlending;

							tilemap->CreateDebris(debris);
						}
					}
				}
			} else if (_invulnerableBlinkTime >= 0.0f && (_currentTransition == nullptr || _currentTransition->State != AnimState::Hurt)) {
				_invulnerableBlinkTime -= timeMult;
				if (_invulnerableBlinkTime <= 0.0f) {
					_renderer.setDrawEnabled(!_renderer.isDrawEnabled());
					_invulnerableBlinkTime = 3.0f;
				}
			} else {
				_renderer.setDrawEnabled(true);
			}
		}

		if (_controllableTimeout > 0.0f) {
			_controllableTimeout -= timeMult;

			if (_controllableTimeout <= 0.0f) {
				_controllable = true;

				if (_isAttachedToPole) {
					// Something went wrong, detach and try to continue
					// To prevent stucking
					for (std::int32_t i = -1; i > -6; i--) {
						if (MoveInstantly(Vector2f(_speed.X, (float)i), MoveType::Relative)) {
							break;
						}
					}

					SetState(ActorState::ApplyGravitation, true);
					_isAttachedToPole = false;
					_wasActivelyPushing = false;

					_controllableTimeout = 4.0f;
					_lastPoleTime = 10.0f;
				}
			} else {
				_controllable = false;
			}
		}

		if (_jumpTime > 0.0f) {
			_jumpTime -= timeMult;
		}
		if (_externalForceCooldown > 0.0f) {
			_externalForceCooldown -= timeMult;
		}
		if (_springCooldown > 0.0f) {
			_springCooldown -= timeMult;
		}
		if (_weaponCooldown > 0.0f) {
			_weaponCooldown -= timeMult;
		}
		if (_bonusWarpTimer > 0.0f) {
			_bonusWarpTimer -= timeMult;
		}
		if (_lastPoleTime > 0.0f) {
			_lastPoleTime -= timeMult;
		}
		if (_gemsTimer > 0.0f) {
			_gemsTimer -= timeMult;

			if (_gemsTimer <= 0.0f) {
				_gemsPitch = 0;
			}
		}

		if (_waterCooldownLeft > 0.0f) {
			_waterCooldownLeft -= timeMult;
		}

		// Weapons
		if (_fireFramesLeft > 0.0f) {
			_fireFramesLeft -= timeMult;

			if (_fireFramesLeft <= 0.0f) {
				// Play post-fire animation
				if ((_currentAnimation->State & (AnimState::Walk | AnimState::Run | AnimState::Dash | AnimState::Buttstomp | AnimState::Swim | AnimState::Airboard | AnimState::Lift | AnimState::Spring)) == AnimState::Idle &&
					(_currentTransition == nullptr || (_currentTransition->State != AnimState::TransitionRunToIdle && _currentTransition->State != AnimState::TransitionDashToIdle)) &&
					!_isAttachedToPole) {

					if ((_currentAnimation->State & AnimState::Hook) == AnimState::Hook) {
						SetTransition(AnimState::TransitionHookShootToHook, false);
					} else if ((_currentAnimation->State & AnimState::Copter) == AnimState::Copter) {
						SetAnimation(AnimState::Copter);
						SetTransition(AnimState::TransitionCopterShootToCopter, false);
					} else if ((_currentAnimation->State & AnimState::Fall) == AnimState::Fall) {
						SetTransition(AnimState::TransitionFallShootToFall, false);
					} else if ((_currentAnimation->State & AnimState::Crouch) == AnimState::Crouch) {
						SetAnimation(AnimState::Crouch, true);
					} else {
						SetTransition(AnimState::TransitionShootToIdle, false);
					}
				}
			}
		}
		if (_weaponFlareTime > 0.0f) {
			_weaponFlareTime -= timeMult;
		}

		// Dizziness
		if (_dizzyTime > 0.0f) {
			_dizzyTime -= timeMult;
		}

		// Shield
		if (_activeShieldTime > 0.0f) {
			_activeShieldTime -= timeMult;
			if (_activeShieldTime <= 0.0f) {
				_activeShield = ShieldType::None;
			}
		}

		// Sugar Rush
		if (_sugarRushLeft > 0.0f) {
			_sugarRushLeft -= timeMult;

			if (_sugarRushLeft > 0.0f) {
				_sugarRushStarsTime -= timeMult;
				if (_sugarRushStarsTime <= 0.0f) {
					_sugarRushStarsTime = Random().FastFloat(2.0f, 8.0f);

					auto* tilemap = _levelHandler->TileMap();
					if (tilemap != nullptr) {
						auto* res = _metadata->FindAnimation(SugarRush);
						if (res != nullptr && res->Base->TextureDiffuse != nullptr) {
							Vector2i texSize = res->Base->TextureDiffuse->GetSize();
							Vector2i size = res->Base->FrameDimensions;
							Vector2i frameConf = res->Base->FrameConfiguration;
							std::int32_t frame = res->FrameOffset + Random().Next(0, res->FrameCount);
							float speedX = Random().FastFloat(-4.0f, 4.0f);

							Tiles::TileMap::DestructibleDebris debris = { };
							debris.Pos = _pos;
							debris.Depth = _renderer.layer() - 2;
							debris.Size = Vector2f((float)size.X, (float)size.Y);
							debris.Speed = Vector2f(speedX, Random().FastFloat(-4.0f, -2.2f));
							debris.Acceleration = Vector2f(0.0f, 0.2f);

							debris.Scale = Random().FastFloat(0.1f, 0.5f);
							debris.ScaleSpeed = -0.002f;
							debris.Angle = Random().FastFloat(0.0f, fTwoPi);
							debris.AngleSpeed = speedX * 0.04f;
							debris.Alpha = 1.0f;
							debris.AlphaSpeed = -0.018f;

							debris.Time = 160.0f;

							debris.TexScaleX = (size.X / float(texSize.X));
							debris.TexBiasX = ((float)(frame % frameConf.X) / frameConf.X);
							debris.TexScaleY = (size.Y / float(texSize.Y));
							debris.TexBiasY = ((float)(frame / frameConf.X) / frameConf.Y);

							debris.DiffuseTexture = res->Base->TextureDiffuse.get();
							// Recolor through the palette when the sprite is indexed (-1 = baked/RGBA, behavior unchanged)
							debris.PaletteOffset = (((res->Base->Flags & GenericGraphicResourceFlags::Indexed) == GenericGraphicResourceFlags::Indexed) ? (std::int32_t)res->PaletteOffset : -1);

							tilemap->CreateDebris(debris);
						}
					}
				}
			} else {
				_renderer.Initialize(ActorRendererType::Default);
			}
		}

		// Weapon Wheel Transitions
		switch (_weaponWheelState) {
			case WeaponWheelState::Opening:
				if (_renderer.GetRendererType() == ActorRendererType::Default) {
					_renderer.Initialize(ActorRendererType::Outline);
				}
				_weaponWheelState = WeaponWheelState::Visible;
				break;
			case WeaponWheelState::Closing:
				if (_renderer.GetRendererType() == ActorRendererType::Outline) {
					_renderer.Initialize(ActorRendererType::Default);
				}
				_weaponWheelState = WeaponWheelState::Hidden;
				break;
		}

		// Copter
		if (_activeModifier == Modifier::Copter || _activeModifier == Modifier::LizardCopter) {
			_copterFramesLeft -= timeMult;
			if (_copterFramesLeft <= 0.0f) {
				SetModifier(Modifier::None);
			} else if (_activeModifierDecor != nullptr) {
				_activeModifierDecor->MoveInstantly(_pos, MoveType::Absolute | MoveType::Force);
			}
		}

#if defined(WITH_AUDIO)
		if (_copterSound != nullptr) {
			if ((_currentAnimation->State & AnimState::Copter) == AnimState::Copter) {
				_copterSound->setPosition(Vector3f(_pos.X, _pos.Y, 0.8f));
			} else {
				_copterSound->stop();
				_copterSound = nullptr;
			}
		}
#endif

		// Trail
		if (PreferencesCache::ShowPlayerTrails) {
			for (std::int32_t i = 0; i < _trail.size(); i++) {
				auto& part = _trail[i];
				part.Intensity -= timeMult * 0.04f;
				part.Brightness -= timeMult * 0.04f;
				part.RadiusFar -= timeMult * 0.4f;
				if (part.RadiusFar <= 0.0f) {
					_trail.eraseUnordered(i);
					i--;
				}
			}

			if ((_keepRunningTime > 0.0f || _speed.SqrLength() > (_isRunPressed ? 36.0f : 100.0f)) && _inTubeTime <= 0.0f) {
				constexpr float TrailDivision = 10.0f;
				Vector2f trailDelta = (_pos - _trailLastPos);
				std::int32_t trailDistance = (std::int32_t)(trailDelta.Length() / TrailDivision);
				if (trailDistance > 0) {
					trailDelta.Normalize();
					while (trailDistance-- > 0) {
						_trailLastPos += trailDelta * TrailDivision;

						auto& light = _trail.emplace_back();
						light.Pos = _trailLastPos;
						light.Intensity = 0.5f;
						light.Brightness = 0.8f;
						light.RadiusNear = 0.0f;
						light.RadiusFar = 28.0f;
					}
				}
			} else {
				_trailLastPos = _pos;
			}
		}

		// Tube
		if (_inTubeTime > 0.0f) {
			_inTubeTime -= timeMult;

			if (_inTubeTime <= 0.0f) {
				_controllable = true;
				SetState(ActorState::ApplyGravitation | ActorState::CollideWithTileset, true);
			} else {
				// Skip controls, player is not controllable in tube
#if defined(WITH_AUDIO)
				// Weapons are automatically disabled if player is not controllable
				if (_weaponSound != nullptr) {
					_weaponSound->stop();
					_weaponSound = nullptr;
				}
#endif
			}
		}
	}

	void Player::OnHandleMovement(float timeMult, bool areaWeaponAllowed, bool canJumpPrev)
	{
		// Move
		if (PreferencesCache::ToggleRunAction) {
			if (_levelHandler->PlayerActionHit(this, PlayerAction::Run)) {
				_isRunPressed = !_isRunPressed;
			}
		} else {
			_isRunPressed = _levelHandler->PlayerActionPressed(this, PlayerAction::Run);
		}

		if (_health <= 0) {
			return;
		}

		// Original-style ledge hop: running off a ledge at (at least) walking speed gives a small upward boost so
		// the player clears small gaps instead of dropping straight in. Fires once on the ground->air transition,
		// only when not already rising (so deliberate jumps/springs/uppercuts are untouched), and only over a real
		// gap (a probe below finds no ground within ~1.5 tiles - a step/slope down has ground there, so no hop).
		if (!_levelHandler->IsReforged() && canJumpPrev && !CanJump() && _speed.Y >= 0.0f &&
			_controllable && _controllableExternal && !_isLifting && std::abs(_speed.X) >= MaxRunningSpeed &&
			_suspendType == SuspendType::None && _currentSpecialMove == SpecialMoveType::None && _activeModifier == Modifier::None) {
			AABBf gapProbe = AABBf(AABBInner.L + 6.0f, AABBInner.B + 4.0f, AABBInner.R - 6.0f, AABBInner.B + 44.0f);
			TileCollisionParams params = { TileDestructType::None, true };
			if (_levelHandler->IsPositionEmpty(this, gapProbe, params)) {
				_speed.Y = -1.5f * LegacyVerticalSpeedScale;
			}
		}

		HandleHorizontalMovement(timeMult);

		if (_hitFloorTime > 0.0f) {
			_hitFloorTime -= timeMult;
		}

		if (!_controllable || !_controllableExternal || _currentSpecialMove == SpecialMoveType::Buttstomp) {
#if defined(WITH_AUDIO)
			// Weapons are automatically disabled if player is not controllable
			if (_currentWeapon != WeaponType::Thunderbolt || _fireFramesLeft <= 0.0f) {
				if (_weaponSound != nullptr) {
					_weaponSound->stop();
					_weaponSound = nullptr;
				}
			}
#endif
			return;
		}

		if (_inWater || _activeModifier != Modifier::None) {
			HandleWaterAndModifierMovement(timeMult);
		} else {
			HandleLookupAndCrouch(timeMult, canJumpPrev);
			HandleJump(timeMult);
		}

		HandleWeaponFire(areaWeaponAllowed);
	}

	void Player::HandleHorizontalMovement(float timeMult)
	{
		if (_keepRunningTime <= 0.0f) {
			bool canWalk = (_controllable && _controllableExternal && !_isLifting && _suspendType != SuspendType::SwingingVine &&
				(_playerType != PlayerType::Frog || !_levelHandler->PlayerActionPressed(this, PlayerAction::Fire)));

			float playerMovement = _levelHandler->PlayerHorizontalMovement(this);
			float playerMovementVelocity = std::abs(playerMovement);
			if (_currentSpecialMove == SpecialMoveType::Buttstomp) {
				_speed.X = 0.2f * playerMovement;
				if (_isRunPressed) {
					_speed.X *= 2.6f;
				}
			} else if (canWalk && playerMovementVelocity > 0.4f) {
				SetAnimation(_currentAnimation->State & ~(AnimState::Lookup | AnimState::Crouch));

				bool wasFacingLeft = IsFacingLeft();
				bool isFacingLeft;
				if (_dizzyTime > 0.0f) {
					isFacingLeft = (playerMovement > 0.0f);
				} else {
					isFacingLeft = (playerMovement < 0.0f);
				}

				if (isFacingLeft != wasFacingLeft) {
					SetFacingLeft(isFacingLeft);
					// If player changed direction, reset push frames to prevent pushing animation
					_pushFramesLeft = 0.0f;
				}

				_isActivelyPushing = _wasActivelyPushing = true;

				float acceleration = (_levelHandler->IsReforged() ? Acceleration : Acceleration * 2.0f * LegacyGroundAccelScale);
				// Skid (original turn-around): when the player is moving fast and the input flips to the opposite
				// direction, bleed the existing momentum off gently instead of snapping around, so they keep
				// drifting the original way for a few frames before accelerating back.
				if (!_levelHandler->IsReforged()) {
					bool reversing = (isFacingLeft ? (_speed.X > 0.4f) : (_speed.X < -0.4f));
					if (reversing) {
						// Coast in the original direction before turning - even at walking speed, just less than running
						acceleration *= (std::abs(_speed.X) > MaxRunningSpeed * LegacyGroundSpeedScale
							? LegacyRunBrakeScale : LegacyWalkBrakeScale);
					}
				}

				if (_dizzyTime > 0.0f || _playerType == PlayerType::Frog) {
					_speed.X = std::clamp(_speed.X + acceleration * timeMult * (isFacingLeft ? -1 : 1), -MaxDizzySpeed * playerMovementVelocity, MaxDizzySpeed * playerMovementVelocity);
				} else if (_inShallowWater != -1 && _levelHandler->IsReforged() && _playerType != PlayerType::Lori) {
					// Use lower speed in shallow water if Reforged
					// Also, exclude Lori, because she can't ledge climb or double jump (rescue/01_colon1)
					_speed.X = std::clamp(_speed.X + acceleration * timeMult * (isFacingLeft ? -1 : 1), -MaxShallowWaterSpeed * playerMovementVelocity, MaxShallowWaterSpeed * playerMovementVelocity);
				} else {
					if (_suspendType == SuspendType::None && !_inWater && _isRunPressed) {
						// Original JJ2 caps the applied dash speed at 8 px/tick
						float maxDashSpeed = (_levelHandler->IsReforged() ? MaxDashingSpeed : 8.0f * LegacyGroundSpeedScale);
						_speed.X = std::clamp(_speed.X + acceleration * timeMult * (isFacingLeft ? -1 : 1), -maxDashSpeed * playerMovementVelocity, maxDashSpeed * playerMovementVelocity);
					} else if (_suspendType == SuspendType::Vine) {
						if (_wasFirePressed) {
							_speed.X = 0.0f;
						} else {
							// If Run is pressed, player moves faster on vines
							if (_isRunPressed) {
								playerMovementVelocity *= 1.6f;
							}
							_speed.X = std::clamp(_speed.X + acceleration * timeMult * (isFacingLeft ? -1 : 1), -MaxVineSpeed * playerMovementVelocity, MaxVineSpeed * playerMovementVelocity);
						}
					} else if (_suspendType != SuspendType::Hook) {
						// Original JJ2 walk cap is 4 px/tick
						float maxRunSpeed = (_levelHandler->IsReforged() ? MaxRunningSpeed : MaxRunningSpeed * LegacyGroundSpeedScale);
						_speed.X = std::clamp(_speed.X + acceleration * timeMult * (isFacingLeft ? -1 : 1), -maxRunSpeed * playerMovementVelocity, maxRunSpeed * playerMovementVelocity);
					}
				}

				if (CanJump()) {
					_wasUpPressed = _wasDownPressed = false;
				}
			} else if (_inTubeTime <= 0.0f) {
				float absSpeedX = std::abs(_speed.X);
				float deceleration = Deceleration;
				if (!_levelHandler->IsReforged()) {
					deceleration *= LegacyReleaseBrakeScale;
					// Releasing the key stops the player fairly quickly, only walking coasts a little longer. (Pressing
					// the opposite direction is the slow, momentum-keeping case - handled as the reversal skid above.)
					if (absSpeedX <= MaxRunningSpeed * LegacyGroundSpeedScale) {
						deceleration *= LegacyWalkBrakeScale;
					}
				}
				_speed.X = std::max((absSpeedX - deceleration * timeMult), 0.0f) * (_speed.X < 0.0f ? -1.0f : 1.0f);
				_isActivelyPushing = false;

				absSpeedX = std::abs(_speed.X);
				if (absSpeedX > 4.0f) {
					SetFacingLeft(_speed.X < 0.0f);
				} else if (absSpeedX < 0.001f) {
					_wasActivelyPushing = false;
				}
			}
		} else {
			_keepRunningTime -= timeMult;

			_isActivelyPushing = _wasActivelyPushing = true;

			float absSpeedX = std::abs(_speed.X);
			if (absSpeedX > 1.0f) {
				SetFacingLeft(_speed.X < 0.0f);
			} else if (absSpeedX < 1.0f) {
				_keepRunningTime = 0.0f;
			}
		}
	}

	void Player::HandleWaterAndModifierMovement(float timeMult)
	{
		float playerMovement = _levelHandler->PlayerVerticalMovement(this);
		float playerMovementVelocity = std::abs(playerMovement);
		if (playerMovementVelocity > 0.3f) {
			float mult, max;
			switch (_activeModifier) {
				case Modifier::Airboard: mult = (playerMovement > 0 ? 1.0f : -0.2f); max = MaxRunningSpeed; break;
				case Modifier::LizardCopter: mult = (playerMovement > 0 ? 2.0f : -4.0f); max = (playerMovement > 0 ? MaxRunningSpeed : 2.0f * MaxRunningSpeed); break;
				default: mult = (playerMovement > 0 ? 1.0f : -1.0f); max = MaxRunningSpeed; break;
			}

			_speed.Y = std::clamp(_speed.Y + Acceleration * timeMult * mult, -max * playerMovementVelocity, max * playerMovementVelocity);
		} else {
			_speed.Y = std::max((std::abs(_speed.Y) - Deceleration * timeMult), 0.0f) * (_speed.Y < 0.0f ? -1.0f : 1.0f);
		}

		if (_activeModifier == Modifier::LizardCopter && _levelHandler->PlayerActionHit(this, PlayerAction::Jump)) {
			// Allow to jump off the copter
			// TODO: Copter shouldn't diappear immediately
			SetModifier(Modifier::None);
			// Don't trigger buttstomp or copter ears when pressing down
			_wasDownPressed = true;
			_wasJumpPressed = true;
		}
	}

	void Player::HandleLookupAndCrouch(float timeMult, bool canJumpPrev)
	{
		// Look-up
		if (_levelHandler->PlayerActionPressed(this, PlayerAction::Up)) {
			if (!_wasUpPressed && _dizzyTime <= 0.0f) {
				// Check also previous CanJump to avoid animation glitches on Springs
				if (((canJumpPrev && CanJump()) || (_suspendType != SuspendType::None && _suspendType != SuspendType::SwingingVine)) && !_isLifting && std::abs(_speed.X) < std::numeric_limits<float>::epsilon()) {
					_wasUpPressed = true;

					SetAnimation(AnimState::Lookup | (_currentAnimation->State & AnimState::Hook));
				}
			}
		} else if (_wasUpPressed) {
			_wasUpPressed = false;

			SetAnimation(_currentAnimation->State & ~AnimState::Lookup);
		}

		// Crouch / Buttstomp - Uses different bindings whether it's in the air or not
		if (_levelHandler->PlayerActionPressed(this, CanJump() ? PlayerAction::Down : PlayerAction::Buttstomp)) {
			if (_suspendType == SuspendType::SwingingVine) {
				// TODO: Swinging vine
			} else if (_suspendType != SuspendType::None) {
				// Jump off vine/hook
				if (IsContinuousJumpAllowed() || _levelHandler->PlayerActionHit(this, CanJump() ? PlayerAction::Down : PlayerAction::Buttstomp)) {
					_wasDownPressed = true;

					MoveInstantly(Vector2f(0.0f, 4.0f), MoveType::Relative | MoveType::Force);
					_suspendType = SuspendType::None;
					_suspendTime = 4.0f;

					SetState(ActorState::ApplyGravitation, true);
				}
			} else if (_dizzyTime <= 0.0f) {
				// Check also previous CanJump to avoid animation glitches on Springs
				if (canJumpPrev && CanJump()) {
					if (!_isLifting && std::abs(_speed.X) < std::numeric_limits<float>::epsilon()) {
						_wasDownPressed = true;
						if (_fireFramesLeft > 0.0f) {
							SetAnimation(AnimState::Crouch | AnimState::Shoot);
						} else {
							SetAnimation(AnimState::Crouch);
						}
					}
				} else if (!CanJump() && !_wasDownPressed && _playerType != PlayerType::Frog) {
					_wasDownPressed = true;

					_speed.X = 0.0f;
					_speed.Y = 0.0f;
					_internalForceY = 0.0f;
					_externalForce.Y = 0.0f;
					SetState(ActorState::ApplyGravitation, false);
					SetAnimation(AnimState::Buttstomp);
					SetPlayerTransition(AnimState::TransitionButtstompStart, true, false, SpecialMoveType::Buttstomp, [this]() {
						// Original JJ2 buttstomp falls at 10 px/tick
						_speed.Y = (_levelHandler->IsReforged() ? 9.0f : 10.0f * LegacyVerticalSpeedScale);
						SetState(ActorState::ApplyGravitation, true);
						SetAnimation(AnimState::Buttstomp);
						PlaySfx("Buttstomp"_s, 1.0f, 0.8f);
						PlaySfx("Buttstomp2"_s);
					});
				}
			}
		} else if (_wasDownPressed) {
			_wasDownPressed = false;

			SetAnimation(_currentAnimation->State & ~AnimState::Crouch);
		}
	}

	void Player::HandleJump(float timeMult)
	{
		// Jump
		if (_levelHandler->PlayerActionPressed(this, PlayerAction::Jump)) {
			if (!_wasJumpPressed) {
				_wasJumpPressed = true;

				if (_suspendType == SuspendType::None && _jumpTime <= 0.0f) {
					if (_isLifting && CanJump() && _currentSpecialMove == SpecialMoveType::None) {
						SetState(ActorState::CanJump, false);
						SetAnimation(_currentAnimation->State & ~(AnimState::Lookup | AnimState::Crouch));
						PlayPlayerSfx("Jump"_s);
						_carryingObject = nullptr;

						SetState(ActorState::IsSolidObject | ActorState::CollideWithSolidObjects, false);

						_isLifting = false;
						_controllable = false;
						_jumpTime = 12.0f;

						_speed.Y = -3.0f;
						_internalForceY = -0.88f;

						// If we're jumping out from under another player (not a solid object), bump out sideways too,
						// so we arc away and don't just fall straight back onto them into the lift state again
						for (auto* other : _levelHandler->GetPlayers()) {
							if (other != this && other->_stackCarrying && other->_carryingObject == this) {
								_speed.X = (_pos.X <= other->GetPos().X ? -1.0f : 1.0f) * PlayerBumpMinSeparationSpeed;
								break;
							}
						}

						SetTransition(AnimState::TransitionLiftEnd, false, [this]() {
							_controllable = true;
							SetState(ActorState::CollideWithSolidObjects, true);
						});
					} else {
						HandleSpecialJump(timeMult);
					}
				}
			}

			if (_suspendType != SuspendType::None) {
				// Drop off hook/vine
				if (IsContinuousJumpAllowed() || _levelHandler->PlayerActionHit(this, PlayerAction::Jump)) {
					if (_suspendType == SuspendType::SwingingVine) {
						CancelCarryingObject();
						_springCooldown = 30.0f;
					} else {
						MoveInstantly(Vector2(0.0f, -4.0f), MoveType::Relative | MoveType::Force);
					}
					SetState(ActorState::CanJump, true);
					_canDoubleJump = true;
				}
			}

			if (!CanJump()) {
				// Extend copter time
				if (_copterFramesLeft > 0.0f) {
					_copterFramesLeft = 70.0f;
				}
			} else if (_currentSpecialMove == SpecialMoveType::None && _jumpTime <= 0.0f && !_levelHandler->PlayerActionPressed(this, PlayerAction::Down)) {
				// Standard jump
				if (IsContinuousJumpAllowed() || _levelHandler->PlayerActionHit(this, PlayerAction::Jump)) {
					SetState(ActorState::CanJump, false);
					_isFreefall = false;
					SetAnimation(_currentAnimation->State & (~AnimState::Lookup & ~AnimState::Crouch));
					PlayPlayerSfx("Jump"_s);
					_jumpTime = 10.0f;
					_carryingObject = nullptr;

					// Gravitation is sometimes off because of active copter, turn it on again
					SetState(ActorState::ApplyGravitation, true);
					SetState(ActorState::IsSolidObject, false);

					if (_levelHandler->IsReforged()) {
						_speed.Y = -3.6f - std::max(0.0f, (std::abs(_speed.X) - 4.0f) * 0.3f);
						_internalForceY = -1.02f - 0.07f * (1.0f - timeMult);
						if (_playerType == PlayerType::Lori) {
							_speed.Y *= 1.3f;
						}
					} else {
						// Original-style instant jump impulse (-10 reference). The applied rise cap then holds the
						// ascent at a constant speed for the first frames (original feel); vertical scale carries
						// the 70/60 framerate plus the playtest slow-down (height preserved: velocity x s, gravity x s^2).
						_speed.Y = -10.0f * LegacyVerticalSpeedScale - std::max(0.0f, (std::abs(_speed.X) - 4.0f) * 0.3f);
					}
				}
			}
		} else {
			if (_wasJumpPressed) {
				_wasJumpPressed = false;
				if (_levelHandler->IsReforged()) {
					if (_internalForceY < 0.0f) {
						_internalForceY = 0.0f;
					}
				} else {
					// Releasing jump early cuts the ascent (original tap-hop). -4 reference, vertical scale.
					if (_speed.Y < -4.0f * LegacyVerticalSpeedScale) {
						_speed.Y = -4.0f * LegacyVerticalSpeedScale;
					}
				}
			}
		}
	}

	void Player::HandleSpecialJump(float timeMult)
	{
		switch (_playerType) {
			case PlayerType::Jazz: {
				if ((_currentAnimation->State & AnimState::Crouch) == AnimState::Crouch) {
					_controllable = false;
					SetAnimation(AnimState::Uppercut);
					SetPlayerTransition(AnimState::TransitionUppercutA, true, true, SpecialMoveType::Uppercut, [this]() {
						// Non-Reforged launch is boosted to keep the original height against the steeper rise gravity
						_externalForce.Y = (_levelHandler->IsReforged() ? -1.4f : -1.2f * LegacySpecialMoveScale);
						_speed.Y = (_levelHandler->IsReforged() ? -2.0f : -2.0f * LegacySpecialMoveScale);
						SetState(ActorState::CanJump, false);
						SetPlayerTransition(AnimState::TransitionUppercutB, true, true, SpecialMoveType::Uppercut);
					});
				} else {
					if (_speed.Y > 0.01f && !CanJump() && (_currentAnimation->State & (AnimState::Fall | AnimState::Copter)) != AnimState::Idle) {
						SetState(ActorState::ApplyGravitation, false);
						_speed.Y = 1.5f;
						_externalForce.Y = 0.0f;
						if ((_currentAnimation->State & AnimState::Copter) != AnimState::Copter) {
							SetAnimation(AnimState::Copter);
						}
						_copterFramesLeft = 70.0f;
#if defined(WITH_AUDIO)
						if (_copterSound == nullptr) {
							_copterSound = PlaySfx("Copter"_s, 0.6f, 1.5f);
							if (_copterSound != nullptr) {
								_copterSound->setLooping(true);
							}
						}
#endif
					}
				}
				break;
			}
			case PlayerType::Spaz: {
				if ((_currentAnimation->State & AnimState::Crouch) == AnimState::Crouch) {
					_controllable = false;
					_controllableTimeout = (_levelHandler->IsReforged() ? 60.0f : 120.0f);	// Non-Reforged Spaz sidekick travels ~2x as far (dash runs until controllable)
					SetAnimation(AnimState::Uppercut);
					SetPlayerTransition(AnimState::TransitionUppercutA, true, false, SpecialMoveType::Sidekick, [this]() {
						_externalForce.X = 8.0f * (IsFacingLeft() ? -1.0f : 1.0f);
						_speed.X = 14.4f * (IsFacingLeft() ? -1.0f : 1.0f);
						SetState(ActorState::ApplyGravitation, false);
						SetPlayerTransition(AnimState::TransitionUppercutB, true, false, SpecialMoveType::Sidekick);
					});

					PlayPlayerSfx("Sidekick"_s);
				} else {
					if (!CanJump() && _canDoubleJump) {
						_canDoubleJump = false;
						_isFreefall = false;

						_internalForceY = -1.15f - 0.1f * (1.0f - timeMult);
						_speed.Y = -0.6f - std::max(0.0f, (std::abs(_speed.X) - 4.0f) * 0.3f);
						_speed.X = std::clamp(_speed.X * 0.4f, -1.0f, 1.0f);

						PlayPlayerSfx("DoubleJump"_s);

						SetTransition(AnimState::Spring, false);
					}
				}
				break;
			}
			case PlayerType::Lori: {
				if ((_currentAnimation->State & AnimState::Crouch) == AnimState::Crouch) {
					_controllable = false;
					_controllableTimeout = 40.0f;
					SetAnimation(AnimState::Uppercut);
					SetPlayerTransition(AnimState::TransitionUppercutA, true, false, SpecialMoveType::Sidekick, [this]() {
						_externalForce.X = 4.0f * (IsFacingLeft() ? -1.0f : 1.0f);
						_speed.X = 9.3f * (IsFacingLeft() ? -1.0f : 1.0f);
						SetState(ActorState::ApplyGravitation, false);
					});
				} else {
					if (_speed.Y > 0.01f && !CanJump() && (_currentAnimation->State & (AnimState::Fall | AnimState::Copter)) != AnimState::Idle) {
						SetState(ActorState::ApplyGravitation, false);
						_speed.Y = 1.5f;
						_externalForce.Y = 0.0f;
						if ((_currentAnimation->State & AnimState::Copter) != AnimState::Copter) {
							SetAnimation(AnimState::Copter);
						}
						_copterFramesLeft = 70.0f;
#if defined(WITH_AUDIO)
						if (_copterSound == nullptr) {
							_copterSound = PlaySfx("Copter"_s, 0.6f, 1.5f);
							if (_copterSound != nullptr) {
								_copterSound->setLooping(true);
							}
						}
#endif
					}
				}
				break;
			}
		}
	}

	void Player::HandleWeaponFire(bool areaWeaponAllowed)
	{
		// Fire
		bool weaponInUse = false;
		if (_weaponAllowed && areaWeaponAllowed && _levelHandler->PlayerActionPressed(this, PlayerAction::Fire)) {
			if (!_isLifting && _suspendType != SuspendType::SwingingVine && !_canPushFurther) {
				if (_playerType == PlayerType::Frog) {
					if (_currentTransition == nullptr && std::abs(_speed.X) < 0.1f && std::abs(_speed.Y) < 0.1f && std::abs(_externalForce.X) < 0.1f && std::abs(_externalForce.Y) < 0.1f) {
						PlayPlayerSfx("Tongue"_s, 0.8f);

						_controllable = false;
						_controllableTimeout = 120.0f;

						SetTransition(_currentAnimation->State | AnimState::Shoot, false, [this]() {
							_controllable = true;
							_controllableTimeout = 0.0f;
						});
					}
				} else if (_inventory.WeaponAmmo[(std::int32_t)_currentWeapon] != 0) {
					_wasFirePressed = true;

					// Shooting has higher priority than pushing if object can't be moved further anymore
					_pushFramesLeft = 0.0f;

					bool weaponCooledDown = (_weaponCooldown <= 0.0f);
					weaponInUse = FireCurrentWeapon(_currentWeapon);
					if (weaponInUse) {
						if (_currentTransition != nullptr && (_currentTransition->State == AnimState::Spring || _currentTransition->State == AnimState::TransitionShootToIdle)) {
							ForceCancelTransition();
						}

						SetAnimation(_currentAnimation->State | AnimState::Shoot);
						// Rewind the animation, if it should be played only once
						if (weaponCooledDown) {
							if (_currentAnimation->LoopMode == AnimationLoopMode::Once) {
								_renderer.AnimTime = 0.0f;
							}
							auto rumbleEffect = (_currentWeapon == WeaponType::Toaster || _currentWeapon == WeaponType::Thunderbolt ? "FireWeak"_s : "Fire"_s);
							_levelHandler->PlayerExecuteRumble(this, rumbleEffect);
						}

						_fireFramesLeft = 20.0f;
					}
				}
			}
		} else if (_wasFirePressed) {
			_wasFirePressed = false;

			_weaponCooldown = 0.0f;
		}

#if defined(WITH_AUDIO)
		if (_weaponSound != nullptr) {
			if (weaponInUse) {
				_weaponSound->setPosition(Vector3f(_pos.X, _pos.Y, 0.8f));
			} else {
				_weaponSound->stop();
				_weaponSound = nullptr;

				if (_currentWeapon == WeaponType::Thunderbolt) {
					PlayPlayerSfx("WeaponThunderboltEnd"_s, 0.8f);
					_levelHandler->PlayerExecuteRumble(this, "Fire"_s);
				}
			}
		}
#endif
	}

	bool Player::OnDraw(RenderQueue& renderQueue)
	{
		if (_weaponFlareTime > 0.0f && !_inWater && _currentTransition == nullptr) {
			auto* res = _metadata->FindAnimation(WeaponFlare);
			if (res != nullptr && res->Base->TextureDiffuse != nullptr) {
				// When the player is recolored its anims (incl. the flare) are indexed, so the flare must go through
				// the PaletteRemap shader too. Its colors aren't in the fur range, so it still renders yellow.
				bool flareIndexed = ((res->Base->Flags & GenericGraphicResourceFlags::Indexed) == GenericGraphicResourceFlags::Indexed);

				auto& command = _weaponFlareCommand;
				if (command == nullptr) {
					command = std::make_unique<RenderCommand>(RenderCommand::Type::Sprite);
					command->GetMaterial().SetBlendingEnabled(true);
				}

				bool shaderChanged = (flareIndexed
					? command->GetMaterial().SetShader(ContentResolver::Get().GetShader(PrecompiledShader::PaletteRemap))
					: command->GetMaterial().SetShaderProgramType(Material::ShaderProgramType::Sprite));
				if (shaderChanged) {
					command->GetMaterial().ReserveUniformsDataMemory();
					command->GetMaterial().SetBlendingFactors(BlendingFactor::SrcAlpha, BlendingFactor::One);
					//command->GetMaterial().SetBlendingFactors(BlendingFactor::SrcAlpha, BlendingFactor::OneMinusSrcAlpha);
					command->GetGeometry().SetDrawParameters(PrimitiveType::TriangleStrip, 0, 4);

					auto* textureUniform = command->GetMaterial().Uniform(Material::TextureUniformName);
					if (textureUniform && textureUniform->GetIntValue(0) != 0) {
						textureUniform->SetIntValue(0); // GL_TEXTURE0
					}
					if (flareIndexed) {
						auto* paletteUniform = command->GetMaterial().Uniform("uTexturePalette");
						if (paletteUniform != nullptr) {
							paletteUniform->SetIntValue(1); // GL_TEXTURE1
						}
					}
				}

				Vector2i texSize = res->Base->TextureDiffuse->GetSize();
				std::int32_t curAnimFrame = res->FrameOffset + (_weaponFlareFrame % res->FrameCount);
				std::int32_t col = curAnimFrame % res->Base->FrameConfiguration.X;
				std::int32_t row = curAnimFrame / res->Base->FrameConfiguration.X;
				float texScaleX = (float(res->Base->FrameDimensions.X) / float(texSize.X));
				float texBiasX = (float(res->Base->FrameDimensions.X * col) / float(texSize.X));
				float texScaleY = (float(res->Base->FrameDimensions.Y) / float(texSize.Y));
				float texBiasY = (float(res->Base->FrameDimensions.Y * row) / float(texSize.Y));

				float scaleY = std::max(_weaponFlareTime / 8.0f, 0.4f) * GetCharacterTraits(_playerType).WeaponFlareScaleY;

				bool facingLeft = IsFacingLeft();
				bool lookUp = (_currentAnimation->State & AnimState::Lookup) == AnimState::Lookup;
				std::int32_t gunspotOffsetX = (_currentAnimation->Base->Hotspot.X - _currentAnimation->Base->Gunspot.X);
				std::int32_t gunspotOffsetY = (_currentAnimation->Base->Hotspot.Y - _currentAnimation->Base->Gunspot.Y);

				float gunspotPosX, gunspotPosY;
				if (lookUp) {
					gunspotPosX = _pos.X + (gunspotOffsetX) * (facingLeft ? 1 : -1);
					gunspotPosY = _pos.Y - (gunspotOffsetY - 3) - res->Base->FrameDimensions.Y;
				} else {
					gunspotPosX = _pos.X + (gunspotOffsetX - 7) * (facingLeft ? 1 : -1);
					gunspotPosY = _pos.Y - gunspotOffsetY;
					if (facingLeft) {
						texBiasX += texScaleX;
						texScaleX *= -1.0f;
					}
				}

				if (!PreferencesCache::UnalignedViewport) {
					gunspotPosX = std::floor(gunspotPosX);
					gunspotPosY = std::floor(gunspotPosY);
				}

				auto instanceBlock = command->GetInstanceBlock();
				instanceBlock->GetUniform(Material::TexRectUniformName)->SetFloatValue(texScaleX, texBiasX, texScaleY, texBiasY);
				instanceBlock->GetUniform(Material::SpriteSizeUniformName)->SetFloatValue(res->Base->FrameDimensions.X, res->Base->FrameDimensions.Y * scaleY);
				instanceBlock->GetUniform(Material::ColorUniformName)->SetFloatValue(1.0f, 1.0f, 1.0f, 1.8f);

				Matrix4x4f worldMatrix = Matrix4x4f::Translation(gunspotPosX, gunspotPosY, 0.0f);
				if (lookUp) {
					worldMatrix.RotateZ(-fRadAngle90);
				}
				worldMatrix.Translate(res->Base->FrameDimensions.X * -0.5f, res->Base->FrameDimensions.Y * scaleY * -0.5f, 0.0f);
				command->SetTransformation(worldMatrix);
				command->SetLayer(_renderer.layer() + 2);
				command->GetMaterial().SetTexture(*res->Base->TextureDiffuse.get());
				if (flareIndexed) {
					// Use the DEFAULT palette (row 0, palette offset 0) so the flare keeps its original colors even
					// when the player's fur recolor would otherwise overlap the flare's palette indices
					Texture* defaultPalette = ContentResolver::Get().GetPaletteTexture();
					if (defaultPalette != nullptr) {
						command->GetMaterial().SetTexture(1, *defaultPalette);
					}
					RHI::UniformCache* palOffsetUniform = instanceBlock->GetUniform(Material::PaletteOffsetUniformName);
					if (palOffsetUniform != nullptr) {
						palOffsetUniform->SetFloatValue(0.0f);
					}
				}

				renderQueue.AddCommand(command.get());
			}
		}

		DrawShield(renderQueue, _activeShield, _activeShieldTime, _metadata, _levelHandler->GetElapsedFrames(), _pos, _renderer.layer(), _shieldRenderCommands);

		return ActorBase::OnDraw(renderQueue);
	}

	void Player::DrawShield(RenderQueue& renderQueue, ShieldType shieldType, float shieldTime, Metadata* metadata,
		float elapsedFrames, Vector2f pos, std::uint16_t baseLayer, std::unique_ptr<RenderCommand> (&shieldRenderCommands)[2])
	{
		// Local aliases so the drawing code below is shared verbatim by Player::OnDraw (which owns the live state)
		// and by the remote-player path (RemoteActor), which passes its own server-synced copies of the same values
		auto& _shieldRenderCommands = shieldRenderCommands;
		Metadata* const _metadata = metadata;
		const Vector2f _pos = pos;
		const float _activeShieldTime = shieldTime;

		switch (shieldType) {
			case ShieldType::Fire: {
				auto* res = _metadata->FindAnimation(ShieldFire);
				if (res != nullptr && res->Base->TextureDiffuse != nullptr) {
					constexpr float PosMultiplier = 0.003f;
					float frames = elapsedFrames;
					float shieldAlpha = std::min(_activeShieldTime * 0.01f, 1.0f);
					float shieldScale = std::min(_activeShieldTime * 0.016f + 0.6f, 1.0f);
					float shieldSize = 70.0f * shieldScale;

					float shieldPosX = _pos.X - shieldSize * 0.5f;
					float shieldPosY = _pos.Y - shieldSize * 0.5f;

					if (!PreferencesCache::UnalignedViewport) {
						shieldPosX = std::floor(shieldPosX);
						shieldPosY = std::floor(shieldPosY);
					}

					{
						auto& command = _shieldRenderCommands[0];
						if (command == nullptr) {
							command = std::make_unique<RenderCommand>(RenderCommand::Type::Sprite);
							command->GetMaterial().SetBlendingEnabled(true);
						}

						if (command->GetMaterial().SetShader(ContentResolver::Get().GetShader(PrecompiledShader::ShieldFire))) {
							command->GetMaterial().ReserveUniformsDataMemory();
							command->GetMaterial().SetBlendingFactors(BlendingFactor::SrcAlpha, BlendingFactor::OneMinusSrcAlpha);
							command->GetGeometry().SetDrawParameters(PrimitiveType::TriangleStrip, 0, 4);

							auto* textureUniform = command->GetMaterial().Uniform(Material::TextureUniformName);
							if (textureUniform && textureUniform->GetIntValue(0) != 0) {
								textureUniform->SetIntValue(0); // GL_TEXTURE0
							}
						}

						auto instanceBlock = command->GetInstanceBlock();
						instanceBlock->GetUniform(Material::TexRectUniformName)->SetFloatValue(
							frames * -0.008f + _pos.X * PosMultiplier, frames * 0.006f - sinf(frames * 0.006f),
							-sinf(frames * 0.015f), frames * 0.006f + _pos.Y * PosMultiplier);
						instanceBlock->GetUniform(Material::SpriteSizeUniformName)->SetFloatValue(shieldSize, shieldSize);
						instanceBlock->GetUniform(Material::ColorUniformName)->SetFloatValue(2.0f, 2.0f, 0.8f, 0.9f * shieldAlpha);

						command->SetTransformation(Matrix4x4f::Translation(shieldPosX, shieldPosY, 0.0f));
						command->SetLayer(baseLayer - 4);
						command->GetMaterial().SetTexture(*res->Base->TextureDiffuse.get());

						renderQueue.AddCommand(command.get());
					}
					{
						auto& command = _shieldRenderCommands[1];
						if (command == nullptr) {
							command = std::make_unique<RenderCommand>(RenderCommand::Type::Sprite);
							command->GetMaterial().SetBlendingEnabled(true);
						}

						if (command->GetMaterial().SetShader(ContentResolver::Get().GetShader(PrecompiledShader::ShieldFire))) {
							command->GetMaterial().ReserveUniformsDataMemory();
							command->GetMaterial().SetBlendingFactors(BlendingFactor::SrcAlpha, BlendingFactor::OneMinusSrcAlpha);
							command->GetGeometry().SetDrawParameters(PrimitiveType::TriangleStrip, 0, 4);

							auto* textureUniform = command->GetMaterial().Uniform(Material::TextureUniformName);
							if (textureUniform && textureUniform->GetIntValue(0) != 0) {
								textureUniform->SetIntValue(0); // GL_TEXTURE0
							}
						}

						auto instanceBlock = command->GetInstanceBlock();
						instanceBlock->GetUniform(Material::TexRectUniformName)->SetFloatValue(
							frames * 0.006f, sinf(frames * 0.006f) + _pos.Y * PosMultiplier,
							sinf(frames * 0.015f) + _pos.X * PosMultiplier, frames * -0.006f);
						instanceBlock->GetUniform(Material::SpriteSizeUniformName)->SetFloatValue(shieldSize, shieldSize);
						instanceBlock->GetUniform(Material::ColorUniformName)->SetFloatValue(2.0f, 2.0f, 1.0f, 1.0f * shieldAlpha);

						command->SetTransformation(Matrix4x4f::Translation(shieldPosX, shieldPosY, 0.0f));
						command->SetLayer(baseLayer + 4);
						command->GetMaterial().SetTexture(*res->Base->TextureDiffuse.get());

						renderQueue.AddCommand(command.get());
					}
				}
				break;
			}
			case ShieldType::Water: {
				auto* res = _metadata->FindAnimation(ShieldWater);
				if (res != nullptr && res->Base->TextureDiffuse != nullptr) {
					float frames = elapsedFrames;
					float shieldAlpha = std::min(_activeShieldTime * 0.01f, 1.0f);
					float shieldScale = std::min(_activeShieldTime * 0.016f + 0.6f, 1.0f);

					auto& command = _shieldRenderCommands[1];
					if (command == nullptr) {
						command = std::make_unique<RenderCommand>(RenderCommand::Type::Sprite);
						command->GetMaterial().SetBlendingEnabled(true);
					}

					bool shieldIndexed = ((res->Base->Flags & GenericGraphicResourceFlags::Indexed) == GenericGraphicResourceFlags::Indexed);
					if (ContentResolver::Get().ConfigureSpriteShader(*command, shieldIndexed)) {
						// Water shield blends additively
						command->GetMaterial().SetBlendingFactors(BlendingFactor::SrcAlpha, BlendingFactor::One);
					}

					Vector2i texSize = res->Base->TextureDiffuse->GetSize();
					std::int32_t curAnimFrame = res->FrameOffset + ((std::int32_t)(frames * 0.24f) % res->FrameCount);
					std::int32_t col = curAnimFrame % res->Base->FrameConfiguration.X;
					std::int32_t row = curAnimFrame / res->Base->FrameConfiguration.X;
					float texScaleX = (float(res->Base->FrameDimensions.X) / float(texSize.X));
					float texBiasX = (float(res->Base->FrameDimensions.X * col) / float(texSize.X));
					float texScaleY = (float(res->Base->FrameDimensions.Y) / float(texSize.Y));
					float texBiasY = (float(res->Base->FrameDimensions.Y * row) / float(texSize.Y));

					float shieldPosX = _pos.X - res->Base->FrameDimensions.X * shieldScale * 0.5f;
					float shieldPosY = _pos.Y - res->Base->FrameDimensions.Y * shieldScale * 0.5f;

					if (!PreferencesCache::UnalignedViewport) {
						shieldPosX = std::floor(shieldPosX);
						shieldPosY = std::floor(shieldPosY);
					}

					auto instanceBlock = command->GetInstanceBlock();
					instanceBlock->GetUniform(Material::TexRectUniformName)->SetFloatValue(texScaleX, texBiasX, texScaleY, texBiasY);
					instanceBlock->GetUniform(Material::SpriteSizeUniformName)->SetFloatValue(res->Base->FrameDimensions.X * shieldScale, res->Base->FrameDimensions.Y * shieldScale);
					instanceBlock->GetUniform(Material::ColorUniformName)->SetFloatValue(1.0f, 1.0f, 1.0f, shieldAlpha);

					command->SetTransformation(Matrix4x4f::Translation(shieldPosX, shieldPosY, 0.0f));
					command->SetLayer(baseLayer + 4);
					// Use the default palette (offset 0) so the shield keeps its own colors, not the player's fur recolor
					ContentResolver::Get().BindSpritePalette(*command, *res->Base->TextureDiffuse.get(), shieldIndexed, res->PaletteOffset);

					renderQueue.AddCommand(command.get());
				}
				break;
			}
			case ShieldType::Lightning: {
				auto* res = _metadata->FindAnimation(ShieldLightning);
				if (res != nullptr && res->Base->TextureDiffuse != nullptr) {
					constexpr float PosMultiplier = 0.001f;
					float frames = elapsedFrames;
					float shieldAlpha = std::min(_activeShieldTime * 0.01f, 1.0f);
					float shieldScale = std::min(_activeShieldTime * 0.016f + 0.6f, 1.0f);
					float shieldSize = 70.0f * shieldScale + sinf(frames * 0.06f) * 4.0f;

					float shieldPosX = _pos.X - shieldSize * 0.5f;
					float shieldPosY = _pos.Y - shieldSize * 0.5f;

					if (!PreferencesCache::UnalignedViewport) {
						shieldPosX = std::floor(shieldPosX);
						shieldPosY = std::floor(shieldPosY);
					}

					{
						auto& command = _shieldRenderCommands[0];
						if (command == nullptr) {
							command = std::make_unique<RenderCommand>(RenderCommand::Type::Sprite);
							command->GetMaterial().SetBlendingEnabled(true);
						}

						if (command->GetMaterial().SetShader(ContentResolver::Get().GetShader(PrecompiledShader::ShieldLightning))) {
							command->GetMaterial().ReserveUniformsDataMemory();
							command->GetMaterial().SetBlendingFactors(BlendingFactor::SrcAlpha, BlendingFactor::OneMinusSrcAlpha);
							command->GetGeometry().SetDrawParameters(PrimitiveType::TriangleStrip, 0, 4);

							auto* textureUniform = command->GetMaterial().Uniform(Material::TextureUniformName);
							if (textureUniform && textureUniform->GetIntValue(0) != 0) {
								textureUniform->SetIntValue(0); // GL_TEXTURE0
							}
						}

						auto instanceBlock = command->GetInstanceBlock();
						instanceBlock->GetUniform(Material::TexRectUniformName)->SetFloatValue(
							frames * -0.008f + _pos.X * PosMultiplier, frames * 0.006f - sinf(frames * 0.006f) + _pos.Y * PosMultiplier,
							-sinf(frames * 0.015f), frames * 0.006f);
						instanceBlock->GetUniform(Material::SpriteSizeUniformName)->SetFloatValue(shieldSize, shieldSize);
						instanceBlock->GetUniform(Material::ColorUniformName)->SetFloatValue(2.0f, 2.0f, 0.8f, 0.9f * shieldAlpha);

						command->SetTransformation(Matrix4x4f::Translation(shieldPosX, shieldPosY, 0.0f));
						command->SetLayer(baseLayer - 4);
						command->GetMaterial().SetTexture(*res->Base->TextureDiffuse.get());

						renderQueue.AddCommand(command.get());
					}
					{
						auto& command = _shieldRenderCommands[1];
						if (command == nullptr) {
							command = std::make_unique<RenderCommand>(RenderCommand::Type::Sprite);
							command->GetMaterial().SetBlendingEnabled(true);
						}

						if (command->GetMaterial().SetShader(ContentResolver::Get().GetShader(PrecompiledShader::ShieldLightning))) {
							command->GetMaterial().ReserveUniformsDataMemory();
							command->GetMaterial().SetBlendingFactors(BlendingFactor::SrcAlpha, BlendingFactor::OneMinusSrcAlpha);
							command->GetGeometry().SetDrawParameters(PrimitiveType::TriangleStrip, 0, 4);

							auto* textureUniform = command->GetMaterial().Uniform(Material::TextureUniformName);
							if (textureUniform && textureUniform->GetIntValue(0) != 0) {
								textureUniform->SetIntValue(0); // GL_TEXTURE0
							}
						}

						auto* instanceBlock = command->GetInstanceBlock();
						instanceBlock->GetUniform(Material::TexRectUniformName)->SetFloatValue(
							frames * 0.006f + _pos.X * PosMultiplier, sinf(frames * 0.006f) + _pos.Y * PosMultiplier,
							sinf(frames * 0.015f), frames * -0.006f);
						instanceBlock->GetUniform(Material::SpriteSizeUniformName)->SetFloatValue(shieldSize, shieldSize);
						instanceBlock->GetUniform(Material::ColorUniformName)->SetFloatValue(2.0f, 2.0f, 1.0f, shieldAlpha);

						command->SetTransformation(Matrix4x4f::Translation(shieldPosX, shieldPosY, 0.0f));
						command->SetLayer(baseLayer + 4);
						command->GetMaterial().SetTexture(*res->Base->TextureDiffuse.get());

						renderQueue.AddCommand(command.get());
					}
				}
				break;
			}
		}
	}

	void Player::OnEmitLights(SmallVectorImpl<LightEmitter>& lights)
	{
		auto& light = lights.emplace_back();
		light.Pos = _pos;
		light.Intensity = 1.0f;
		if (_sugarRushLeft > 0.0f) {
			light.Brightness = 0.4f;
			light.RadiusNear = 60.0f;
			light.RadiusFar = 180.0f;
		} else {
			light.RadiusNear = 40.0f;
			light.RadiusFar = 110.0f;
		}

		for (std::int32_t i = 0; i < (std::int32_t)_trail.size(); i++) {
			lights.emplace_back(_trail[i]);
		}
	}

	bool Player::OnPerish(ActorBase* collider)
	{
		if (_currentTransition != nullptr && _currentTransition->State == AnimState::TransitionDeath) {
			return false;
		}

		SetState(ActorState::IsInvulnerable, true);

		ForceCancelTransition();

		if (_playerType == PlayerType::Frog) {
			_playerType = _playerTypeOriginal;

			// Load original metadata, indexed only when recolored (must match the renderer's current palette state)
			bool useIndexed = (GetEffectiveFurColor() != 0);
			RequestMetadata(GetCharacterTraits(_playerType).Metadata, useIndexed);

			// Refresh animation state
			AnimState prevState = _currentAnimation->State;
			_currentSpecialMove = SpecialMoveType::None;
			_currentAnimation = nullptr;
			SetAnimation(prevState);

			// Morph to original type with animation and then trigger death
			SetPlayerTransition(AnimState::TransitionFromFrog, false, true, SpecialMoveType::None, [this]() {
				OnPerishInner();
			});
		} else {
			OnPerishInner();
		}

		return false;
	}

	void Player::OnUpdateHitbox()
	{
		if (_playerType == PlayerType::Spectate) {
			AABBInner = {};
			return;
		}

		// The sprite is always located relative to the hotspot.
		// The coldspot is usually located at the ground level of the sprite,
		// but for falling sprites for some reason somewhere above the hotspot instead.
		// It is absolutely important that the position of the hitbox stays constant
		// to the hotspot, though; otherwise getting stuck at walls happens all the time.
		if (_levelHandler->IsReforged()) {
			AABBInner = AABBf(_pos.X - 11.0f, _pos.Y + 8.0f - 18.0f, _pos.X + 11.0f, _pos.Y + 8.0f + 12.0f);
		} else {
			AABBInner = AABBf(_pos.X - 10.0f, _pos.Y + 8.0f - 16.0f, _pos.X + 10.0f, _pos.Y + 8.0f + 12.0f);
		}
	}

	bool Player::OnHandleCollision(ActorBase* other)
	{
		ZoneScoped;

		bool handled = false;
		bool removeSpecialMove = false;
		if (auto* turtleShell = runtime_cast<Enemies::TurtleShell>(other)) {
			if (_currentSpecialMove == SpecialMoveType::Buttstomp && _currentTransition != nullptr && _sugarRushLeft <= 0.0f) {
				// Buttstomp is probably in starting transition, do nothing yet unless sugar rush is active
			} else if (_currentSpecialMove != SpecialMoveType::None || _sugarRushLeft > 0.0f) {
				other->DecreaseHealth(INT32_MAX, this);
				handled = true;

				if ((_currentAnimation->State & AnimState::Buttstomp) == AnimState::Buttstomp) {
					removeSpecialMove = true;
					_speed.Y *= -0.6f;
					SetState(ActorState::CanJump, false);
				}
			}
		} else if (auto* enemy = runtime_cast<Enemies::EnemyBase>(other)) {
			if (_currentSpecialMove == SpecialMoveType::Buttstomp && _currentTransition != nullptr && _sugarRushLeft <= 0.0f) {
				// Buttstomp is probably in starting transition, do nothing yet unless sugar rush or shield is active
			} else if (_currentSpecialMove != SpecialMoveType::None || _sugarRushLeft > 0.0f || (enemy->IsFrozen() && _speed.Length() >= 9.0f)) {
				if (!enemy->IsInvulnerable()) {
					enemy->DecreaseHealth(4, this);
					handled = true;

					Explosion::Create(_levelHandler, Vector3i((std::int32_t)_pos.X, (std::int32_t)_pos.Y, _renderer.layer() + 2), Explosion::Type::Small);

					if (_sugarRushLeft > 0.0f) {
						if (!_inWater && CanJump()) {
							_speed.Y = 3;
							SetState(ActorState::CanJump, false);
							_externalForce.Y = -0.6f;
						}
						_speed.Y *= -0.5f;
					}
					if ((_currentAnimation->State & AnimState::Buttstomp) == AnimState::Buttstomp) {
						removeSpecialMove = true;
						_speed.Y *= -0.6f;
						SetState(ActorState::CanJump, false);
						SetInvulnerability(FrameTimer::FramesPerSecond, InvulnerableType::Transient);
					} else if (_currentSpecialMove != SpecialMoveType::None && enemy->GetHealth() > 0) {
						removeSpecialMove = true;
						_externalForce.X = 0.0f;
						_externalForce.Y = 0.0f;

						if (_currentSpecialMove == SpecialMoveType::Sidekick) {
							_speed.X *= 0.5f;
						}
					}

					if (_currentSpecialMove == SpecialMoveType::None && _sugarRushLeft <= 0.0f && enemy->IsFrozen()) {
						_speed = -_speed;
						if (_speed.Y > -4.0f) {
							_speed.Y = -4.0f;
						}
						SetState(ActorState::CanJump, false);
					}

					_levelHandler->PlayerExecuteRumble(this, "Land"_s);
				}
			} else if (enemy->CanHurtPlayer()) {
				if (!IsInvulnerable()) {
					if (_activeShieldTime > 0.0f) {
						DecreaseShieldTime(5.0f * FrameTimer::FramesPerSecond);
						float invulnerableTime = _levelHandler->GetHurtInvulnerableTime();
						SetInvulnerability(invulnerableTime, InvulnerableType::Blinking);
						PlayPlayerSfx("HurtSoft"_s);
					} else {
						TakeDamage(1, 4 * (_pos.X > enemy->GetPos().X ? 1.0f : -1.0f));
					}
				}
			}
		} else if (auto* spring = runtime_cast<Environment::Spring>(other)) {
			// Collide only with hitbox here
			if (_controllableExternal && (_currentTransition == nullptr || _currentTransition->State != AnimState::TransitionLedgeClimb) && _springCooldown <= 0.0f && spring->AABBInner.Overlaps(AABBInner)) {
				Vector2f force = spring->Activate();
				OnHitSpring(spring->GetPos(), force, spring->KeepSpeedX, spring->KeepSpeedY, removeSpecialMove);
			}

			handled = true;
		} else if (auto* bonusWarp = runtime_cast<Environment::BonusWarp>(other)) {
			if (_currentTransition == nullptr || _currentTransitionCancellable) {
				auto cost = bonusWarp->GetCost();
				if (cost <= _inventory.Coins) {
					_inventory.Coins -= cost;
					bonusWarp->Activate(this);

					// Convert remaing coins to gems and equivalent score
					_inventory.Gems[0] += _inventory.Coins;
					AddScore(_inventory.Coins * 100);
					_inventory.Coins = 0;
				} else if (_bonusWarpTimer <= 0.0f) {
					_levelHandler->HandlePlayerCoins(this, _inventory.Coins, _inventory.Coins);
					PlaySfx("BonusWarpNotEnoughCoins"_s);

					_bonusWarpTimer = 400.0f;
				}
			}

			handled = true;
		} else if (auto* otherPlayer = runtime_cast<Player>(other)) {
			// Local splitscreen co-op: players physically bump each other apart and can stand on one another. Both
			// must be alive (skip a dead/respawning player) and not warping. Vertical contact is left to the stacking
			// resolver (one player landing on the other), so ApplyPlayerBump only separates side-to-side contact here.
			if (_levelHandler->CanPlayersCollide() && _health > 0 && otherPlayer->_health > 0 &&
				(_currentTransition == nullptr ||
				 (_currentTransition->State != AnimState::TransitionWarpIn && _currentTransition->State != AnimState::TransitionWarpInFreefall &&
				  _currentTransition->State != AnimState::TransitionWarpOut && _currentTransition->State != AnimState::TransitionWarpOutFreefall))) {
				ApplyPlayerBump(*otherPlayer, /*stackingEnabled:*/ true);
				// Resolve the pair only once per frame: returning `true` stops LevelHandler from also dispatching the
				// reverse collision (which would bump both players a second time, since ApplyPlayerBump moves both)
				handled = true;
			}
		}

		if (removeSpecialMove) {
			_controllable = true;
			EndDamagingMove();
		}

		return handled;
	}

	void Player::OnHitFloor(float timeMult)
	{
		if (_activeModifier == Modifier::None && (_currentAnimation->State & AnimState::Copter) == AnimState::Copter) {
			_copterFramesLeft = 0.0f;
			SetAnimation(_currentAnimation->State & ~AnimState::Copter);
			if (!_isAttachedToPole) {
				SetState(ActorState::ApplyGravitation, true);
			}
		}

		if (_levelHandler->EventMap()->IsHurting(_pos.X, _pos.Y + 24.0f, Direction::Up)) {
			if (!IsInvulnerable() && _sugarRushLeft <= 0.0f) {
				if (_activeShieldTime > 0.0f) {
					DecreaseShieldTime(5.0f * FrameTimer::FramesPerSecond);
					float invulnerableTime = _levelHandler->GetHurtInvulnerableTime();
					SetInvulnerability(invulnerableTime, InvulnerableType::Blinking);
					PlayPlayerSfx("HurtSoft"_s);
				} else {
					TakeDamage(1, _speed.X * 0.25f);
				}
			}
		} else if (!_inWater && _activeModifier == Modifier::None) {
			if (_hitFloorTime <= 0.0f && !CanJump()) {
				_hitFloorTime = 30.0f;
				PlaySfx("Land"_s, 0.8f);
				if (PreferencesCache::GamepadRumble >= 2) {
					// "Land" effect is enabled only for Strong preset
					_levelHandler->PlayerExecuteRumble(this, "Land"_s);
				}

				if (Random().NextFloat() < 0.6f) {
					Explosion::Create(_levelHandler, Vector3i((std::int32_t)_pos.X, (std::int32_t)_pos.Y + 20, _renderer.layer() - 2), Explosion::Type::TinyDark);
				}
			}
		} else {
			// Prevent stucking with water/airboard
			SetState(ActorState::CanJump, false);
			if (_speed.Y > 0.0f) {
				_speed.Y = 0.0f;
			}
		}

		_canDoubleJump = true;
		_isFreefall = false;

		SetState(ActorState::IsSolidObject, true);
	}

	void Player::OnHitCeiling(float timeMult)
	{
		// Hitting a ceiling must stop the upward push immediately - otherwise a strong launch (e.g. a vertical
		// spring) keeps re-applying its force and the player sticks to the ceiling. (_speed.Y and _internalForceY
		// are already zeroed by the caller; clear the external force too.)
		if (!_levelHandler->IsReforged() && _externalForce.Y < 0.0f) {
			_externalForce.Y = 0.0f;
		}

		if (_levelHandler->EventMap()->IsHurting(_pos.X, _pos.Y - 4.0f, Direction::Down)) {
			if (!IsInvulnerable() && _sugarRushLeft <= 0.0f) {
				if (_activeShieldTime > 0.0f) {
					DecreaseShieldTime(5.0f * FrameTimer::FramesPerSecond);
					float invulnerableTime = _levelHandler->GetHurtInvulnerableTime();
					SetInvulnerability(invulnerableTime, InvulnerableType::Blinking);
					PlayPlayerSfx("HurtSoft"_s);
				} else {
					TakeDamage(1, _speed.X * 0.25f);
				}
			}
		}
	}

	void Player::OnHitWall(float timeMult)
	{
		// Reset speed and show Push animation
		_speed.X = 0.0f;
		_pushFramesLeft = 12.0f;
		_pushContactThisFrame = true;
		_keepRunningTime = 0.0f;

		if (_levelHandler->EventMap()->IsHurting(_pos.X + (_speed.X > 0.0f ? 16.0f : -16.0f), _pos.Y, (_speed.X > 0.0f ? Direction::Left : Direction::Right))) {
			if (!IsInvulnerable() && _sugarRushLeft <= 0.0f) {
				if (_activeShieldTime > 0.0f) {
					DecreaseShieldTime(5.0f * FrameTimer::FramesPerSecond);
					float invulnerableTime = _levelHandler->GetHurtInvulnerableTime();
					SetInvulnerability(invulnerableTime, InvulnerableType::Blinking);
					PlayPlayerSfx("HurtSoft"_s);
				} else {
					TakeDamage(1, _speed.X * 0.25f);
				}
			}
		} else {
			if (IsLedgeClimbAllowed() && _isActivelyPushing && _suspendType == SuspendType::None && _activeModifier == Modifier::None && !CanJump() &&
				!_inWater && _currentSpecialMove == SpecialMoveType::None && (_currentTransition == nullptr || _currentTransition->State != AnimState::TransitionUppercutEnd) &&
				_speed.Y >= -1.0f && _externalForce.Y >= 0.0f && _copterFramesLeft <= 0.0f && _keepRunningTime <= 0.0f && _fireFramesLeft <= 0.0f && _dizzyTime <= 0.0f) {

				// Check if the character supports ledge climbing
				if (_metadata->FindAnimation(AnimState::TransitionLedgeClimb)) {
					constexpr std::int32_t MaxTolerancePixels = 6;

					SetState(ActorState::CollideWithTilesetReduced, false);

					float x = (IsFacingLeft() ? -8.0f : 8.0f);
					AABBf hitbox1 = AABBInner + Vector2f(x, -42.0f - MaxTolerancePixels);				// Empty space to climb to
					AABBf hitbox2 = AABBInner + Vector2f(x, -42.0f + 2.0f);								// Wall below the empty space
					AABBf hitbox3 = AABBInner + Vector2f(x, -42.0f + 2.0f + 24.0f);						// Wall between the player and the wall above (vertically)
					AABBf hitbox4 = AABBInner + Vector2f(x, 20.0f);										// Wall below the player
					AABBf hitbox5 = AABBf(AABBInner.L + 2, hitbox1.T, AABBInner.R - 2, AABBInner.B);	// Player can't climb through walls
					TileCollisionParams params = { TileDestructType::None, false };
					if (_levelHandler->IsPositionEmpty(this, hitbox1, params) &&
						!_levelHandler->IsPositionEmpty(this, hitbox2, params) &&
						!_levelHandler->IsPositionEmpty(this, hitbox3, params) &&
						!_levelHandler->IsPositionEmpty(this, hitbox4, params) &&
						 _levelHandler->IsPositionEmpty(this, hitbox5, params)) {

						uint8_t* wallParams;
						if (_levelHandler->EventMap()->GetEventByPosition(IsFacingLeft() ? hitbox2.L : hitbox2.R, hitbox2.B, &wallParams) != EventType::ModifierNoClimb) {
							// Move the player upwards, if it is in tolerance, so the animation will look better
							AABBf aabb = AABBInner + Vector2f(x, -42.0f);
							for (std::int32_t y = 0; y >= -MaxTolerancePixels; y -= 1) {
								if (_levelHandler->IsPositionEmpty(this, aabb, params)) {
									MoveInstantly(Vector2f(0.0f, (float)y), MoveType::Relative | MoveType::Force, params);
									break;
								}
								aabb.T -= 1.0f;
								aabb.B -= 1.0f;
							}

							// Prepare the player for animation
							_controllable = false;
							SetState(ActorState::ApplyGravitation | ActorState::CollideWithTileset | ActorState::CollideWithSolidObjects, false);

							_speed.X = 0.0f;
							_speed.Y = -1.4f;
							if (timeMult < 1.0f) {
								_speed.Y += (1.0f - timeMult) * 0.4f;
							}

							_externalForce.X = 0.0f;
							_externalForce.Y = 0.0f;
							_internalForceY = 0.0f;
							_pushFramesLeft = 0.0f;
							_fireFramesLeft = 0.0f;
							_copterFramesLeft = 0.0f;

							// Stick the player to wall
							MoveInstantly(Vector2f(IsFacingLeft() ? -6.0f : 6.0f, 0.0f), MoveType::Relative | MoveType::Force, params);

							SetAnimation(AnimState::Idle);
							SetTransition(AnimState::TransitionLedgeClimb, false, [this]() {
								// Reset the player to normal state
								_controllable = true;
								SetState(ActorState::CanJump | ActorState::ApplyGravitation | ActorState::CollideWithTileset | ActorState::CollideWithSolidObjects, true);
								_pushFramesLeft = 0.0f;
								_fireFramesLeft = 0.0f;
								_copterFramesLeft = 0.0f;
								_hitFloorTime = 60.0f;

								_speed.Y = 0.0f;

								// Move it far from the ledge
								TileCollisionParams params = { TileDestructType::None, false };
								MoveInstantly(Vector2f(IsFacingLeft() ? -4.0f : 4.0f, 0.0f), MoveType::Relative, params);

								// Move the player upwards, so it will not be stuck in the wall
								for (float y = -1.0f; y > -24.0f; y -= 1.0f) {
									if (MoveInstantly(Vector2f(0.0f, y), MoveType::Relative, params)) {
										break;
									}
								}
							});
						}
					}

					SetState(ActorState::CollideWithTilesetReduced, true);
				}
			}
		}
	}

	void Player::OnPushSolidObject(float timeMult, float pushSpeedX)
	{
		if (std::abs(pushSpeedX) > 0.0f) {
			_speed.X = pushSpeedX * 1.2f * timeMult;
			_pushFramesLeft = 12.0f;
			_pushContactThisFrame = true;
			_fireFramesLeft = 0.0f;
			_canPushFurther = true;
		} else {
			_canPushFurther = false;
		}
	}

	void Player::OnHitSpring(Vector2f pos, Vector2f force, bool keepSpeedX, bool keepSpeedY, bool& removeSpecialMove)
	{
		std::int32_t sign = ((force.X + force.Y) > std::numeric_limits<float>::epsilon() ? 1 : -1);
		if (std::abs(force.X) > 0.0f) {
			MoveInstantly(Vector2f(_pos.X, (_pos.Y + pos.Y) * 0.5f), MoveType::Absolute);

			_copterFramesLeft = 0.0f;
			//speedX = force.X;
			// Match the original real-time launch speed (70/60 baseline) when not Reforged
			float springScaleX = (_levelHandler->IsReforged() ? 1.0f : LegacyFrameRateScale * 0.95f);
			_speed.X = (1.0f + std::abs(force.X)) * sign * springScaleX;
			_externalForce.X = force.X * 0.6f * springScaleX;
			_springCooldown = 10.0f;
			SetState(ActorState::CanJump, false);

			_wasActivelyPushing = false;
			_keepRunningTime = (_levelHandler->IsReforged() ? 100.0f : 80.0f);

			if (!keepSpeedY) {
				_speed.Y = 0.0f;
				_externalForce.Y = 0.0f;
			}

			if (_currentSpecialMove != SpecialMoveType::Sidekick) {
				if (_inIdleTransition) {
					_inIdleTransition = false;
					CancelTransition();
				}

				removeSpecialMove = true;
				_controllableTimeout = 2.0f;
				if (_activeModifier == Modifier::None) {
					SetAnimation(_currentAnimation->State & ~(AnimState::Crouch | AnimState::Lookup | AnimState::Buttstomp));
					SetPlayerTransition(AnimState::Dash | AnimState::Jump, true, false, SpecialMoveType::None);
				}
			}

			_levelHandler->PlayerExecuteRumble(this, "Spring"_s);
		} else if (std::abs(force.Y) > 0.0f) {
			MoveInstantly(Vector2f(lerp(_pos.X, pos.X, 0.3f), _pos.Y), MoveType::Absolute);

			if (_activeModifier == Modifier::None && _copterFramesLeft > 0.0f) {
				_copterFramesLeft = 0.0f;
				SetAnimation(_currentAnimation->State & ~AnimState::Copter);
				SetState(ActorState::ApplyGravitation, true);
			}

			// The applied rise cap (_maxRiseSpeed) holds the launch at a constant speed for the first frames like
			// the original, so the big gravity-compensation boost is gone.
			float springScaleY = (_levelHandler->IsReforged() ? 1.0f : LegacyFrameRateScale * 1.18f);
			_speed.Y = (4.0f + std::abs(force.Y)) * sign * springScaleY;
			if (!GetState(ActorState::ApplyGravitation)) {
				_externalForce.Y = force.Y * 0.14f;
			} else if (_levelHandler->IsReforged()) {
				_externalForce.Y = force.Y;
			} else {
				_externalForce.Y = force.Y * 0.8f * springScaleY;
			}
			_springCooldown = 10.0f;
			SetState(ActorState::CanJump, false);

			if (!keepSpeedX) {
				_speed.X = 0.0f;
				_externalForce.X = 0.0f;
				_keepRunningTime = 0.0f;
			}

			if (_inIdleTransition) {
				_inIdleTransition = false;
				CancelTransition();
			}

			if (sign > 0) {
				removeSpecialMove = false;
				if (_activeModifier == Modifier::None) {
					_currentSpecialMove = SpecialMoveType::Buttstomp;
					SetAnimation(AnimState::Buttstomp);
				}
			} else {
				removeSpecialMove = true;
				_isSpring = true;
				if (_activeModifier == Modifier::None) {
					SetAnimation(_currentAnimation->State & ~(AnimState::Crouch | AnimState::Lookup));
				}
			}

			_levelHandler->PlayerExecuteRumble(this, "Spring"_s);
			PlayPlayerSfx("Spring"_s);
		}
	}

	void Player::OnWaterSplash(Vector2f pos, bool inwards)
	{
		Explosion::Create(_levelHandler, Vector3i((std::int32_t)pos.X, (std::int32_t)pos.Y, _renderer.layer() + 2), Explosion::Type::WaterSplash);
		_levelHandler->PlayCommonSfx("WaterSplash"_s, Vector3f(pos.X, pos.Y, 0.0f), inwards ? 0.7f : 1.0f, 0.5f);
	}

	void Player::UpdateAnimation(float timeMult)
	{
		if (!_controllable) {
			return;
		}

		AnimState oldState = _currentAnimation->State;
		AnimState newState;
		if (_inWater) {
			newState = AnimState::Swim;
		} else if (_activeModifier == Modifier::Airboard) {
			newState = AnimState::Airboard;
		} else if (_activeModifier == Modifier::Copter) {
			newState = AnimState::Copter;
		} else if (_activeModifier == Modifier::LizardCopter) {
			newState = AnimState::Hook;
		} else if (_suspendType == SuspendType::SwingingVine) {
			newState = AnimState::Swing;
		} else if (_isLifting || (_beingStoodOn && CanJump() && std::abs(_speed.X) < 1.0f && std::abs(_speed.Y) < 1.0f)) {
			// `_isLifting` is the movement-restricting lift (solid object, or a player in local co-op); `_beingStoodOn`
			// is the cosmetic online case - only show its pose while grounded and still, so a moving (unrestricted)
			// player doesn't freeze in the lift pose
			newState = AnimState::Lift;
		} else if (CanJump() && _isActivelyPushing && _pushFramesLeft > 0.0f && _keepRunningTime <= 0.0f && _fireFramesLeft <= 0.0f &&
			(_pushContactThisFrame || std::abs(_speed.X) <= MaxPushingSpeed)) {
			// The grace timer (_pushFramesLeft) bridges brief frames where the contact probe misses an object that's
			// still being pushed, so the pose doesn't flicker. But only keep it while still genuinely pushing: in
			// contact this step, or still held to the slow push speed. Once the player breaks free and accelerates
			// away, end it at once instead of letting the timer linger the push pose over open ground.
			newState = AnimState::Push;

			if (_inIdleTransition) {
				_inIdleTransition = false;
				CancelTransition();
			}
		} else {
			// Only certain ones don't need to be preserved from earlier state, others should be set as expected
			AnimState composite = (_currentAnimation->State & CompositeAnimMask);

			if (_isActivelyPushing == _wasActivelyPushing || !_levelHandler->IsReforged()) {
				float absSpeedX = std::abs(_speed.X);
				// Threshold must track the actual walk cap, otherwise the higher non-Reforged walk speed would
				// keep triggering the Dash animation while merely walking.
				float dashAnimThreshold = (_levelHandler->IsReforged() ? MaxRunningSpeed : MaxRunningSpeed * LegacyGroundSpeedScale);
				if (absSpeedX > dashAnimThreshold) {
					composite |= AnimState::Dash;
				} else if (_keepRunningTime > 0.0f) {
					composite |= AnimState::Run;
				} else if (absSpeedX > (_fireFramesLeft > 0.0f ? 1.0f : 0.0f)) {	// Shooting needs higher threshold to fix pushing into a wall
					composite |= AnimState::Walk;
				}

				if (_inIdleTransition) {
					_inIdleTransition = false;
					CancelTransition();
				}
			}

			if (_fireFramesLeft > 0.0f) {
				composite |= AnimState::Shoot;
			}

			if (_suspendType != SuspendType::None) {
				composite |= AnimState::Hook;
			} else {
				if (CanJump()) {
					// Grounded, no vertical speed
					if (_dizzyTime > 0.0f) {
						composite |= AnimState::Dizzy;
					}
				} else if (_speed.Y < 0.0f) {
					// Jumping, ver. speed is negative
					if (_isSpring) {
						composite |= AnimState::Spring;
					} else {
						composite |= AnimState::Jump;
					}

				} else if (_isFreefall) {
					// Free falling, ver. speed is positive
					composite |= AnimState::Freefall;
					_isSpring = false;
				} else {
					// Falling, ver. speed is positive
					composite |= AnimState::Fall;
					_isSpring = false;
				}
			}

			newState = composite;
		}

		if (newState == AnimState::Idle) {
			if (_idleTime > 600.0f) {
				_idleTime = 0.0f;

				if (_currentTransition == nullptr) {
					constexpr StringView IdleBored[] = {
						"IdleBored1"_s, "IdleBored2"_s, "IdleBored3"_s, "IdleBored4"_s, "IdleBored5"_s
					};
					std::int32_t maxIdx = GetCharacterTraits(_playerType).IdleBoredAnimCount;
					if (maxIdx > 0) {
						std::int32_t selectedIdx = Random().Fast(0, maxIdx);
						if (SetTransition((AnimState)(536870944 + selectedIdx), true)) {
							PlayPlayerSfx(IdleBored[selectedIdx]);
						}
					}
				}
			} else {
				_idleTime += timeMult;
			}
		} else {
			_idleTime = 0.0f;
		}

		SetAnimation(newState);

		if (!_isAttachedToPole) {
			switch (oldState) {
				case AnimState::Walk:
					// The skid/brake transition isn't part of the original game, so skip it when not Reforged
					if (newState == AnimState::Dash) {
						SetTransition(AnimState::TransitionRunToDash, true);
					} else if (newState == AnimState::Idle && _levelHandler->IsReforged()) {
						_inIdleTransition = true;
						SetTransition(AnimState::TransitionRunToIdle, true, [this]() {
							_inIdleTransition = false;
						});
					}
					break;
				case AnimState::Dash:
					if (newState == AnimState::Idle && _levelHandler->IsReforged()) {
						_inIdleTransition = true;
						SetTransition(AnimState::TransitionDashToIdle, true, [this]() {
							if (_inIdleTransition) {
								SetTransition(AnimState::TransitionRunToIdle, true, [this]() {
									_inIdleTransition = false;
								});
							}
						});
					}
					break;
				case AnimState::Fall:
				case AnimState::Freefall:
					if (newState == AnimState::Idle) {
						SetTransition(AnimState::TransitionFallToIdle, true);
					}
					break;
				case AnimState::Idle:
					if (newState == AnimState::Jump) {
						SetTransition(AnimState::TransitionIdleToJump, true);
					} else if (newState != AnimState::Idle) {
						_inLedgeTransition = false;
						if (_currentTransition != nullptr && _currentTransition->State == AnimState::TransitionLedge) {
							CancelTransition();
						}
					} else if (!_inLedgeTransition && _carryingObject == nullptr && std::abs(_speed.X) < 1.0f && std::abs(_speed.Y) < 1.0f) {
						AABBf aabbL = AABBf(AABBInner.L + 2, AABBInner.B - 10, AABBInner.L + 4, AABBInner.B + 28);
						AABBf aabbR = AABBf(AABBInner.R - 4, AABBInner.B - 10, AABBInner.R - 2, AABBInner.B + 28);
						TileCollisionParams params = { TileDestructType::None, true };
						if (IsFacingLeft()
							? (_levelHandler->IsPositionEmpty(this, aabbL, params) && !_levelHandler->IsPositionEmpty(this, aabbR, params))
							: (!_levelHandler->IsPositionEmpty(this, aabbL, params) && _levelHandler->IsPositionEmpty(this, aabbR, params))) {

							_inLedgeTransition = true;
							if (_playerType == PlayerType::Spaz) {
								// Spaz's and Lori's animation should be continual, so reset it in callback
								SetTransition(AnimState::TransitionLedge, true, [this]() {
									_inLedgeTransition = false;
								});
							} else {
								SetTransition(AnimState::TransitionLedge, true);
							}

							PlayPlayerSfx("Ledge"_s);
						}
					}
					break;
			}
		}
	}

	void Player::PushSolidObjects(float timeMult)
	{
		// Ground truth for "pushing this step" - re-established below (and in OnHitWall during the move that follows)
		_pushContactThisFrame = false;

		if (_pushFramesLeft > 0.0f) {
			_pushFramesLeft -= timeMult;
		} else {
			_canPushFurther = false;
		}

		if (CanJump() && _controllable && _controllableExternal && _isActivelyPushing /*&& std::abs(_speed.X) > 0.0f*/) {
			float offset = (IsFacingLeft() ? -4.0f : 4.0f);
			AABBf hitbox = { AABBInner.L + offset, AABBInner.T + 8.0f, AABBInner.R + offset, AABBInner.B - 14.0f };
			TileCollisionParams params = { TileDestructType::None, false };
			ActorBase* collider;
			if (!_levelHandler->IsPositionEmpty(this, hitbox, params, &collider)) {
				if (auto* solidObject = runtime_cast<SolidObjectBase>(collider)) {
					SetState(ActorState::IsSolidObject, false);
					float pushSpeedX = solidObject->Push(_speed.X < 0, timeMult);
					OnPushSolidObject(timeMult, pushSpeedX);
					SetState(ActorState::IsSolidObject, true);
				}
			}
		} else {
			// Lift state: a solid object resting on our head, or another player standing on top of us. The lift
			// animation is shown in both cases (online it propagates to other clients via the normal animation sync),
			// but only the local co-op case immobilizes us, exactly like a solid object - so the player has to jump
			// to get out of it. Online, being stood on is purely cosmetic and never restricts movement.
			bool liftedBySolid = false;
			if (GetState(ActorState::IsSolidObject)) {
				AABBf aabb = AABBf(AABBInner.L, AABBInner.T - 20.0f, AABBInner.R, AABBInner.T + 6.0f);
				TileCollisionParams params = { TileDestructType::None, false };
				ActorBase* collider;
				ActorState prevState = GetState();
				SetState(ActorState::CollideWithTileset, false);
				if (!_levelHandler->IsPositionEmpty(this, aabb, params, &collider)) {
					if (auto* solidObject = runtime_cast<SolidObjectBase>(collider)) {
						liftedBySolid = (AABBInner.T >= solidObject->AABBInner.T);
					}
				}
				SetState(prevState);
			}

			// Detecting who stands on us by scanning the player list only works where we simulate them: locally
			// (splitscreen) and on the server (all players). On an online client, our own player is predicted and
			// can't see a remote player standing on it, so the server tells us via PlayerPropertyType::BeingStoodOn -
			// in that case leave `_beingStoodOn` alone (it's driven by the packet, not recomputed here).
			bool authoritative = (_levelHandler->IsServer() || _levelHandler->CanPlayersCollide());
			bool stoodOnByPlayer = (authoritative && IsBeingStoodOnByPlayer());
			// Only local splitscreen co-op turns being stood on into a real (movement-restricting) lift
			bool stoodOnRestricts = (stoodOnByPlayer && _levelHandler->CanPlayersCollide());
			if (liftedBySolid || stoodOnRestricts) {
				if (!_isLifting && std::abs(_speed.Y) < 1.0f) {
					_isLifting = true;
					SetTransition(AnimState::TransitionLiftStart, true);
				}
			} else {
				_isLifting = false;
			}
			// Cosmetic-only "being stood on" (online): drives the lift pose without restricting movement
			if (authoritative) {
				_beingStoodOn = (stoodOnByPlayer && !stoodOnRestricts);
			}
		}
	}

	void Player::CheckEndOfSpecialMoves(float timeMult)
	{
		// Buttstomp
		if (_currentSpecialMove == SpecialMoveType::Buttstomp && (CanJump() || _suspendType != SuspendType::None)) {
			EndDamagingMove();
			if (_suspendType == SuspendType::None && !_isSpring) {
				std::int32_t tx = (std::int32_t)_pos.X / 32;
				std::int32_t ty = ((std::int32_t)_pos.Y + 24) / 32;

				std::uint8_t* eventParams;
				if (_levelHandler->EventMap()->GetEventByPosition(tx, ty, &eventParams) == EventType::GemStomp) {
					_levelHandler->EventMap()->StoreTileEvent(tx, ty, EventType::Empty);

					for (std::int32_t i = 0; i < 8; i++) {
						float fx = Random().NextFloat(-12.0f, 12.0f);
						float fy = Random().NextFloat(-2.0f, 0.2f);

						std::uint8_t spawnParams[Events::EventSpawner::SpawnParamsSize] = { 0, 0x01 | 0x04 };
						std::shared_ptr<ActorBase> actor = _levelHandler->EventSpawner()->SpawnEvent(EventType::Gem, spawnParams, ActorState::None, Vector3i((std::int32_t)(_pos.X + fx * 2.0f), (std::int32_t)(_pos.Y + fy * 4.0f), _renderer.layer() - 10));
						if (actor != nullptr) {
							actor->AddExternalForce(fx, fy);
							_levelHandler->AddActor(actor);
						}
					}
				}

				if (_levelHandler->IsReforged()) {
					_controllable = false;
					SetTransition(AnimState::TransitionButtstompEnd, false, [this]() {
						_controllable = true;
					});
				} else {
					SetTransition(AnimState::TransitionButtstompEnd, true);
				}
			} else {
				_controllable = true;
			}
		}

		// Uppercut
		if (_currentSpecialMove == SpecialMoveType::Uppercut && _currentTransition == nullptr && ((_currentAnimation->State & AnimState::Uppercut) == AnimState::Uppercut) && _speed.Y > -2) {
			EndDamagingMove();
		}

		// Sidekick
		if (_currentSpecialMove == SpecialMoveType::Sidekick && _currentTransition == nullptr && (_controllable || std::abs(_speed.X) < 0.01f)) {
			EndDamagingMove();
			_controllable = true;
			_controllableTimeout = 0.0f;
			if (_suspendType == SuspendType::None) {
				SetTransition(AnimState::TransitionUppercutEnd, false);
			}
		}

		// Copter Ears
		if (_activeModifier != Modifier::Copter && _activeModifier != Modifier::LizardCopter) {
			// TODO: Is this still needed?
			bool cancelCopter;
			if ((_currentAnimation->State & AnimState::Copter) == AnimState::Copter) {
				cancelCopter = (CanJump() || _suspendType != SuspendType::None || _copterFramesLeft <= 0.0f);

				_copterFramesLeft -= timeMult;
				_speed.Y = std::min(_speed.Y + _levelHandler->GetGravity() * timeMult, 1.5f);
			} else {
				cancelCopter = ((_currentAnimation->State & AnimState::Fall) == AnimState::Fall && _copterFramesLeft > 0.0f);
			}

			if (cancelCopter) {
				_copterFramesLeft = 0.0f;
				SetAnimation(_currentAnimation->State & ~AnimState::Copter);
				if (!_isAttachedToPole) {
					SetState(ActorState::ApplyGravitation, true);
				}
			}
		}
	}

	void Player::CheckSuspendState(float timeMult)
	{
		if (_suspendTime > 0.0f) {
			_suspendTime -= timeMult;
			return;
		}

		if (_suspendType == SuspendType::SwingingVine || _activeModifier != Modifier::None) {
			return;
		}

		auto tiles = _levelHandler->TileMap();
		if (tiles == nullptr) {
			return;
		}

		AnimState currentState = _currentAnimation->State;

		SuspendType newSuspendState = tiles->GetTileSuspendState(_pos.X, _pos.Y - 1.0f);

		if (newSuspendState == _suspendType) {
			if (newSuspendState == SuspendType::None) {
				constexpr float ToleranceX = 8.0f;
				constexpr float ToleranceY = 4.0f;

				newSuspendState = tiles->GetTileSuspendState(_pos.X - ToleranceX, _pos.Y - 1.0f);
				if (newSuspendState != SuspendType::Hook) {
					newSuspendState = tiles->GetTileSuspendState(_pos.X + ToleranceX, _pos.Y - 1.0f);
					if (newSuspendState != SuspendType::Hook) {
						// Also try with Y tolerance
						newSuspendState = tiles->GetTileSuspendState(_pos.X, _pos.Y - 1.0f + ToleranceY);
						if (newSuspendState != SuspendType::Hook) {
							newSuspendState = tiles->GetTileSuspendState(_pos.X - ToleranceX, _pos.Y - 1.0f + ToleranceY);
							if (newSuspendState != SuspendType::Hook) {
								newSuspendState = tiles->GetTileSuspendState(_pos.X + ToleranceX, _pos.Y - 1.0f + ToleranceY);
								if (newSuspendState != SuspendType::Hook) {
									return;
								} else {
									MoveInstantly(Vector2f(ToleranceX, ToleranceY), MoveType::Relative | MoveType::Force);
								}
							} else {
								MoveInstantly(Vector2f(-ToleranceX, ToleranceY), MoveType::Relative | MoveType::Force);
							}
						} else {
							MoveInstantly(Vector2f(0.0f, ToleranceY), MoveType::Relative | MoveType::Force);
						}
					} else {
						MoveInstantly(Vector2f(ToleranceX, 0.0f), MoveType::Relative | MoveType::Force);
					}
				} else {
					MoveInstantly(Vector2f(-ToleranceX, 0.0f), MoveType::Relative | MoveType::Force);
				}
			} else {
				return;
			}
		}

		if (newSuspendState != SuspendType::None && _playerType != PlayerType::Frog && _frozenTimeLeft <= 0.0f) {
			// In original (non-Reforged) gameplay, grabbing a vine cancels an ongoing buttstomp
			if (!_levelHandler->IsReforged() && _currentSpecialMove == SpecialMoveType::Buttstomp && newSuspendState == SuspendType::Vine) {
				EndDamagingMove();
			}

			if (_currentSpecialMove == SpecialMoveType::None) {
				_suspendType = newSuspendState;
				SetState(ActorState::ApplyGravitation, false);

				if (_speed.Y > 0.0f && newSuspendState == SuspendType::Vine) {
					PlayPlayerSfx("HookAttach"_s, 0.8f, 1.2f);
				}

				_speed.Y = 0.0f;
				_externalForce.Y = 0.0f;
				_isFreefall = false;
				_isSpring = false;
				_copterFramesLeft = 0.0f;

				if (newSuspendState == SuspendType::Hook || _wasFirePressed) {
					_speed.X = 0.0f;
					_externalForce.X = 0.0f;
				}

				// Move downwards until we're on the standard height
				while (tiles->GetTileSuspendState(_pos.X, _pos.Y - 1) != SuspendType::None) {
					MoveInstantly(Vector2f(0.0f, 1.0f), MoveType::Relative | MoveType::Force);
				}
				MoveInstantly(Vector2f(0.0f, -1.0f), MoveType::Relative | MoveType::Force);

				SetAnimation(AnimState::Hook);
			}
		} else {
			_suspendType = SuspendType::None;
			_suspendTime = 8.0f;
			if ((currentState & (AnimState::Buttstomp | AnimState::Copter)) == AnimState::Idle && !_isAttachedToPole) {
				SetState(ActorState::ApplyGravitation, true);
			}
		}
	}

	void Player::OnHandleWater()
	{
		if (_inWater) {
			if (_pos.Y >= _levelHandler->GetWaterLevel()) {
				SetState(ActorState::ApplyGravitation, false);

				if (std::abs(_speed.X) > 1.0f || std::abs(_speed.Y) > 1.0f) {
					float angle;
					if (_speed.X == 0.0f) {
						if (IsFacingLeft()) {
							angle = atan2(-_speed.Y, -std::numeric_limits<float>::epsilon());
						} else {
							angle = atan2(_speed.Y, std::numeric_limits<float>::epsilon());
						}
					} else if (_speed.X < 0.0f) {
						angle = atan2(-_speed.Y, -_speed.X);
					} else {
						angle = atan2(_speed.Y, _speed.X);
					}

					if (angle > fPi) {
						angle = angle - fTwoPi;
					}

					_renderer.setRotation(std::clamp(angle, -fPiOver3, fPiOver3));
				}

				// Adjust swimming animation speed
				if (_currentTransition == nullptr) {
					_renderer.AnimDuration = std::max(_currentAnimation->AnimDuration + 1.0f - Vector2f(_speed.X, _speed.Y).Length() * 0.26f, 0.4f);
				}

			} else if (_waterCooldownLeft <= 0.0f) {
				_inWater = false;
				_waterCooldownLeft = 20.0f;

				SetState(ActorState::ApplyGravitation | ActorState::CanJump, true);
				_externalForce.Y = -0.6f;
				_renderer.setRotation(0.0f);

				SetAnimation(AnimState::Jump);

				OnWaterSplash(Vector2f(_pos.X, _levelHandler->GetWaterLevel()), false);
			}
		} else {
			if (_pos.Y >= _levelHandler->GetWaterLevel() && _waterCooldownLeft <= 0.0f) {
				_inWater = true;
				_waterCooldownLeft = 20.0f;

				_controllable = true;
				EndDamagingMove();

				OnWaterSplash(Vector2f(_pos.X, _levelHandler->GetWaterLevel()), true);
			}

			// Adjust walking animation speed
			if (_currentAnimation->State == AnimState::Walk && _currentTransition == nullptr) {
				_renderer.AnimDuration = _currentAnimation->AnimDuration * (1.4f - 0.4f * std::min(std::abs(_speed.X), MaxRunningSpeed) / MaxRunningSpeed);
			}
		}
	}

	void Player::OnHandleAreaEvents(float timeMult, bool& areaWeaponAllowed, std::int32_t& areaWaterBlock)
	{
		areaWeaponAllowed = true;
		areaWaterBlock = -1;

		auto events = _levelHandler->EventMap();
		if (events == nullptr) {
			return;
		}

		std::uint8_t* p;
		EventType tileEvent = events->GetEventByPosition(_pos.X, _pos.Y, &p);
		switch (tileEvent) {
			case EventType::LightAmbient: { // Intensity, Red, Green, Blue, Flicker
				// TODO: Change only player view, handle splitscreen multiplayer
				_levelHandler->SetAmbientLight(this, p[0] / 255.0f);
				break;
			}
			case EventType::WarpOrigin: { // Warp ID, Fast, Set Lap
				// Allow warping only if not in non-cancellable transition, except for some cosmetic but non-cancellable ones
				if (_currentTransition == nullptr || _currentTransitionCancellable ||
					   (_currentTransition->State == (AnimState::Dash | AnimState::Jump) ||
						_currentTransition->State == AnimState::Spring ||
						_currentTransition->State == AnimState::TransitionCopterShootToCopter ||
						_currentTransition->State == AnimState::TransitionFallShootToFall ||
						_currentTransition->State == AnimState::TransitionHookShootToHook ||
						_currentTransition->State == AnimState::TransitionShootToIdle ||
						_currentTransition->State == AnimState::TransitionUppercutEnd)) {
					Vector2f c = events->GetWarpTarget(p[0]);
					if (c.X >= 0.0f && c.Y >= 0.0f) {
						WarpFlags flags = WarpFlags::Default;
						if (p[1] != 0) {
							flags |= WarpFlags::Fast;
						}
						if (p[2] != 0) {
							flags |= WarpFlags::IncrementLaps;
						}

						WarpToPosition(c, flags);
					}
				}
				break;
			}
			case EventType::ModifierDeath: {
				TakeDamage(INT32_MAX, 0.0f, true);
				break;
			}
			case EventType::ModifierSetWater: {
				_levelHandler->BroadcastTriggeredEvent(this, EventType::ModifierSetWater, p);
				break;
			}
			case EventType::ModifierLimitCameraView: { // Left, Width
				std::uint16_t left = *(std::uint16_t*)&p[0];
				std::uint16_t width = *(std::uint16_t*)&p[2];
				_levelHandler->LimitCameraView(this, _pos,
					(left == 0 ? (std::int32_t)(_pos.X / Tiles::TileSet::DefaultTileSize) : left) * Tiles::TileSet::DefaultTileSize,
					width * Tiles::TileSet::DefaultTileSize);
				break;
			}
			case EventType::ModifierHPole: {
				InitialPoleStage(true);
				break;
			}
			case EventType::ModifierVPole: {
				InitialPoleStage(false);
				break;
			}
			case EventType::ModifierTube: { // XSpeed, YSpeed, Wait Time, Trig Sample, Become No-clip, No-clip Only
				// TODO: Implement other parameters
				bool becomeNoclip = (p[4] != 0);
				bool noclipOnly = (p[5] != 0);
				if (noclipOnly == GetState(ActorState::CollideWithTileset)) {
					// A player in Noclip Mode cannot use a tube event with Noclip Only set to false,
					// nor can a player not in Noclip Mode use a tube event with Noclip Only set to true
					break;
				}

				EndDamagingMove();

				SetAnimation(AnimState::Dash | AnimState::Jump);

				_controllable = false;
				SetState(ActorState::CanJump | ActorState::ApplyGravitation, false);

				_speed.X = (float)(std::int8_t)p[0];
				_speed.Y = (float)(std::int8_t)p[1];

				Vector2f pos = _pos;
				if (_speed.X == 0.0f) {
					pos.X = (std::floor(pos.X / 32) * 32) + 16;
					MoveInstantly(pos, MoveType::Absolute | MoveType::Force);
					OnUpdateHitbox();
				} else if (_speed.Y == 0.0f) {
					pos.Y = (std::floor(pos.Y / 32) * 32) + 8;
					MoveInstantly(pos, MoveType::Absolute | MoveType::Force);
					OnUpdateHitbox();
				} else if (_inTubeTime <= 0.0f) {
					pos.X = (std::floor(pos.X / 32) * 32) + 16;
					pos.Y = (std::floor(pos.Y / 32) * 32) + 8;
					MoveInstantly(pos, MoveType::Absolute | MoveType::Force);
					OnUpdateHitbox();
				}

				SetState(ActorState::CollideWithTileset, !becomeNoclip);
				_inTubeTime = (becomeNoclip ? 600.0f : 10.0f);
				break;
			}
			case EventType::AreaEndOfLevel: { // ExitType, Fast (No score count, only black screen), TextID, TextOffset, Coins
				if (_levelExiting == LevelExitingState::None) {
					// TODO: Implement Fast parameter
					uint16_t coinsRequired = *(std::uint16_t*)&p[4];
					if (coinsRequired <= _inventory.Coins) {
						_inventory.Coins -= coinsRequired;

						ExitType exitType = (ExitType)p[0];
						if (p[1] != 0) {
							exitType |= ExitType::FastTransition;
						}
						StringView nextLevel;
						if (p[2] != 0) {
							nextLevel = _levelHandler->GetLevelText(p[2], p[3], '|');
						}
						_levelHandler->BeginLevelChange(this, exitType, nextLevel);
					} else if (_bonusWarpTimer <= 0.0f) {
						_levelHandler->HandlePlayerCoins(this, _inventory.Coins, _inventory.Coins);
						PlaySfx("BonusWarpNotEnoughCoins"_s);

						_bonusWarpTimer = 400.0f;
					}
				}
				break;
			}
			case EventType::AreaText: { // Text, TextOffset, Vanish
				std::uint8_t index = p[1];
				StringView text = _levelHandler->GetLevelText(p[0], index != 0 ? index : -1, '|');
				_levelHandler->ShowLevelText(text, this);

				if (p[2] != 0) {
					events->StoreTileEvent((std::int32_t)(_pos.X / 32), (std::int32_t)(_pos.Y / 32), EventType::Empty);
				}
				break;
			}
			case EventType::AreaCallback: { // Function, Param, Vanish
				// Skip AreaCallbacks if player is currently warping
				if (_currentTransition == nullptr ||
					(_currentTransition->State != AnimState::TransitionWarpIn && _currentTransition->State != AnimState::TransitionWarpOut &&
					 _currentTransition->State != AnimState::TransitionWarpInFreefall && _currentTransition->State != AnimState::TransitionWarpOutFreefall)) {
					_levelHandler->BroadcastTriggeredEvent(this, EventType::AreaCallback, p);
				}
				break;
			}
			case EventType::AreaActivateBoss: { // Music
				_levelHandler->BroadcastTriggeredEvent(this, EventType::AreaActivateBoss, p);

				// Deactivate sugar rush if it's active
				if (_sugarRushLeft > 1.0f) {
					_sugarRushLeft = 1.0f;
				}
				break;
			}
			case EventType::AreaFlyOff: {
				if (_activeModifier == Modifier::Airboard) {
					SetModifier(Modifier::None);
				}
				break;
			}
			case EventType::AreaRevertMorph: {
				if (_playerType != _playerTypeOriginal) {
					MorphRevert();
				}
				break;
			}
			case EventType::AreaMorphToFrog: {
				if (_playerType != PlayerType::Frog) {
					MorphTo(PlayerType::Frog);
				}
				break;
			}
			case EventType::AreaNoFire: {
				switch (p[0]) {
					case 0: areaWeaponAllowed = false; break;
					case 1: _weaponAllowed = true; break;
					case 2: _weaponAllowed = false; break;
				}
				break;
			}
			case EventType::AreaWaterBlock: {
				areaWaterBlock = ((std::int32_t)_pos.Y / 32) * 32 + p[0];
				break;
			}
			case EventType::TriggerZone: { // Trigger ID, Turn On, Switch
				// TODO: Implement Switch parameter
				_levelHandler->SetTrigger(p[0], p[1] != 0);
				break;
			}

			case EventType::RollingRockTrigger: { // Rock ID
				_levelHandler->BroadcastTriggeredEvent(this, EventType::RollingRockTrigger, p);
				break;
			}
		}

		// TODO: Implement Slide modifier with JJ2+ parameter

		// Check floating from each corner of an extended hitbox
		// Player should not pass from a single tile wide gap if the columns left or right have
		// float events, so checking for a wider box is necessary.
		constexpr float ExtendedHitbox = 2.0f;

		if (!_isAttachedToPole && (_currentTransition == nullptr || _currentTransition->State != AnimState::TransitionLedgeClimb)) {
			if (_currentSpecialMove != SpecialMoveType::Buttstomp) {
				if ((events->GetEventByPosition(_pos.X, _pos.Y, &p) == EventType::AreaFloatUp) ||
					(events->GetEventByPosition(AABBInner.L - ExtendedHitbox, AABBInner.T - ExtendedHitbox, &p) == EventType::AreaFloatUp) ||
					(events->GetEventByPosition(AABBInner.R + ExtendedHitbox, AABBInner.T - ExtendedHitbox, &p) == EventType::AreaFloatUp) ||
					(events->GetEventByPosition(AABBInner.R + ExtendedHitbox, AABBInner.B + ExtendedHitbox, &p) == EventType::AreaFloatUp) ||
					(events->GetEventByPosition(AABBInner.L - ExtendedHitbox, AABBInner.B + ExtendedHitbox, &p) == EventType::AreaFloatUp)
				) {
					// External force of pinball bumber has higher priority
					if (_externalForceCooldown <= 0.0f || _speed.Y < 0.0f) {
						if ((_currentAnimation->State & AnimState::Copter) == AnimState::Copter) {
							_speed.Y = std::max(_speed.Y - _levelHandler->GetGravity() * timeMult * 8.0f, -6.0f);
						} else if (GetState(ActorState::ApplyGravitation)) {
							float gravity = _levelHandler->GetGravity();
							_externalForce.Y = -2.0f * gravity * timeMult;
							_speed.Y = std::min(gravity * timeMult, _speed.Y);
						} else {
							_speed.Y = std::max(_speed.Y - _levelHandler->GetGravity() * timeMult, -6.0f);
						}
					}
				}
			}

			if ((events->GetEventByPosition(_pos.X, _pos.Y, &p) == EventType::AreaHForce) ||
				(events->GetEventByPosition(AABBInner.L - ExtendedHitbox, AABBInner.T - ExtendedHitbox, &p) == EventType::AreaHForce) ||
				(events->GetEventByPosition(AABBInner.R + ExtendedHitbox, AABBInner.T - ExtendedHitbox, &p) == EventType::AreaHForce) ||
				(events->GetEventByPosition(AABBInner.R + ExtendedHitbox, AABBInner.B + ExtendedHitbox, &p) == EventType::AreaHForce) ||
				(events->GetEventByPosition(AABBInner.L - ExtendedHitbox, AABBInner.B + ExtendedHitbox, &p) == EventType::AreaHForce)
			   ) {
				std::uint8_t p1 = p[4];
				std::uint8_t p2 = p[5];
				if ((p2 != 0 || p1 != 0)) {
					MoveInstantly(Vector2f((p2 - p1) * 0.7f * timeMult, 0), MoveType::Relative);
				}
			}

			if (GetState(ActorState::CanJump)) {
				// Floor events
				tileEvent = events->GetEventByPosition(_pos.X, _pos.Y + 32, &p);
				switch (tileEvent) {
					case EventType::AreaHForce: {
						std::uint8_t p1 = p[0];
						std::uint8_t p2 = p[1];
						std::uint8_t p3 = p[2];
						std::uint8_t p4 = p[3];
						if (p2 != 0 || p1 != 0) {
							MoveInstantly(Vector2f((p2 - p1) * 0.7f * timeMult, 0), MoveType::Relative);
						}
						if (p4 != 0 || p3 != 0) {
							_speed.X += (p4 - p3) * 0.1f;
						}
						break;
					}
				}
			}
		}
	}

	void Player::OnHandleSpectate(float timeMult)
	{
		constexpr float SpectateSpeed = 8.0f;

		Vector2f speed;

		float playerMovement = _levelHandler->PlayerHorizontalMovement(this);
		if (std::abs(playerMovement) > 0.3f) {
			speed.X = playerMovement * SpectateSpeed;
		} else {
			speed.X = 0.0f;
		}

		float playerMovementVert = _levelHandler->PlayerVerticalMovement(this);
		if (std::abs(playerMovementVert) > 0.3f) {
			speed.Y = playerMovementVert * SpectateSpeed;
		} else {
			speed.Y = 0.0f;
		}

		if (_levelHandler->PlayerActionPressed(this, PlayerAction::Run)) {
			speed *= 2.0f;
		} else if (_levelHandler->PlayerActionPressed(this, PlayerAction::Jump)) {
			speed *= 0.5f;
		}

		auto levelBounds = _levelHandler->GetLevelBounds();
		_pos.X = std::clamp(_pos.X + speed.X * timeMult, float(levelBounds.X), float(levelBounds.X + levelBounds.W));
		_pos.Y = std::clamp(_pos.Y + speed.Y * timeMult, float(levelBounds.Y), float(levelBounds.Y + levelBounds.H));
	}

	std::shared_ptr<AudioBufferPlayer> Player::PlayPlayerSfx(StringView identifier, float gain, float pitch)
	{
		auto it = _metadata->Sounds.find(String::nullTerminatedView(identifier));
		if (it != _metadata->Sounds.end()) {
			AudioBuffer* buffer;
			if (!it->second.Buffers.empty()) {
				std::int32_t idx = (it->second.Buffers.size() > 1 ? Random().Next(0, (std::int32_t)it->second.Buffers.size()) : 0);
				buffer = &it->second.Buffers[idx]->Buffer;
			} else {
				buffer = nullptr;
			}
			
			return _levelHandler->PlaySfx(this, identifier, buffer, Vector3f::Zero, true, gain, pitch);
		}

		return nullptr;
	}

	bool Player::SetPlayerTransition(AnimState state, bool cancellable, bool removeControl, SpecialMoveType specialMove, Function<void()>&& callback)
	{
		if (removeControl) {
			_controllable = false;
			_controllableTimeout = 0.0f;
		}

		_currentSpecialMove = specialMove;
		return SetTransition(state, cancellable, std::move(callback));
	}

	bool Player::CanFreefall()
	{
		AABBf aabb = AABBf(_pos.X - 14, _pos.Y + 8 - 12, _pos.X + 14, _pos.Y + 8 + 12 + 100);
		TileCollisionParams params = { TileDestructType::None, true };
		return _levelHandler->IsPositionEmpty(this, aabb, params);
	}

	void Player::OnPerishInner()
	{
		_trailLastPos = _pos;

#if defined(WITH_AUDIO)
		if (_copterSound != nullptr) {
			_copterSound->stop();
			_copterSound = nullptr;
		}
		if (_weaponSound != nullptr) {
			_weaponSound->stop();
			_weaponSound = nullptr;
		}
#endif

		SetState(ActorState::CanJump, false);
		_speed.X = 0.0f;
		_speed.Y = 0.0f;
		_externalForce.X = 0.0f;
		_externalForce.Y = 0.0f;
		_internalForceY = 0.0f;
		_fireFramesLeft = 0.0f;
		_copterFramesLeft = 0.0f;
		_pushFramesLeft = 0.0f;
		_weaponCooldown = 0.0f;
		_controllableTimeout = 0.0f;
		_inShallowWater = -1;
		_keepRunningTime = 0.0f;
		_invulnerableTime = 0.0f;
		_lastPoleTime = 0.0f;
		_isAttachedToPole = false;
		SetDizzy(0.0f);
		SetModifier(Modifier::None);
		SetShield(ShieldType::None, 0.0f);

		SetPlayerTransition(AnimState::TransitionDeath, false, true, SpecialMoveType::None, [this]() {
			_speed.X = 0.0f;
			_speed.Y = 0.0f;
			_externalForce.X = 0.0f;
			_externalForce.Y = 0.0f;
			_internalForceY = 0.0f;
			_inShallowWater = -1;
			_keepRunningTime = 0.0f;
			_carryingObject = nullptr;

			if (_lives > 1 || !_levelHandler->IsLocalSession()) {
				if (_lives > 1 && _lives < UINT8_MAX) {
					_lives--;
				}

				// Revert coins, gems, ammo and weapon upgrades; food eaten is reverted only if Reforged
				std::int32_t foodEaten = _inventory.FoodEaten;
				_inventory = _inventoryCheckpoint;
				if (!_levelHandler->IsReforged()) {
					_inventory.FoodEaten = foodEaten;
				}

				// Remove all fast fires and Blaster upgrades
				_inventory.WeaponUpgrades[(std::int32_t)WeaponType::Blaster] = 0;

				// Reset current weapon to Blaster if player has no ammo on checkpoint
				if (_inventory.WeaponAmmo[(std::int32_t)_currentWeapon] == 0) {
					SetCurrentWeapon(WeaponType::Blaster, SetCurrentWeaponReason::Rollback);
				}

				if (_sugarRushLeft > 0.0f) {
					_sugarRushLeft = 0.0f;
					_renderer.Initialize(ActorRendererType::Default);
				}

				// Spawn corpse - pass the effective fur color (bytes 2..5) so the corpse is recolored like the player
				std::uint32_t furColor = GetEffectiveFurColor();
				std::uint8_t playerParams[6] = { (std::uint8_t)_playerType, (std::uint8_t)(IsFacingLeft() ? 1 : 0),
					(std::uint8_t)(furColor & 0xFF), (std::uint8_t)((furColor >> 8) & 0xFF),
					(std::uint8_t)((furColor >> 16) & 0xFF), (std::uint8_t)((furColor >> 24) & 0xFF) };
				std::shared_ptr<PlayerCorpse> corpse = std::make_shared<PlayerCorpse>();
				corpse->OnActivated(ActorActivationDetails(
					_levelHandler,
					Vector3i(_pos.X, _pos.Y, _renderer.layer() - 40),
					playerParams
				));
				_levelHandler->AddActor(corpse);

				SetAnimation(AnimState::Idle);

				if (_levelHandler->HandlePlayerDied(this)) {
					// Reset health
					_health = _maxHealth;

					// Player can be respawned immediately
					if (_invulnerableTime <= 0.0f) {
						SetState(ActorState::IsInvulnerable, false);
					}
					SetState(ActorState::ApplyGravitation | ActorState::CollideWithTileset | ActorState::CollideWithSolidObjects, true);
					_controllable = true;

					// Return to the last save point
					MoveInstantly(_checkpointPos, MoveType::Absolute | MoveType::Force);
					_levelHandler->SetAmbientLight(this, _checkpointLight);
				} else {
					// Respawn is delayed
					_controllable = false;
					_renderer.setDrawEnabled(false);

					_invulnerableTime = 0.0f;
					SetState(ActorState::IsInvulnerable, true);
					SetState(ActorState::ApplyGravitation | ActorState::CollideWithTileset | ActorState::CollideWithOtherActors, false);
				}
			} else {
				_lives = 0;
				_renderer.setDrawEnabled(false);

				_levelHandler->HandleGameOver(this);
			}
		});

		PlayPlayerSfx("Die"_s, 1.3f);
		_levelHandler->PlayerExecuteRumble(this, "Die"_s);
	}

	void Player::SwitchToNextWeapon()
	{
#if defined(WITH_AUDIO)
		if (_weaponSound != nullptr) {
			_weaponSound->stop();
			_weaponSound = nullptr;
		}
#endif

		// Find next available weapon
		WeaponType weaponType = (WeaponType)(((std::int32_t)_currentWeapon + 1) % (std::int32_t)WeaponType::Count);
		for (std::int32_t i = 0; i < (std::int32_t)WeaponType::Count && _inventory.WeaponAmmo[(std::int32_t)weaponType] == 0; i++) {
			weaponType = (WeaponType)(((std::int32_t)weaponType + 1) % (std::int32_t)WeaponType::Count);
		}
		SetCurrentWeapon(weaponType, SetCurrentWeaponReason::User);

		_weaponCooldown = 1.0f;
	}

	void Player::SwitchToWeaponByIndex(std::uint32_t weaponIndex)
	{
		if (weaponIndex >= (std::uint32_t)WeaponType::Count || _inventory.WeaponAmmo[weaponIndex] == 0) {
			PlayPlayerSfx("ChangeWeapon"_s);
			return;
		}
#if defined(WITH_AUDIO)
		if (_weaponSound != nullptr) {
			_weaponSound->stop();
			_weaponSound = nullptr;
		}
#endif
		SetCurrentWeapon((WeaponType)weaponIndex, SetCurrentWeaponReason::User);
		_weaponCooldown = 1.0f;
	}

	template<typename T, WeaponType weaponType>
	void Player::FireWeapon(float cooldownBase, float cooldownUpgrade, bool emitFlare)
	{
		// NOTE: cooldownBase and cooldownUpgrade cannot be template parameters in Emscripten
		Vector3i initialPos;
		Vector2f gunspotPos;
		float angle;
		GetFirePointAndAngle(initialPos, gunspotPos, angle);

		std::shared_ptr<T> shot = std::make_shared<T>();
		std::uint8_t shotParams[1] = { _inventory.WeaponUpgrades[(std::int32_t)weaponType] };
		shot->OnActivated(ActorActivationDetails(
			_levelHandler,
			initialPos,
			shotParams
		));
		shot->OnFire(shared_from_this(), gunspotPos, _speed, angle, IsFacingLeft());
		_levelHandler->AddActor(shot);

		std::int32_t fastFire = (_inventory.WeaponUpgrades[(std::int32_t)WeaponType::Blaster] >> 1);
		_weaponCooldown = cooldownBase - (fastFire * cooldownUpgrade);

		if (emitFlare) {
			EmitWeaponFlare();
		}
	}

	void Player::FireWeaponRF()
	{
		Vector3i initialPos;
		Vector2f gunspotPos;
		float angle;
		GetFirePointAndAngle(initialPos, gunspotPos, angle);

		uint8_t shotParams[1] = { _inventory.WeaponUpgrades[(std::int32_t)WeaponType::RF] };

		if ((_inventory.WeaponUpgrades[(std::int32_t)WeaponType::RF] & 0x1) != 0) {
			std::shared_ptr<Weapons::RFShot> shot1 = std::make_shared<Weapons::RFShot>();
			shot1->OnActivated(ActorActivationDetails(
				_levelHandler,
				initialPos,
				shotParams
			));
			shot1->OnFire(shared_from_this(), gunspotPos, _speed, angle - 0.3f, IsFacingLeft());
			_levelHandler->AddActor(shot1);

			std::shared_ptr<Weapons::RFShot> shot2 = std::make_shared<Weapons::RFShot>();
			shot2->OnActivated(ActorActivationDetails(
				_levelHandler,
				initialPos,
				shotParams
			));
			shot2->OnFire(shared_from_this(), gunspotPos, _speed, angle, IsFacingLeft());
			_levelHandler->AddActor(shot2);

			std::shared_ptr<Weapons::RFShot> shot3 = std::make_shared<Weapons::RFShot>();
			shot3->OnActivated(ActorActivationDetails(
				_levelHandler,
				initialPos,
				shotParams
			));
			shot3->OnFire(shared_from_this(), gunspotPos, _speed, angle + 0.3f, IsFacingLeft());
			_levelHandler->AddActor(shot3);
		} else {
			std::shared_ptr<Weapons::RFShot> shot1 = std::make_shared<Weapons::RFShot>();
			shot1->OnActivated(ActorActivationDetails(
				_levelHandler,
				initialPos,
				shotParams
			));
			shot1->OnFire(shared_from_this(), gunspotPos, _speed, angle - 0.26f, IsFacingLeft());
			_levelHandler->AddActor(shot1);

			std::shared_ptr<Weapons::RFShot> shot2 = std::make_shared<Weapons::RFShot>();
			shot2->OnActivated(ActorActivationDetails(
				_levelHandler,
				initialPos,
				shotParams
			));
			shot2->OnFire(shared_from_this(), gunspotPos, _speed, angle + 0.26f, IsFacingLeft());
			_levelHandler->AddActor(shot2);
		}

		_weaponCooldown = 120.0f;
		EmitWeaponFlare();
	}

	void Player::FireWeaponPepper()
	{
		Vector3i initialPos;
		Vector2f gunspotPos;
		float angle;
		GetFirePointAndAngle(initialPos, gunspotPos, angle);

		uint8_t shotParams[1] = { _inventory.WeaponUpgrades[(std::int32_t)WeaponType::Pepper] };

		std::shared_ptr<Weapons::PepperShot> shot1 = std::make_shared<Weapons::PepperShot>();
		shot1->OnActivated(ActorActivationDetails(
			_levelHandler,
			initialPos,
			shotParams
		));
		shot1->OnFire(shared_from_this(), gunspotPos, _speed, angle - Random().NextFloat(-0.2f, 0.2f), IsFacingLeft());
		_levelHandler->AddActor(shot1);

		std::shared_ptr<Weapons::PepperShot> shot2 = std::make_shared<Weapons::PepperShot>();
		shot2->OnActivated(ActorActivationDetails(
			_levelHandler,
			initialPos,
			shotParams
		));
		shot2->OnFire(shared_from_this(), gunspotPos, _speed, angle + Random().NextFloat(-0.2f, 0.2f), IsFacingLeft());
		_levelHandler->AddActor(shot2);

		std::int32_t fastFire = (_inventory.WeaponUpgrades[(std::int32_t)WeaponType::Blaster] >> 1);
		_weaponCooldown = 30.0f - (fastFire * 2.76f);
		EmitWeaponFlare();
	}

	void Player::FireWeaponTNT()
	{
		std::shared_ptr<Weapons::TNT> tnt = std::make_shared<Weapons::TNT>();
		tnt->OnActivated(ActorActivationDetails(
			_levelHandler,
			Vector3i((std::int32_t)_pos.X, (std::int32_t)_pos.Y, _renderer.layer() - 2)
		));
		tnt->OnFire(shared_from_this());
		_levelHandler->AddActor(tnt);

		_levelHandler->PlayerExecuteRumble(this, "FireWeak"_s);

		_weaponCooldown = 60.0f;
	}

	bool Player::FireWeaponThunderbolt()
	{
		if (_isActivelyPushing || _inWater || _isAttachedToPole) {
			return false;
		}

		Vector3i initialPos;
		Vector2f gunspotPos;
		float angle;
		GetFirePointAndAngle(initialPos, gunspotPos, angle);

		std::shared_ptr<Weapons::Thunderbolt> shot = std::make_shared<Weapons::Thunderbolt>();
		uint8_t shotParams[1] = { _inventory.WeaponUpgrades[(std::int32_t)WeaponType::Thunderbolt] };
		shot->OnActivated(ActorActivationDetails(
			_levelHandler,
			initialPos,
			shotParams
		));
		shot->OnFire(shared_from_this(), gunspotPos, _speed, angle, IsFacingLeft());
		_levelHandler->AddActor(shot);

		_weaponCooldown = 12.0f - (_inventory.WeaponUpgrades[(std::int32_t)WeaponType::Blaster] * 0.1f);

		if (!_inWater && (_currentAnimation->State & AnimState::Lookup) != AnimState::Lookup) {
			AddExternalForce(IsFacingLeft() ? 2.0f : -2.0f, 0.0f);
		}

#if defined(WITH_AUDIO)
		if (_weaponSound == nullptr) {
			PlaySfx("WeaponThunderboltStart"_s, 0.5f);
			_weaponSound = PlaySfx("WeaponThunderbolt"_s, 1.0f);
			if (_weaponSound != nullptr) {
				_weaponSound->setLooping(true);
				_weaponSound->setPitch(Random().FastFloat(1.05f, 1.2f));
				_weaponSound->setLowPass(0.9f);
			}
		}
#endif

		return true;
	}

	bool Player::FireCurrentWeapon(WeaponType weaponType)
	{
		if (_weaponCooldown > 0.0f) {
			return (weaponType != WeaponType::TNT);
		}

		std::uint16_t ammoDecrease = 256;

		switch (weaponType) {
			case WeaponType::Blaster:
				switch (_activeShield) {
					case ShieldType::Fire: {
						if (_inWater) {
							return false;
						}
						FireWeapon<Weapons::ShieldFireShot, WeaponType::Blaster>(10.0f, 0.0f, true);
						break;
					}
					case ShieldType::Water: {
						FireWeapon<Weapons::ShieldWaterShot, WeaponType::Blaster>(8.0f, 0.0f);
						break;
					}
					case ShieldType::Lightning: {
						FireWeapon<Weapons::ShieldLightningShot, WeaponType::Blaster>(10.0f, 0.0f);
						break;
					}
					default: {
						FireWeapon<Weapons::BlasterShot, WeaponType::Blaster>(30.0f, 2.76f, true);
						PlayPlayerSfx("WeaponBlaster"_s);
						break;
					}
				}
				ammoDecrease = 0;
				break;

			case WeaponType::Bouncer: FireWeapon<Weapons::BouncerShot, WeaponType::Bouncer>(30.0f, 2.76f, true); break;
				case WeaponType::Freezer:
					// TODO: Add upgraded freezer
					FireWeapon<Weapons::FreezerShot, WeaponType::Freezer>(30.0f, 2.76f);
					break;
				case WeaponType::Seeker: FireWeapon<Weapons::SeekerShot, WeaponType::Seeker>(120.0f, 0.0f, true); break;
				case WeaponType::RF: FireWeaponRF(); break;

				case WeaponType::Toaster: {
					if (_inWater) {
						return false;
					}
					FireWeapon<Weapons::ToasterShot, WeaponType::Toaster>(6.0f, 0.0f);
#if defined(WITH_AUDIO)
					if (_weaponSound == nullptr) {
						_weaponSound = PlaySfx("WeaponToaster"_s, 0.6f);
						if (_weaponSound != nullptr) {
							_weaponSound->setLooping(true);
						}
					}
#endif
					ammoDecrease = 50;
					break;
				}

				case WeaponType::TNT: FireWeaponTNT(); break;
				case WeaponType::Pepper: FireWeaponPepper(); break;
				case WeaponType::Electro: FireWeapon<Weapons::ElectroShot, WeaponType::Electro>(30.0f, 2.76f, true); break;

				case WeaponType::Thunderbolt: {
					if (!FireWeaponThunderbolt()) {
						return false;
					}
					ammoDecrease = ((_inventory.WeaponUpgrades[(std::int32_t)WeaponType::Thunderbolt] & 0x1) != 0 ? 40 : 80); // Lower ammo consumption with upgrade
					break;
				}

			default:
				return false;
		}

		auto& currentAmmo = _inventory.WeaponAmmo[(std::int32_t)weaponType];
		if (ammoDecrease > currentAmmo) {
			ammoDecrease = currentAmmo;
		}
		currentAmmo -= ammoDecrease;

		// No ammo, switch weapons
		if (currentAmmo == 0) {
			// Remove upgrade if no ammo left
			_inventory.WeaponUpgrades[(std::int32_t)weaponType] &= ~0x01;

			SwitchToNextWeapon();
			PlayPlayerSfx("ChangeWeapon"_s);
			_weaponCooldown = 20.0f;
		}

		return (weaponType != WeaponType::TNT);
	}

	void Player::EmitWeaponFlare()
	{
		_weaponFlareFrame = (Random().Next() & 0xFFFF);
		_weaponFlareTime = 6.0f;
	}

	void Player::SetCurrentWeapon(WeaponType weaponType, SetCurrentWeaponReason reason)
	{
		// Handle only local sessions here, online sessions are handled in derived classes
		if (reason == SetCurrentWeaponReason::AddAmmo && !PreferencesCache::SwitchToNewWeapon && _levelHandler->IsLocalSession()) {
			return;
		}

		_currentWeapon = weaponType;
	}

	void Player::GetFirePointAndAngle(Vector3i& initialPos, Vector2f& gunspotPos, float& angle)
	{
		if (_currentTransition != nullptr && (_currentTransition->State == AnimState::Spring || _currentTransition->State == AnimState::TransitionShootToIdle)) {
			ForceCancelTransition();
		}

		SetAnimation(_currentAnimation->State | AnimState::Shoot);

		initialPos = Vector3i((std::int32_t)_pos.X, (std::int32_t)_pos.Y, _renderer.layer() - 2);
		gunspotPos = _pos;

		if (_inWater) {
			angle = _renderer.rotation();

			std::int32_t size = (_currentAnimation->Base->FrameDimensions.X / 2);
			gunspotPos.X += (cosf(angle) * size) * (IsFacingLeft() ? -1.0f : 1.0f);
			gunspotPos.Y += (sinf(angle) * size) * (IsFacingLeft() ? -1.0f : 1.0f) - (_currentAnimation->Base->Hotspot.Y - _currentAnimation->Base->Gunspot.Y);
		} else {
			gunspotPos.X += (_currentAnimation->Base->Hotspot.X - _currentAnimation->Base->Gunspot.X) * (IsFacingLeft() ? 1 : -1);
			gunspotPos.Y -= (_currentAnimation->Base->Hotspot.Y - _currentAnimation->Base->Gunspot.Y);

			if ((_currentAnimation->State & AnimState::Lookup) == AnimState::Lookup) {
				initialPos.X = (std::int32_t)gunspotPos.X;
				angle = (IsFacingLeft() ? fRadAngle90 : fRadAngle270);
			} else {
				initialPos.Y = (std::int32_t)gunspotPos.Y;
				angle = 0.0f;
			}
		}
	}

	bool Player::OnLevelChanging(Actors::ActorBase* initiator, ExitType exitType)
	{
		// Deactivate any shield
		if (_activeShieldTime > 70.0f) {
			_activeShieldTime = 70.0f;
		}

		if (_spawnedBird != nullptr) {
			_spawnedBird->FlyAway();
			_spawnedBird = nullptr;
		}

		switch (_levelExiting) {
			case LevelExitingState::Waiting: {
				if (CanJump() && std::abs(_speed.X) < 1.0f && std::abs(_speed.Y) < 1.0f) {
					_levelExiting = LevelExitingState::Transition;

					ForceCancelTransition();

					SetPlayerTransition(AnimState::TransitionEndOfLevel, false, true, SpecialMoveType::None, [this]() {
						_renderer.setDrawEnabled(false);
						_levelExiting = LevelExitingState::Ready;
					});
					PlayPlayerSfx("EndOfLevel1"_s, 1.0f / _levelHandler->GetPlayers().size());

					SetState(ActorState::ApplyGravitation, false);
					_speed.X = 0.0f;
					_speed.Y = 0.0f;
					_externalForce.X = 0.0f;
					_externalForce.Y = 0.0f;
					_internalForceY = 0.0f;
				} else if (_lastPoleTime <= 0.0f) {
					// Waiting timeout - use warp transition instead
					_levelExiting = LevelExitingState::Transition;

					ForceCancelTransition();

					SetPlayerTransition(_isFreefall ? AnimState::TransitionWarpInFreefall : AnimState::TransitionWarpIn, false, true, SpecialMoveType::None, [this]() {
						_renderer.setDrawEnabled(false);
						_levelExiting = LevelExitingState::Ready;
					});
					PlayPlayerSfx("WarpIn"_s, 1.0f / _levelHandler->GetPlayers().size());
					_levelHandler->PlayerExecuteRumble(this, "Warp"_s);

					SetState(ActorState::ApplyGravitation, false);
					_speed.X = 0.0f;
					_speed.Y = 0.0f;
					_externalForce.X = 0.0f;
					_externalForce.Y = 0.0f;
					_internalForceY = 0.0f;
				} else {
					// Refresh animation state, because UpdateAnimation() is not called when _controllable is false
					AnimState oldState = _currentAnimation->State;
					AnimState newState = (oldState & CompositeAnimMask);
					if (std::abs(_speed.X) > std::numeric_limits<float>::epsilon()) {
						newState |= AnimState::Walk;
					}
					if (!CanJump()) {
						if (_speed.Y < 0.0f) {
							newState |= AnimState::Jump;
						} else {
							newState |= AnimState::Fall;
						}
					}
					SetAnimation(newState);
					if ((oldState == AnimState::Fall || oldState == AnimState::Freefall) && newState == AnimState::Idle) {
						SetTransition(AnimState::TransitionFallToIdle, true);
					}
				}
				return false;
			}

			case LevelExitingState::WaitingForWarp: {
				if (_lastPoleTime <= 0.0f) {
					_levelExiting = LevelExitingState::Transition;

					ForceCancelTransition();

					SetPlayerTransition(_isFreefall || _inWater ? AnimState::TransitionWarpInFreefall : AnimState::TransitionWarpIn, false, true, SpecialMoveType::None, [this]() {
						_renderer.setDrawEnabled(false);
						_levelExiting = LevelExitingState::Ready;
					});
					PlayPlayerSfx("WarpIn"_s, 1.0f / _levelHandler->GetPlayers().size());
					_levelHandler->PlayerExecuteRumble(this, "Warp"_s);

					SetState(ActorState::ApplyGravitation, false);
					_speed.X = 0.0f;
					_speed.Y = 0.0f;
					_externalForce.X = 0.0f;
					_externalForce.Y = 0.0f;
					_internalForceY = 0.0f;
				} else {
					// Refresh animation state, because UpdateAnimation() is not called when _controllable is false
					AnimState oldState = _currentAnimation->State;
					AnimState newState = (oldState & CompositeAnimMask);
					if (std::abs(_speed.X) > std::numeric_limits<float>::epsilon()) {
						newState |= AnimState::Walk;
					}
					if (!CanJump()) {
						if (_speed.Y < 0.0f) {
							newState |= AnimState::Jump;
						} else {
							newState |= AnimState::Fall;
						}
					}
					SetAnimation(newState);
					if ((oldState == AnimState::Fall || oldState == AnimState::Freefall) && newState == AnimState::Idle) {
						SetTransition(AnimState::TransitionFallToIdle, true);
					}
				}
				return false;
			}

			case LevelExitingState::Transition:
				return false;

			case LevelExitingState::Ready:
				return true;
		}

		if (_health <= 0) {
			// Player is dead, just skip the transition
			_levelExiting = LevelExitingState::Ready;
			return true;
		}

		if (_suspendType != SuspendType::None) {
			MoveInstantly(Vector2f(0.0f, 4.0f), MoveType::Relative | MoveType::Force);
			_suspendType = SuspendType::None;
			_suspendTime = 60.0f;
		}

		_controllable = false;
		SetState(ActorState::IsInvulnerable | ActorState::ApplyGravitation, true);
		_fireFramesLeft = 0.0f;
		_copterFramesLeft = 0.0f;
		_pushFramesLeft = 0.0f;
		_invulnerableTime = 0.0f;

		if (_sugarRushLeft > 1.0f) {
			_sugarRushLeft = 1.0f;
		}

		_renderer.setDrawEnabled(true);

		ExitType exitTypeMasked = (exitType & ExitType::TypeMask);
		if ((exitType & ExitType::FastTransition) == ExitType::FastTransition) {
			if (exitTypeMasked == ExitType::Warp || exitTypeMasked == ExitType::Bonus || exitTypeMasked == ExitType::Boss) {
				_levelExiting = LevelExitingState::WaitingForWarp;

				// Re-used for waiting timeout
				_lastPoleTime = 0.0f;
				return false;
			} else {
				_levelExiting = LevelExitingState::Ready;
				return true;
			}
		} else {
			if (initiator == this || (initiator == nullptr && _playerIndex == 0)) {
				PlayPlayerSfx("EndOfLevel"_s);
			}

			if (exitTypeMasked == ExitType::Warp || exitTypeMasked == ExitType::Bonus || exitTypeMasked == ExitType::Boss || _inWater) {
				_levelExiting = LevelExitingState::WaitingForWarp;

				// Re-used for waiting timeout
				_lastPoleTime = 100.0f;
			} else {
				_levelExiting = LevelExitingState::Waiting;
				SetFacingLeft(false);

				// Re-used for waiting timeout
				_lastPoleTime = 300.0f;
			}
			return false;
		}
	}

	void Player::ReceiveLevelCarryOver(ExitType exitType, const PlayerCarryOver& carryOver)
	{
		_lives = (std::int32_t)carryOver.Lives;
		_score = carryOver.Score;
		_inventory.FoodEaten = (std::int32_t)carryOver.FoodEaten;
		_inventoryCheckpoint.FoodEaten = _inventory.FoodEaten;
		_currentWeapon = carryOver.CurrentWeapon;

		std::memcpy(_gemsTotal, carryOver.Gems, sizeof(_gemsTotal));
		std::memcpy(_inventory.WeaponAmmo, carryOver.Ammo, sizeof(_inventory.WeaponAmmo));
		std::memcpy(_inventoryCheckpoint.WeaponAmmo, carryOver.Ammo, sizeof(_inventoryCheckpoint.WeaponAmmo));
		std::memcpy(_inventory.WeaponUpgrades, carryOver.WeaponUpgrades, sizeof(_inventory.WeaponUpgrades));
		std::memcpy(_inventoryCheckpoint.WeaponUpgrades, carryOver.WeaponUpgrades, sizeof(_inventoryCheckpoint.WeaponUpgrades));

		_inventory.WeaponAmmo[(std::int32_t)WeaponType::Blaster] = UINT16_MAX;
		_inventoryCheckpoint.WeaponAmmo[(std::int32_t)WeaponType::Blaster] = UINT16_MAX;

		ExitType exitTypeMasked = (exitType & ExitType::TypeMask);
		if (exitTypeMasked == ExitType::Warp || exitTypeMasked == ExitType::Bonus || exitTypeMasked == ExitType::Boss) {
			// Use delayed spawning
			SetState(ActorState::ApplyGravitation, false);
			_renderer.setDrawEnabled(false);
			_lastExitType = exitType;
			_controllable = false;
			_controllableTimeout = ((exitType & ExitType::FastTransition) == ExitType::FastTransition ? 5.0f : 55.0f);
		} else if ((exitType & ExitType::Frozen) == ExitType::Frozen) {
			// Use instant spawning
			Freeze(100.0f);
		}

		// Preload all weapons
		for (std::int32_t i = 0; i < std::int32_t(arraySize(_inventory.WeaponAmmo)); i++) {
			if (_inventory.WeaponAmmo[i] != 0) {
				PreloadMetadataAsync(String("Weapon/"_s + WeaponNames[i]));
			}
		}
	}

	PlayerCarryOver Player::PrepareLevelCarryOver()
	{
		PlayerCarryOver carryOver;
		carryOver.Type = _playerType;
		carryOver.Lives = (_lives > UINT8_MAX ? UINT8_MAX : (std::uint8_t)_lives);
		carryOver.Score = _score;
		carryOver.FoodEaten = (_inventory.FoodEaten > UINT8_MAX ? UINT8_MAX : (std::uint8_t)_inventory.FoodEaten);
		carryOver.CurrentWeapon = _currentWeapon;

		for (std::size_t i = 0; i < arraySize(carryOver.Gems); i++) {
			carryOver.Gems[i] = _gemsTotal[i] + _inventory.Gems[i];
		}

		std::memcpy(carryOver.Ammo, _inventory.WeaponAmmo, sizeof(_inventory.WeaponAmmo));
		std::memcpy(carryOver.WeaponUpgrades, _inventory.WeaponUpgrades, sizeof(_inventory.WeaponUpgrades));

		return carryOver;
	}

	void Player::InitializeFromStream(ILevelHandler* levelHandler, Stream& src, std::uint16_t version)
	{
		std::uint8_t playerIndex = src.ReadVariableInt32();
		PlayerType playerType = (PlayerType)src.ReadValue<std::uint8_t>();
		PlayerType playerTypeOriginal = (PlayerType)src.ReadValue<std::uint8_t>();
		float checkpointPosX = src.ReadValueAsLE<float>();
		float checkpointPosY = src.ReadValueAsLE<float>();

		std::uint8_t playerParams[2] = { (std::uint8_t)playerType, (std::uint8_t)playerIndex };
		OnActivated(Actors::ActorActivationDetails(
			levelHandler,
			Vector3i((std::int32_t)checkpointPosX, (std::int32_t)checkpointPosY, ILevelHandler::PlayerZ - playerIndex),
			playerParams
		));

		_playerTypeOriginal = playerTypeOriginal;

		_checkpointLight = src.ReadValueAsLE<float>();
		_lives = src.ReadVariableInt32();
		_inventory.Coins = src.ReadVariableInt32();
		_inventoryCheckpoint.Coins = _inventory.Coins;
		_inventory.FoodEaten = src.ReadVariableInt32();
		_inventoryCheckpoint.FoodEaten = _inventory.FoodEaten;
		_score = src.ReadVariableInt32();

		_inventory.Gems[0] = src.ReadVariableInt32();
		if (version >= 3) {
			// Gem types are split since v3.0.0
			_inventory.Gems[1] = src.ReadVariableInt32();
			_inventory.Gems[2] = src.ReadVariableInt32();
			_inventory.Gems[3] = src.ReadVariableInt32();

			_gemsTotal[0] = src.ReadVariableInt32();
			_gemsTotal[1] = src.ReadVariableInt32();
			_gemsTotal[2] = src.ReadVariableInt32();
			_gemsTotal[3] = src.ReadVariableInt32();
		}
		std::memcpy(_inventoryCheckpoint.Gems, _inventory.Gems, sizeof(_inventory.Gems));

		levelHandler->SetAmbientLight(this, _checkpointLight);

		std::int32_t weaponCount = src.ReadVariableInt32();
		DEATH_ASSERT(weaponCount == std::int32_t(arraySize(_inventoryCheckpoint.WeaponAmmo)), "Weapon count mismatch", );
		_currentWeapon = (WeaponType)src.ReadVariableInt32();
		src.Read(_inventoryCheckpoint.WeaponAmmo, sizeof(_inventoryCheckpoint.WeaponAmmo));
		src.Read(_inventoryCheckpoint.WeaponUpgrades, sizeof(_inventoryCheckpoint.WeaponUpgrades));

		std::memcpy(_inventory.WeaponAmmo, _inventoryCheckpoint.WeaponAmmo, sizeof(_inventoryCheckpoint.WeaponAmmo));
		std::memcpy(_inventory.WeaponUpgrades, _inventoryCheckpoint.WeaponUpgrades, sizeof(_inventoryCheckpoint.WeaponUpgrades));

		// Reset current weapon to Blaster if player has no ammo on checkpoint
		if (_inventory.WeaponAmmo[(std::int32_t)_currentWeapon] == 0) {
			SetCurrentWeapon(WeaponType::Blaster, SetCurrentWeaponReason::Rollback);
		}
	}

	void Player::SerializeResumableToStream(Stream& dest)
	{
		dest.WriteVariableInt32(_playerIndex);
		dest.WriteValue<std::uint8_t>((std::uint8_t)_playerType);
		dest.WriteValue<std::uint8_t>((std::uint8_t)_playerTypeOriginal);
		dest.WriteValueAsLE<float>(_checkpointPos.X);
		dest.WriteValueAsLE<float>(_checkpointPos.Y);
		dest.WriteValueAsLE<float>(_checkpointLight);
		dest.WriteVariableInt32(_lives);
		dest.WriteVariableInt32(_inventoryCheckpoint.Coins);
		dest.WriteVariableInt32(_inventoryCheckpoint.FoodEaten);
		dest.WriteVariableInt32(_score);
		dest.WriteVariableInt32(_inventoryCheckpoint.Gems[0]);
		dest.WriteVariableInt32(_inventoryCheckpoint.Gems[1]);
		dest.WriteVariableInt32(_inventoryCheckpoint.Gems[2]);
		dest.WriteVariableInt32(_inventoryCheckpoint.Gems[3]);
		dest.WriteVariableInt32(_gemsTotal[0]);
		dest.WriteVariableInt32(_gemsTotal[1]);
		dest.WriteVariableInt32(_gemsTotal[2]);
		dest.WriteVariableInt32(_gemsTotal[3]);
		dest.WriteVariableInt32(std::int32_t(arraySize(_inventoryCheckpoint.WeaponAmmo)));
		dest.WriteVariableInt32((std::int32_t)_currentWeapon);
		dest.Write(_inventoryCheckpoint.WeaponAmmo, sizeof(_inventoryCheckpoint.WeaponAmmo));
		dest.Write(_inventoryCheckpoint.WeaponUpgrades, sizeof(_inventoryCheckpoint.WeaponUpgrades));
	}

	bool Player::Respawn(Vector2f pos)
	{
		if ((GetState() & (ActorState::IsInvulnerable | ActorState::ApplyGravitation | ActorState::CollideWithTileset | ActorState::CollideWithOtherActors)) != ActorState::IsInvulnerable) {
			return false;
		}

		_health = _maxHealth;

		MoveInstantly(pos, MoveType::Absolute | MoveType::Force);

		_controllable = true;
		_renderer.setDrawEnabled(true);
		SetState(ActorState::IsInvulnerable, false);
		SetState(ActorState::ApplyGravitation | ActorState::CollideWithTileset | ActorState::CollideWithOtherActors, true);

		return true;
	}

	void Player::WarpToPosition(Vector2f pos, WarpFlags flags)
	{
		if ((flags & WarpFlags::Fast) == WarpFlags::Fast) {
			Vector2f posPrev = _pos;
			bool hideTrail = (posPrev - pos).Length() > 250.0f;
			MoveInstantly(pos, MoveType::Absolute | MoveType::Force);
			if (hideTrail) {
				_trailLastPos = _pos;
			}
			_levelHandler->HandlePlayerWarped(this, posPrev, flags);
		} else {
			EndDamagingMove();
			SetState(ActorState::IsInvulnerable, true);
			SetState(ActorState::ApplyGravitation, false);

			SetAnimation(_currentAnimation->State & ~(AnimState::Uppercut | AnimState::Buttstomp));

			_speed.X = 0.0f;
			_speed.Y = 0.0f;
			_externalForce.X = 0.0f;
			_externalForce.Y = 0.0f;
			_internalForceY = 0.0f;
			_fireFramesLeft = 0.0f;
			_copterFramesLeft = 0.0f;
			_pushFramesLeft = 0.0f;

			// For warping from the water
			_renderer.setRotation(0.0f);

			if ((flags & WarpFlags::SkipWarpIn) == WarpFlags::SkipWarpIn) {
				DoWarpOut(pos, flags);
			} else {
				PlayPlayerSfx("WarpIn"_s);
				_levelHandler->PlayerExecuteRumble(this, "Warp"_s);

				SetPlayerTransition(_isFreefall ? AnimState::TransitionWarpInFreefall : AnimState::TransitionWarpIn, false, true, SpecialMoveType::None, [this, pos, flags]() {
					DoWarpOut(pos, flags);
				});
			}
		}
	}

	void Player::DoWarpOut(Vector2f pos, WarpFlags flags)
	{
		Vector2f posPrev = _pos;
		MoveInstantly(pos, MoveType::Absolute | MoveType::Force);
		_trailLastPos = _pos;
		PlayPlayerSfx("WarpOut"_s);
		_levelHandler->PlayerExecuteRumble(this, "Warp"_s);

		_levelHandler->HandlePlayerWarped(this, posPrev, flags);

		_isFreefall |= CanFreefall();
		SetPlayerTransition(_isFreefall ? AnimState::TransitionWarpOutFreefall : AnimState::TransitionWarpOut, false, true, SpecialMoveType::None, [this, flags]() {
			SetState(ActorState::IsInvulnerable, false);
			// Don't re-enable gravity if any modifier is active
			if (_activeModifier == Modifier::None) {
				SetState(ActorState::ApplyGravitation, true);
			}

			if ((flags & WarpFlags::Freeze) == WarpFlags::Freeze) {
				Freeze(100.0f);
			} else {
				_controllable = true;
				// UpdateAnimation() was probably skipped in this step, because _controllable was false, so call it here
				UpdateAnimation(0.0f);
			}
		});
	}

	void Player::WarpToCheckpoint()
	{
		WarpToPosition(_checkpointPos, WarpFlags::SkipWarpIn);
		_levelHandler->SetAmbientLight(this, _checkpointLight);
	}

	void Player::InitialPoleStage(bool horizontal)
	{
		if (_isAttachedToPole || _playerType == PlayerType::Frog) {
			return;
		}
		
		std::int32_t x = (std::int32_t)_pos.X / Tiles::TileSet::DefaultTileSize;
		std::int32_t y = (std::int32_t)_pos.Y / Tiles::TileSet::DefaultTileSize;

		if (_lastPoleTime > 0.0f && _lastPolePos.X == x && _lastPolePos.Y == y) {
			return;
		}

		_lastPoleTime = 80.0f;
		_lastPolePos = Vector2i(x, y);

		float activeForce, lastSpeed;
		if (horizontal) {
			activeForce = (std::abs(_externalForce.X) > 1.0f ? _externalForce.X : _speed.X);
			lastSpeed = _speed.X;
		} else {
			activeForce = _speed.Y;
			lastSpeed = _speed.Y;
		}
		bool positive = (activeForce >= 0.0f);

		float tx = static_cast<float>(x * Tiles::TileSet::DefaultTileSize + Tiles::TileSet::DefaultTileSize / 2);
		float ty = static_cast<float>(y * Tiles::TileSet::DefaultTileSize + Tiles::TileSet::DefaultTileSize / 2);

		if (_levelHandler->IsReforged()) {
			auto* events = _levelHandler->EventMap();
			std::uint8_t* p;
			if (horizontal) {
				if (events->GetEventByPosition(x, (_pos.Y < ty ? y - 1 : y + 1), &p) == EventType::ModifierHPole) {
					ty = _pos.Y;
				}
			} else {
				if (events->GetEventByPosition((_pos.X < tx ? x - 1 : x + 1), y, &p) == EventType::ModifierVPole) {
					tx = _pos.X;
				}
			}
		}

		MoveInstantly(Vector2f(tx, ty), MoveType::Absolute | MoveType::Force);
		OnUpdateHitbox();

		_speed.X = 0.0f;
		_speed.Y = 0.0f;
		_externalForce.X = 0.0f;
		_externalForce.Y = 0.0f;
		_internalForceY = 0.0f;
		SetState(ActorState::ApplyGravitation, false);
		_renderer.setRotation(0.0f);
		_isAttachedToPole = true;
		if (_inIdleTransition) {
			_inIdleTransition = false;
			CancelTransition();
		}

		_keepRunningTime = 0.0f;
		_pushFramesLeft = 0.0f;
		_fireFramesLeft = 0.0f;
		_copterFramesLeft = 0.0f;

		SetAnimation(_currentAnimation->State & ~(AnimState::Uppercut /*| AnimState::Sidekick*/ | AnimState::Buttstomp));

		AnimState poleAnim = (horizontal ? AnimState::TransitionPoleHSlow : AnimState::TransitionPoleVSlow);
		SetPlayerTransition(poleAnim, false, true, SpecialMoveType::None, [this, horizontal, positive, lastSpeed]() {
			NextPoleStage(horizontal, positive, 2, lastSpeed);
		});

		_controllableTimeout = 80.0f;

		PlayPlayerSfx("Pole"_s, 0.8f, 0.6f);
	}

	void Player::NextPoleStage(bool horizontal, bool positive, std::int32_t stagesLeft, float lastSpeed)
	{
		if (_inIdleTransition) {
			_inIdleTransition = false;
			CancelTransition();
		}

		if (stagesLeft > 0) {
			AnimState poleAnim = (horizontal ? AnimState::TransitionPoleH : AnimState::TransitionPoleV);
			SetPlayerTransition(poleAnim, false, true, SpecialMoveType::None, [this, horizontal, positive, stagesLeft, lastSpeed]() {
				NextPoleStage(horizontal, positive, stagesLeft - 1, lastSpeed);
			});

			_controllableTimeout = 80.0f;

			PlayPlayerSfx("Pole"_s, 1.0f, 0.6f);
		} else {
			std::int32_t sign = (positive ? 1 : -1);
			if (horizontal) {
				// To prevent stucking
				for (std::int32_t i = -1; i > -6; i--) {
					if (MoveInstantly(Vector2f(_speed.X, (float)i), MoveType::Relative)) {
						break;
					}
				}

				_speed.X = 10 * sign + lastSpeed * 0.2f;
				_externalForce.X = 10.0f * sign;
				SetFacingLeft(!positive);

				_keepRunningTime = 60.0f;

				SetPlayerTransition(AnimState::Dash | AnimState::Jump, true, true, SpecialMoveType::None);
			} else {
				MoveInstantly(Vector2f(0.0f, sign * 16.0f), MoveType::Relative | MoveType::Force);

				// Non-Reforged: stronger vertical launch so spring->pole chains gain ~50% more height
				_speed.Y = (4.0f * sign + lastSpeed * 1.4f) * (_levelHandler->IsReforged() ? 1.0f : 2.5f);
				_externalForce.Y = 1.3f * sign * (_levelHandler->IsReforged() ? 1.0f : 2.5f);
			}

			SetState(ActorState::ApplyGravitation, true);
			_isAttachedToPole = false;
			_wasActivelyPushing = false;

			_controllableTimeout = 4.0f;
			_lastPoleTime = 10.0f;

			PlayPlayerSfx("HookAttach"_s, 0.8f, 1.2f);
		}
	}

	Player::Modifier Player::GetModifier() const
	{
		return _activeModifier;
	}

	bool Player::SetModifier(Modifier modifier, const std::shared_ptr<ActorBase>& decor)
	{
		if (_activeModifier == modifier) {
			return false;
		}

		if (_activeModifierDecor != nullptr) {
			_activeModifierDecor->OnDetach(this);
			_activeModifierDecor = nullptr;
		}

		switch (modifier) {
			case Modifier::Airboard: {
				_controllable = true;
				EndDamagingMove();
				SetState(ActorState::ApplyGravitation, false);

				_speed.Y = 0.0f;
				_externalForce.Y = 0.0f;
				_internalForceY = 0.0f;

				_activeModifier = Modifier::Airboard;

				MoveInstantly(Vector2f(0.0f, -16.0f), MoveType::Relative);
				break;
			}
			case Modifier::Copter: {
				_controllable = true;
				EndDamagingMove();
				SetState(ActorState::ApplyGravitation, false);

				_speed.Y = 0.0f;
				_externalForce.Y = 0.0f;
				_internalForceY = 0.0f;

				_activeModifier = Modifier::Copter;

				_copterFramesLeft = 10.0f * FrameTimer::FramesPerSecond;

#if defined(WITH_AUDIO)
				if (_copterSound == nullptr) {
					_copterSound = PlaySfx("Copter"_s, 0.6f, 1.5f);
					if (_copterSound != nullptr) {
						_copterSound->setLooping(true);
					}
				}
#endif
				break;
			}
			case Modifier::LizardCopter: {
				_controllable = true;
				EndDamagingMove();
				SetState(ActorState::ApplyGravitation, false);

				_speed.Y = 0.0f;
				_externalForce.Y = 0.0f;
				_internalForceY = 0.0f;

				_activeModifier = Modifier::LizardCopter;
				_activeModifierDecor = decor;
				_activeModifierDecor->OnDetach(this);

				_copterFramesLeft = 3.0f * FrameTimer::FramesPerSecond;
				break;
			}

			default: {
				_activeModifier = Modifier::None;

				SetState(ActorState::CanJump | ActorState::ApplyGravitation, true);

				SetAnimation(AnimState::Fall);
				break;
			}
		}

		return true;
	}

	bool Player::TakeDamage(std::int32_t amount, float pushForce, bool ignoreInvulnerable)
	{
		if (amount <= 0 || _health <= 0 || (!ignoreInvulnerable && GetState(ActorState::IsInvulnerable)) || _levelExiting != LevelExitingState::None) {
			return false;
		}

		// Cancel active climbing and copter
		if (_currentTransition != nullptr && _currentTransition->State == AnimState::TransitionLedgeClimb) {
			ForceCancelTransition();
			MoveInstantly(Vector2f(IsFacingLeft() ? 6.0f : -6.0f, 0.0f), MoveType::Relative | MoveType::Force);
		} else if (_activeModifier == Modifier::Copter || _activeModifier == Modifier::LizardCopter) {
			SetModifier(Modifier::None);
		}

		if (_spawnedBird != nullptr) {
			_spawnedBird->FlyAway();
			_spawnedBird = nullptr;
			// Bird acts as extra life
			_health++;
		}

		DecreaseHealth(amount);

		_speed.X = 0.0f;
		_internalForceY = 0.0f;
		_fireFramesLeft = 0.0f;
		_copterFramesLeft = 0.0f;
		_pushFramesLeft = 0.0f;
		SetState(ActorState::CanJump, false);
		_isAttachedToPole = false;

		if (_health > 0) {
			_externalForce.X = pushForce;

			if (!_inWater && _activeModifier == Modifier::None) {
				_speed.Y = -6.5f;

				SetState(ActorState::ApplyGravitation | ActorState::CollideWithTileset | ActorState::CollideWithSolidObjects, true);
				SetAnimation(AnimState::Idle);
			}

			SetPlayerTransition(AnimState::Hurt, false, true, SpecialMoveType::None, [this]() {
				_controllable = true;
			});

			float invulnerableTime = _levelHandler->GetHurtInvulnerableTime();
			SetInvulnerability(invulnerableTime, InvulnerableType::Blinking);
			PlayPlayerSfx("Hurt"_s);
			_levelHandler->PlayerExecuteRumble(this, "Hurt"_s);
		} else {
			_externalForce.X = 0.0f;
			_speed.Y = 0.0f;

			PlayPlayerSfx("Die"_s, 1.3f);
		}

		return true;
	}

	bool Player::Freeze(float timeLeft)
	{
		if (_currentTransition != nullptr && _currentTransition->State == AnimState::TransitionDeath) {
			// Don't allow freezing during death transition
			return false;
		}

		if (timeLeft > 0.0f) {
			_renderer.AnimPaused = true;
			_controllable = false;
			_controllableTimeout = timeLeft;
		} else if (_frozenTimeLeft > 0.0f) {
			_controllable = true;
			_controllableTimeout = 0.0f;
		}

		_frozenTimeLeft = timeLeft;

		return true;
	}

	void Player::SetInvulnerability(float timeLeft, InvulnerableType type)
	{
		if (timeLeft <= 0.0f) {
			if (_invulnerableTime > 0.0f) {
				SetState(ActorState::IsInvulnerable, false);
				_invulnerableTime = 0.0f;
				_shieldSpawnTime = ShieldDisabled;
				_renderer.setDrawEnabled(true);
			}
			return;
		}

		if (type == InvulnerableType::Shielded) {
			if (_invulnerableTime > 0.0f) {
				// If the players is already blinking, show it now
				_renderer.setDrawEnabled(true);
			}
			if (_shieldSpawnTime <= ShieldDisabled) {
				_shieldSpawnTime = 1.0f;
			}
		} else {
			_invulnerableBlinkTime = (type == InvulnerableType::Transient ? -1.0f : 0.0f);
			_shieldSpawnTime = ShieldDisabled;
		}

		SetState(ActorState::IsInvulnerable, true);
		_invulnerableTime = timeLeft;
	}

	void Player::EndDamagingMove()
	{
		if (_currentSpecialMove == SpecialMoveType::None) {
			return;
		}

		SetState(ActorState::ApplyGravitation, true);
		SetAnimation(_currentAnimation->State & ~(AnimState::Uppercut | AnimState::Buttstomp));

		if (_currentSpecialMove == SpecialMoveType::Uppercut) {
			if (_suspendType == SuspendType::None) {
				SetTransition(AnimState::TransitionUppercutEnd, false);
			}
			_controllable = true;

			if (_externalForce.Y < 0.0f) {
				_externalForce.Y = 0.0f;
			}
		} else if (_currentSpecialMove == SpecialMoveType::Sidekick) {
			CancelTransition();
			_controllable = true;
			_controllableTimeout = 10;
		}

		_currentSpecialMove = SpecialMoveType::None;
	}

	std::int32_t Player::GetScore() const
	{
		return _score;
	}

	void Player::AddScore(std::int32_t amount)
	{
		_score = std::min(std::max(_score + amount, 0), 999999999);
	}

	bool Player::AddHealth(std::int32_t amount)
	{
		constexpr std::int32_t HealthLimit = 5;

		if (_health >= HealthLimit) {
			return false;
		}

		if (amount < 0) {
			_health = std::max(_maxHealth, HealthLimit);
			PlayPlayerSfx("PickupMaxCarrot"_s);
		} else {
			_health = std::min(_health + amount, HealthLimit);
			if (_maxHealth < _health) {
				_maxHealth = _health;
			}
			PlayPlayerSfx("PickupFood"_s);
		}

		return true;
	}

	std::int32_t Player::GetLives() const
	{
		return _lives;
	}

	bool Player::AddLives(std::int32_t count)
	{
		constexpr std::int32_t LivesLimit = 99;

		if (_lives >= LivesLimit) {
			return false;
		}

		_lives = std::min(_lives + count, LivesLimit);
		PlayPlayerSfx("PickupOneUp"_s);
		return true;
	}

	std::int32_t Player::GetCoins() const
	{
		return _inventory.Coins;
	}

	void Player::AddCoins(std::int32_t count)
	{
		std::int32_t prevCoins = _inventory.Coins;
		_inventory.Coins += count;
		_levelHandler->HandlePlayerCoins(this, prevCoins, _inventory.Coins);
		PlayPlayerSfx("PickupCoin"_s);
	}

	void Player::AddCoinsInternal(std::int32_t count)
	{
		_inventory.Coins += count;
	}

	std::int32_t Player::GetGems(std::uint8_t gemType) const
	{
		if (gemType >= arraySize(_inventory.Gems)) {
			return 0;
		}

		return _inventory.Gems[gemType];
	}

	void Player::AddGems(std::uint8_t gemType, std::int32_t count)
	{
		if (gemType >= arraySize(_inventory.Gems)) {
			return;
		}

		std::int32_t prevGems = _inventory.Gems[gemType];
		_inventory.Gems[gemType] += count;
		_levelHandler->HandlePlayerGems(this, gemType, prevGems, _inventory.Gems[gemType]);
		float pitch = 1.0f - fabs(2.0f * fmod(_gemsPitch * 0.05f, 1.0f) - 1.0f);
		PlayPlayerSfx("PickupGem"_s, 1.0f, std::min(0.7f + pitch * 0.6f, 1.3f));
		_gemsTimer = 120.0f;
		_gemsPitch++;
	}

	std::int32_t Player::GetConsumedFood() const
	{
		return _inventory.FoodEaten;
	}

	void Player::ConsumeFood(bool isDrinkable)
	{
		PlayPlayerSfx(isDrinkable ? "PickupDrink"_s : "PickupFood"_s);

		_inventory.FoodEaten++;
		if (_inventory.FoodEaten >= 100 && _levelHandler->CanActivateSugarRush()) {
			_inventory.FoodEaten = _inventory.FoodEaten % 100;
			ActivateSugarRush(1300.0f);
		}
	}

	void Player::ActivateSugarRush(float duration)
	{
		if (_sugarRushLeft > 0.0f) {
			_sugarRushLeft = std::max(duration, 1.0f);
			return;
		}
		if (duration <= 0.0f) {
			return;
		}
		_sugarRushLeft = duration;
		_renderer.Initialize(ActorRendererType::PartialWhiteMask);
		_weaponWheelState = WeaponWheelState::Hidden;
		_levelHandler->HandleActivateSugarRush(this);
	}

	bool Player::AddAmmo(WeaponType weaponType, std::int16_t count)
	{
		constexpr std::int16_t Multiplier = 256;
		constexpr std::int16_t AmmoLimit = 99 * Multiplier;

		if (weaponType >= WeaponType::Count || _inventory.WeaponAmmo[(std::int32_t)weaponType] < 0 || _inventory.WeaponAmmo[(std::int32_t)weaponType] >= AmmoLimit) {
			return false;
		}

		bool switchTo = (_inventory.WeaponAmmo[(std::int32_t)weaponType] == 0);

		_inventory.WeaponAmmo[(std::int32_t)weaponType] = (std::int16_t)std::min((std::int32_t)_inventory.WeaponAmmo[(std::int32_t)weaponType] + count * Multiplier, (int32_t)AmmoLimit);

		if (switchTo) {
			SetCurrentWeapon(weaponType, SetCurrentWeaponReason::AddAmmo);

			switch (_currentWeapon) {
				case WeaponType::Blaster: PreloadMetadataAsync("Weapon/Blaster"_s); break;
				case WeaponType::Bouncer: PreloadMetadataAsync("Weapon/Bouncer"_s); break;
				case WeaponType::Freezer: PreloadMetadataAsync("Weapon/Freezer"_s); break;
				case WeaponType::Seeker: PreloadMetadataAsync("Weapon/Seeker"_s); break;
				case WeaponType::RF: PreloadMetadataAsync("Weapon/RF"_s); break;
				case WeaponType::Toaster: PreloadMetadataAsync("Weapon/Toaster"_s); break;
				case WeaponType::TNT: PreloadMetadataAsync("Weapon/TNT"_s); break;
				case WeaponType::Pepper: PreloadMetadataAsync("Weapon/Pepper"_s); break;
				case WeaponType::Electro: PreloadMetadataAsync("Weapon/Electro"_s); break;
				case WeaponType::Thunderbolt: PreloadMetadataAsync("Weapon/Thunderbolt"_s); break;
			}
		}

		PlayPlayerSfx("PickupAmmo"_s);
		return true;
	}

	void Player::AddWeaponUpgrade(WeaponType weaponType, std::uint8_t upgrade)
	{
		bool shouldSwitchToWeapon = (_inventory.WeaponUpgrades[(std::int32_t)weaponType] == 0);

		_inventory.WeaponUpgrades[(std::int32_t)weaponType] |= upgrade;

		if (shouldSwitchToWeapon) {
			SetCurrentWeapon(weaponType, SetCurrentWeaponReason::AddUpgrade);
		}
	}

	bool Player::AddFastFire(std::int32_t count)
	{
		const std::int32_t FastFireLimit = 9;

		std::int32_t current = (_inventory.WeaponUpgrades[(std::int32_t)WeaponType::Blaster] >> 1);
		if (current >= FastFireLimit) {
			return false;
		}

		current = std::min(current + count, FastFireLimit);

		_inventory.WeaponUpgrades[(std::int32_t)WeaponType::Blaster] = (std::uint8_t)((_inventory.WeaponUpgrades[(std::int32_t)WeaponType::Blaster] & 0x1) | (current << 1));

		PlayPlayerSfx("PickupAmmo"_s);

		return true;
	}

	bool Player::MorphTo(PlayerType type)
	{
		if (_playerType == type) {
			return false;
		}

		PlayerType playerTypePrevious = _playerType;

		_playerType = type;

		// Load new metadata, indexed only when recolored (must match the renderer's current palette state)
		bool useIndexed = (GetEffectiveFurColor() != 0);
		RequestMetadata(GetCharacterTraits(type).Metadata, useIndexed);

		// Refresh animation state
		if ((_currentSpecialMove == SpecialMoveType::None) ||
			(_currentSpecialMove == SpecialMoveType::Buttstomp && (type == PlayerType::Jazz || type == PlayerType::Spaz || type == PlayerType::Lori))) {
			AnimState prevAnim = _currentAnimation->State;
			_currentAnimation = nullptr;
			if (!SetAnimation(prevAnim)) {
				if (!SetAnimation(AnimState::Idle)) {
					return false;
				}
			}
		} else {
			_currentAnimation = nullptr;
			if (!SetAnimation(AnimState::Fall)) {
				return false;
			}

			SetState(ActorState::ApplyGravitation, true);
			_controllable = true;

			if (_currentSpecialMove == SpecialMoveType::Uppercut && _externalForce.Y < 0.0f) {
				_externalForce.Y = 0.0f;
			}

			_currentSpecialMove = SpecialMoveType::None;
		}

		// Set transition
		if (type == PlayerType::Frog) {
			PlayPlayerSfx("Transform");

			_controllable = false;
			_controllableTimeout = 120.0f;

			switch (playerTypePrevious) {
				case PlayerType::Jazz:
					SetTransition(TransformFrogFromJazz, false, [this]() {
						_controllable = true;
						_controllableTimeout = 0.0f;
					});
					break;
				case PlayerType::Spaz:
					SetTransition(TransformFrogFromSpaz, false, [this]() {
						_controllable = true;
						_controllableTimeout = 0.0f;
					});
					break;
				case PlayerType::Lori:
					SetTransition(TransformFrogFromLori, false, [this]() {
						_controllable = true;
						_controllableTimeout = 0.0f;
					});
					break;
			}
		} else if (playerTypePrevious == PlayerType::Frog) {
			_controllable = false;
			_controllableTimeout = 120.0f;

			SetTransition(AnimState::TransitionFromFrog, false, [this]() {
				_controllable = true;
				_controllableTimeout = 0.0f;
			});
		} else {
			Explosion::Create(_levelHandler, Vector3i((std::int32_t)(_pos.X - 12.0f), (std::int32_t)(_pos.Y - 6.0f), _renderer.layer() + 4), Explosion::Type::SmokeBrown);
			Explosion::Create(_levelHandler, Vector3i((std::int32_t)(_pos.X - 8.0f), (std::int32_t)(_pos.Y + 28.0f), _renderer.layer() + 4), Explosion::Type::SmokeBrown);
			Explosion::Create(_levelHandler, Vector3i((std::int32_t)(_pos.X + 12.0f), (std::int32_t)(_pos.Y + 10.0f), _renderer.layer() + 4), Explosion::Type::SmokeBrown);

			Explosion::Create(_levelHandler, Vector3i((std::int32_t)_pos.X, (std::int32_t)(_pos.Y + 12.0f), _renderer.layer() + 6), Explosion::Type::SmokeBrown);
		}

		return true;
	}

	void Player::MorphRevert()
	{
		MorphTo(_playerTypeOriginal);
	}

	bool Player::SetDizzy(float timeLeft)
	{
		bool wasNotDizzy = (_dizzyTime <= 0.0f);
		_dizzyTime = timeLeft;
		return wasNotDizzy;
	}

	bool Player::SetShield(ShieldType shieldType, float time)
	{
		bool shouldSwitchToBlaster = (shieldType != ShieldType::None && _activeShield == ShieldType::None);

		_activeShield = shieldType;
		_activeShieldTime = (shieldType != ShieldType::None ? time : 0.0f);

		if (shouldSwitchToBlaster) {
			SetCurrentWeapon(WeaponType::Blaster, SetCurrentWeaponReason::Shield);
		}

		return true;
	}

	bool Player::IncreaseShieldTime(float time)
	{
		if (_activeShieldTime <= 0.0f) {
			return false;
		}

		_activeShieldTime += time;
		PlayPlayerSfx("PickupGem"_s);
		return true;
	}

	void Player::DecreaseShieldTime(float time)
	{
		// Only chips away while there's a comfortable margin left, so an absorbed hit never drops the shield right
		// before it would have expired anyway
		if (_activeShieldTime > time) {
			_activeShieldTime -= time;
		}
	}

	bool Player::SpawnBird(std::uint8_t type, Vector2f pos)
	{
		if (_spawnedBird != nullptr) {
			return false;
		}

		_spawnedBird = std::make_shared<Environment::Bird>();
		std::uint8_t birdParams[2] = { type, (std::uint8_t)_playerIndex };
		_spawnedBird->OnActivated(ActorActivationDetails(
			_levelHandler,
			Vector3i((std::int32_t)pos.X, (std::int32_t)pos.Y, _renderer.layer() + 80),
			birdParams
		));
		_levelHandler->AddActor(_spawnedBird);
		return true;
	}

	bool Player::DisableControllable(float timeout)
	{
		if (!_controllable) {
			if (timeout <= 0.0f) {
				_controllable = true;
				_controllableTimeout = 0.0f;
				return true;
			} else {
				return false;
			}
		}

		if (timeout <= 0.0f) {
			return false;
		}

		_controllable = false;
		if (timeout == std::numeric_limits<float>::infinity()) {
			_controllableTimeout = 0.0f;
		} else {
			_controllableTimeout = timeout;
		}

		SetAnimation(AnimState::Idle);
		return true;
	}

	void Player::SetCheckpoint(Vector2f pos, float ambientLight)
	{
		_checkpointPos = Vector2f(pos.X, pos.Y - 20.0f);
		_checkpointLight = ambientLight;
		
		_inventoryCheckpoint = _inventory;
	}

	void Player::CancelCarryingObject(ActorBase* expectedActor)
	{
		if (expectedActor != nullptr && _carryingObject != expectedActor) {
			return;
		}

		_carryingObject = nullptr;

		if (_suspendType == SuspendType::SwingingVine) {
			_suspendType = SuspendType::None;
			SetState(ActorState::ApplyGravitation, true);
			_renderer.setRotation(0.0f);
		}
	}

	void Player::UpdateCarryingObject(ActorBase* actor, SuspendType suspendType)
	{
		DEATH_DEBUG_ASSERT(actor != nullptr);

		if (_carryingObject != nullptr && _carryingObject != actor) {
			return;
		}

		_carryingObject = actor;

		_canDoubleJump = true;

		if (suspendType == SuspendType::SwingingVine) {
			_suspendType = suspendType;
			SetState(ActorState::ApplyGravitation, false);
		} else if (_suspendType == SuspendType::SwingingVine) {
			_suspendType = SuspendType::None;
			SetState(ActorState::ApplyGravitation, true);
			_renderer.setRotation(0.0f);
		}
	}

	bool Player::ApplyPlayerBump(Player& other, bool stackingEnabled)
	{
		// Compute the overlap of the two collision boxes and resolve along the axis of least penetration (minimum
		// translation vector), the standard way to keep two AABBs from interpenetrating
		const AABBf& a = AABBInner;
		const AABBf& b = other.AABBInner;
		float overlapX = std::min(a.R, b.R) - std::max(a.L, b.L);
		float overlapY = std::min(a.B, b.B) - std::max(a.T, b.T);
		if (overlapX <= 0.0f || overlapY <= 0.0f) {
			// Boxes don't actually overlap (e.g., per-pixel collision reported the hit) - nothing to separate
			return false;
		}

		if (overlapY <= overlapX && stackingEnabled) {
			// Vertical overlap is the smaller axis and stacking is enabled - one player is standing/landing on the
			// other. That is resolved by UpdatePlayerStacking (the player below acts as a one-way platform), so don't
			// bump them apart vertically.
			return false;
		}

		// Equal-mass elastic bump along the least-penetration axis, applied only while the two are approaching so it
		// can't compound across frames. This is the "bump apart" behavior (always for side-to-side contact, and for
		// vertical contact too when player stacking is disabled). MoveInstantly checks the tilemap, so a player is
		// never pushed into a wall (it just stays put if blocked).
		Vector2f normal;
		float penetration;
		if (overlapX < overlapY) {
			normal = Vector2f(_pos.X < other._pos.X ? -1.0f : 1.0f, 0.0f);
			penetration = overlapX;
		} else {
			normal = Vector2f(0.0f, _pos.Y < other._pos.Y ? -1.0f : 1.0f);
			penetration = overlapY;
		}

		float push = std::min(penetration * 0.5f, PlayerBumpMaxSeparationPerFrame);
		MoveInstantly(Vector2f(normal.X * push, normal.Y * push), MoveType::Relative);
		other.MoveInstantly(Vector2f(-normal.X * push, -normal.Y * push), MoveType::Relative);

		Vector2f relativeSpeed = _speed - other._speed;
		float approachSpeed = relativeSpeed.X * normal.X + relativeSpeed.Y * normal.Y;
		if (approachSpeed < 0.0f) {
			float impulse = std::max(-(1.0f + PlayerBumpRestitution) * approachSpeed * 0.5f, PlayerBumpMinSeparationSpeed);
			_speed += normal * impulse;
			other._speed += normal * (-impulse);
		}
		return true;
	}

	void Player::UpdatePlayerStacking(float timeMult, bool snap)
	{
		ActorBase* ground = _levelHandler->FindPlayerToStandOn(this, timeMult);
		if (ground != nullptr) {
			if (snap) {
				// Reposition so our feet rest on the other player's head, re-snapping each frame so we ride its
				// vertical movement. The head is derived from the other player's position plus OUR collision
				// head-offset (players are the same size) rather than its AABB top - a client's RemoteActor uses the
				// sprite box, which is taller and would leave us floating. We sink PlayerStackSinkDepth px into the
				// player below so the two read as one connected stack instead of the upper one floating on its head.
				float groundHead = ground->GetPos().Y + (AABBInner.T - _pos.Y);
				float delta = groundHead - AABBInner.B + PlayerStackSinkDepth;
				if (delta != 0.0f) {
					MoveInstantly(Vector2f(0.0f, delta), MoveType::Relative);
				}
			}
			// Carrying makes CanJump() return true (so we can jump off) and Player::OnUpdatePhysics zeroes our
			// vertical speed so we rest on top instead of falling through.
			UpdateCarryingObject(ground);
			_stackCarrying = true;
		} else if (_stackCarrying) {
			// We were standing on a player and aren't anymore (walked off / jumped) - stop carrying so we fall again.
			// Guarded by _stackCarrying so we never cancel a real solid object we might be standing on.
			CancelCarryingObject();
			_stackCarrying = false;
		}
	}

	bool Player::IsBeingStoodOnByPlayer() const
	{
		// A player B is standing on us when its stacking resolver picked us as the ground (B->_carryingObject == this).
		// This sees only locally-simulated players: all of them in local co-op and on the server, just our own on a
		// client - which is why the cosmetic online animation is driven from the server (see PushSolidObjects).
		for (auto* other : _levelHandler->GetPlayers()) {
			if (other != this && other->_stackCarrying && other->_carryingObject == this) {
				return true;
			}
		}
		return false;
	}
}
