Downloads containing BirdRide.mut

Downloads
Name Author Game Mode Rating
JJ2+ Only: SUPERBIRD! Lark Mutator N/A Download file

File preview

const uint8 KEY_LEFT  = 0x25;
const uint8 KEY_UP    = 0x26;
const uint8 KEY_RIGHT = 0x27;
const uint8 KEY_DOWN  = 0x28;
const uint8 KEY_ENTER = 0x0D;
const uint8 KEY_ESCAPE = 0x1B;
const uint8 KEY_F     = 0x46;
const uint8 KEY_T     = 0x54;

const float BIRD_SPEED = 4.0f;
const float BIRD_DASH_SPEED = 8.0f;
const float RIDER_Y_OFFSET = -23.0f;
const float MOUNTED_NAME_Y_OFFSET = -26.0f;
// One continuous target covering the complete visible rider-and-bird sprite.
// These bounds are relative to the native Bird origin at player.xPos/yPos.
const float MOUNTED_HITBOX_HALF_WIDTH = 50.0f;
const float MOUNTED_HITBOX_TOP = -60.0f;
const float MOUNTED_HITBOX_BOTTOM = 36.0f;
const int BIRD_WIPEOUT_COOLDOWN = 350; // five seconds at 70 ticks per second
const int BIRD_TRANSFORM_COOLDOWN = 70; // one second between form changes
const int BIRD_WIPEOUT_RECOVERY = 70; // one second of blinking invulnerability
const float BIRD_WIPEOUT_MAX_X_SPEED = 4.0f;
const int BIRD_FLIGHT_STATE_CLEANUP = 14; // absorb delayed morph state for 0.2 seconds
const uint8 BIRD_FLIGHT_ANIM = 9;
const array<uint8> BIRD_RIDE_WEAPON_ICONS = {18, 25, 29, 34, 49, 57, 59, 61, 68};

array<bool> birdRideMounted(4, false);
array<bool> birdRideReady(4, false);
array<CHAR::Char> birdRideCharacter(4, CHAR::JAZZ);
array<int> birdRideFireCooldown(4, 0);
array<bool> birdRideFireWasPressed(4, false);
array<bool> birdRideSelectWasPressed(4, false);
array<bool> birdRideRunHeld(4, false);
array<bool> birdRideNeedsRespawnReset(4, false);
array<int> birdRideMountCooldown(4, 0);
array<int> birdRideTransformCooldown(4, 0);
array<int> birdRideWipeoutRecovery(4, 0);
array<int> birdRideFlightStateCleanup(4, 0);
array<bool> birdRideMeleeCollisionGuard(4, false);
array<bool> birdRideWipeoutActive(4, false);
array<float> birdRideWipeoutX(4, 0);
array<float> birdRideWipeoutY(4, 0);
array<float> birdRideWipeoutXSpeed(4, 0);
array<float> birdRideWipeoutYSpeed(4, 0);
array<int> birdRideWipeoutAngle(4, 0);
array<uint8> birdRideStoredHealth(4, 0);
array<uint32> birdRideStoredFur(4, 0);
array<int> birdRideColorMappingID(4, -1);
array<string> birdRideCarrotPopupText(4, "");
array<int> birdRideCarrotPopupTimer(4, 0);
array<float> birdRideCarrotPopupX(4, 0);
array<float> birdRideCarrotPopupY(4, 0);
array<bool> birdRideForcedPickupPreset(256, false);
array<bool> birdRideForcedPickupObject(4096, false);
array<bool> birdRideForcedPowerupPreset(256, false);
array<bool> birdRideForcedPowerupObject(4096, false);
array<bool> birdRideBirdBullet(4096, false);
bool birdRideFWasDown = false;
bool birdRideChatOpen = false;
bool birdRideTransformWarningShown = false;

bool birdRideIsWeaponPowerup(int eventID) {
	return eventID == OBJECT::BLASTERPOWERUP ||
		eventID == OBJECT::BOUNCERPOWERUP ||
		eventID == OBJECT::ICEPOWERUP ||
		eventID == OBJECT::SEEKERPOWERUP ||
		eventID == OBJECT::RFPOWERUP ||
		eventID == OBJECT::TOASTERPOWERUP ||
		eventID == OBJECT::TNTPOWERUP ||
		eventID == OBJECT::GUN8POWERUP ||
		eventID == OBJECT::GUN9POWERUP;
}

bool birdRideAnyMounted() {
	for (int i = 0; i < jjLocalPlayerCount && i < 4; ++i) {
		if (birdRideReady[i] && birdRideMounted[i])
			return true;
	}
	return false;
}

void updateWeaponPowerupBulletHandling() {
	const bool ignoreBullets = birdRideAnyMounted();
	for (int eventID = 0; eventID < 256; ++eventID) {
		if (birdRideIsWeaponPowerup(eventID))
			jjObjectPresets[eventID].bulletHandling = ignoreBullets ?
				HANDLING::IGNOREBULLET : HANDLING::HURTBYBULLET;
	}
	for (int i = 1; i < jjObjectCount; ++i) {
		jjOBJ@ obj = jjObjects[i];
		if (obj.isActive && birdRideIsWeaponPowerup(obj.eventID))
			obj.bulletHandling = ignoreBullets ?
				HANDLING::IGNOREBULLET : HANDLING::HURTBYBULLET;
	}
}

bool birdRideBulletCameFromBird(const jjOBJ@ bullet) {
	if (bullet is null)
		return false;
	if (bullet.objectID > 0 && bullet.objectID < 4096 &&
		birdRideBirdBullet[bullet.objectID])
		return true;
	if (bullet.creatorType != CREATOR::PLAYER)
		return false;
	const int playerID = bullet.creatorID;
	return playerID >= 0 && playerID < 32 &&
		jjPlayers[playerID].isActive &&
		jjPlayers[playerID].charCurr == CHAR::BIRD;
}

int birdRideRabbitMonitorHitForce(jjPLAYER@ player, jjOBJ@ target, int fallbackForce) {
	if (player is null || player.charCurr == CHAR::BIRD)
		return fallbackForce;

	const CHAR::Char character = player.charCurr;
	const bool isRabbit = character == CHAR::JAZZ || character == CHAR::SPAZ ||
		(jjIsTSF && character == CHAR::LORI);
	if (!isRabbit)
		return fallbackForce;

	// canHurt is disabled globally so rabbit melee moves cannot damage players.
	// Enable it only while asking JJ2 what this monitor collision would normally
	// be (-1 for a special move, 1 for a buttstomp), then restore it before the
	// collision is actually processed.
	const bool previousCanHurt = jjCharacters[character].canHurt;
	jjCharacters[character].canHurt = true;
	const int nativeForce = player.getObjectHitForce(target);
	jjCharacters[character].canHurt = previousCanHurt;
	return nativeForce != 0 ? nativeForce : fallbackForce;
}

class BirdRidePowerupCollision : jjBEHAVIORINTERFACE {
	private jjBEHAVIOR nativeBehavior;

	BirdRidePowerupCollision(const jjBEHAVIOR &in behavior) {
		nativeBehavior = behavior;
	}

	void onBehave(jjOBJ@ obj) {
		obj.bulletHandling = birdRideAnyMounted() ?
			HANDLING::IGNOREBULLET : HANDLING::HURTBYBULLET;
		obj.behave(nativeBehavior);
		obj.bulletHandling = birdRideAnyMounted() ?
			HANDLING::IGNOREBULLET : HANDLING::HURTBYBULLET;
	}

	bool onObjectHit(jjOBJ@ obj, jjOBJ@ bullet, jjPLAYER@ player, int force) {
		// Returning true tells JJ2+ the collision is fully handled. The monitor
		// and bullet are therefore left exactly as they were.
		if (birdRideBulletCameFromBird(bullet))
			return true;

		// Rabbit shots retain the monitor's original native collision.
		obj.scriptedCollisions = false;
		if (bullet !is null)
			bullet.objectHit(obj, HANDLING::SPECIAL);
		else if (player !is null)
			player.objectHit(
				obj,
				birdRideRabbitMonitorHitForce(player, obj, force),
				HANDLING::SPECIAL
			);
		if (obj.isActive && birdRideIsWeaponPowerup(obj.eventID))
			obj.scriptedCollisions = true;
		return true;
	}
}

void onLevelLoad() {
	// Sprite mappings are level-local, so reserve fresh slots for each local
	// player whenever a new level is loaded.
	for (uint i = 0; i < birdRideColorMappingID.length(); ++i)
		birdRideColorMappingID[i] = -1;

	// Keep JJ2's normal chat control on T.
	jjKeyChat = KEY_T;

	// Birds cannot run in unmodified JJ2. Enable their Run state so the
	// configured Run control, Caps Lock, and /run can all be detected.
	jjCharacters[CHAR::BIRD].canRun = true;

	// Rabbit form is for gathering items, not fighting. canHurt leaves each
	// rabbit's special move fully usable but removes its damaging hit force.
	jjCharacters[CHAR::JAZZ].canHurt = false;
	jjCharacters[CHAR::SPAZ].canHurt = false;
	if (jjIsTSF)
		jjCharacters[CHAR::LORI].canHurt = false;

	// Convert every ordinary pickup preset to scripted collision handling.
	// onObjectHit below then blocks mounted birds while preserving normal
	// collection for Jazz and Spaz after they dismount.
	for (int eventID = 0; eventID < 256; ++eventID) {
		jjOBJ@ preset = jjObjectPresets[eventID];
		if (preset.playerHandling == HANDLING::PICKUP && !preset.scriptedCollisions) {
			preset.scriptedCollisions = true;
			birdRideForcedPickupPreset[eventID] = true;
		}
		if (birdRideIsWeaponPowerup(eventID)) {
			preset.behavior = BirdRidePowerupCollision(preset.behavior);
			preset.scriptedCollisions = true;
			// Every game begins mounted, so make monitors physically ignore
			// bullets before the first object or collision tick can run.
			preset.bulletHandling = HANDLING::IGNOREBULLET;
			birdRideForcedPowerupPreset[eventID] = true;
		}
	}
}

void birdRideAlertWrapped(const string &in text) {
	// jjAlert itself does not wrap. Measure the same small font it uses and
	// split only at spaces, leaving a comfortable margin on both screen edges.
	const int maxWidth = jjResolutionWidth > 160 ? jjResolutionWidth - 96 : 64;
	string line = "";
	uint cursor = 0;
	while (cursor < text.length()) {
		while (cursor < text.length() && text[cursor] == 32)
			++cursor;
		if (cursor >= text.length())
			break;

		uint wordEnd = cursor;
		while (wordEnd < text.length() && text[wordEnd] != 32)
			++wordEnd;
		const string word = text.substr(cursor, wordEnd - cursor);
		const string candidate = line.length() == 0 ? word : line + " " + word;

		if (line.length() > 0 &&
			jjGetStringWidth(candidate, STRING::SMALL, STRING::NORMAL) > maxWidth) {
			jjAlert("|" + line);
			line = word;
		} else {
			line = candidate;
		}
		cursor = wordEnd + 1;
	}

	if (line.length() > 0)
		jjAlert("|" + line);
}

void onLevelBegin() {
	resetBirdRide();
	preparePickupBlocking();
	// Preserve the user's three intentional paragraph breaks, then wrap each
	// paragraph further when the current resolution is too narrow.
	birdRideAlertWrapped("Everybody's heard about the bird.");
	birdRideAlertWrapped("Use the arrow keys to move your SUPERBIRD, and SPRINT to move faster.");
	birdRideAlertWrapped("Press F to jump on and off the bird, but be careful - You are vulnerable when you are off the bird!");
}

void onLevelReload() {
	resetBirdRide();
	preparePickupBlocking();
}

void resetBirdRide() {
	for (uint i = 0; i < 4; ++i) {
		birdRideMounted[i] = false;
		birdRideReady[i] = false;
		birdRideFireCooldown[i] = 0;
		birdRideFireWasPressed[i] = false;
		birdRideSelectWasPressed[i] = false;
		birdRideRunHeld[i] = false;
		birdRideNeedsRespawnReset[i] = false;
		birdRideMountCooldown[i] = 0;
		birdRideTransformCooldown[i] = 0;
		birdRideWipeoutRecovery[i] = 0;
		birdRideFlightStateCleanup[i] = 0;
		birdRideMeleeCollisionGuard[i] = false;
		birdRideWipeoutActive[i] = false;
		birdRideWipeoutX[i] = 0;
		birdRideWipeoutY[i] = 0;
		birdRideWipeoutXSpeed[i] = 0;
		birdRideWipeoutYSpeed[i] = 0;
		birdRideWipeoutAngle[i] = 0;
		birdRideStoredHealth[i] = 0;
		birdRideStoredFur[i] = 0;
		birdRideCarrotPopupText[i] = "";
		birdRideCarrotPopupTimer[i] = 0;
		birdRideCarrotPopupX[i] = 0;
		birdRideCarrotPopupY[i] = 0;
	}
	for (uint i = 0; i < birdRideForcedPickupObject.length(); ++i)
		birdRideForcedPickupObject[i] = false;
	for (uint i = 0; i < birdRideForcedPowerupObject.length(); ++i)
		birdRideForcedPowerupObject[i] = false;
	for (uint i = 0; i < birdRideBirdBullet.length(); ++i)
		birdRideBirdBullet[i] = false;
	birdRideFWasDown = jjKey[KEY_F];
	birdRideChatOpen = false;
	birdRideTransformWarningShown = false;
}

CHAR::Char rabbitCharacter(const jjPLAYER@ player) {
	if (player.charCurr == CHAR::JAZZ ||
		player.charCurr == CHAR::SPAZ ||
		(jjIsTSF && player.charCurr == CHAR::LORI))
		return player.charCurr;

	if (player.charOrig == CHAR::JAZZ ||
		player.charOrig == CHAR::SPAZ ||
		(jjIsTSF && player.charOrig == CHAR::LORI))
		return player.charOrig;

	return CHAR::JAZZ;
}

void clearBirdRideFlightState(jjPLAYER@ player) {
	// Bird morphs and multiplayer packets can leave these native rabbit-flight
	// fields behind. SUPERBIRD movement does not use any of them.
	player.fly = FLIGHT::NONE;
	player.helicopter = 0;
	player.helicopterElapsed = 0;
}

void enforceBirdRideFur(jjPLAYER@ player, uint id) {
	// Use the packed value so all four color slots retain their exact order.
	// Checking first avoids repeatedly broadcasting an unchanged palette.
	if (id < 4 && player.fur != birdRideStoredFur[id])
		player.fur = birdRideStoredFur[id];
}

void prepareBirdRideColorMapping(jjPLAYER@ player, uint id) {
	if (id >= 4)
		return;

	if (birdRideColorMappingID[id] < 0)
		birdRideColorMappingID[id] = jjSpriteModeFirstFreeMapping();
	if (birdRideColorMappingID[id] < 0)
		return;

	// Build a permanent mapping for the four standard player gradients. This
	// makes Bird and rabbit sprites use identical slots regardless of charCurr.
	uint8 color1, color2, color3, color4;
	enforceBirdRideFur(player, id);
	player.furGet(color1, color2, color3, color4);
	const array<uint8> colors = {color1, color2, color3, color4};
	array<uint8> mapping(256);
	for (uint i = 0; i < mapping.length(); ++i)
		mapping[i] = uint8(i);
	for (uint ramp = 0; ramp < 4; ++ramp) {
		for (uint shade = 0; shade < 8; ++shade) {
			const uint source = 16 + ramp * 8 + shade;
			mapping[source] = uint8(uint(colors[ramp]) + shade);
		}
	}
	jjSpriteModeSetMapping(uint8(birdRideColorMappingID[id]), mapping);
}

void prepareBirdRide(jjPLAYER@ player) {
	const uint id = player.localPlayerID;
	if (id >= 4 || birdRideReady[id])
		return;

	birdRideCharacter[id] = rabbitCharacter(player);
	// morphTo may rearrange the four PLAYER color ramps for Bird form. Save
	// the user's actual palette before the first morph and reapply it afterward.
	birdRideStoredFur[id] = player.fur;
	prepareBirdRideColorMapping(player, id);
	birdRideReady[id] = true;
	birdRideMounted[id] = true;
	birdRideNeedsRespawnReset[id] = false;
	birdRideMountCooldown[id] = 0;
	birdRideTransformCooldown[id] = BIRD_TRANSFORM_COOLDOWN;
	birdRideWipeoutRecovery[id] = 0;
	birdRideFlightStateCleanup[id] = 0;
	birdRideMeleeCollisionGuard[id] = false;
	birdRideWipeoutActive[id] = false;
	player.morphTo(CHAR::BIRD, false);
	clearBirdRideFlightState(player);
	enforceBirdRideFur(player, id);
	player.xSpeed = 0;
	player.ySpeed = 0;
	player.noFire = false;
	// Negative invincibility is invisible. Shots are handled as wipeouts below
	// instead of reducing the mounted player's health.
	player.invincibility = -2;
}

void dismountBird(jjPLAYER@ player) {
	const uint id = player.localPlayerID;
	if (id >= 4 || !birdRideReady[id] || !birdRideMounted[id])
		return;

	// Rabbit form is deliberately fragile. Save the SUPERBIRD's
	// current health so it can be restored when the rider mounts again.
	birdRideStoredHealth[id] = player.health;
	player.morphTo(birdRideCharacter[id], false);
	clearBirdRideFlightState(player);
	enforceBirdRideFur(player, id);
	if (player.health > 0)
		player.health = 1;
	player.ySpeed = -4.0f;
	player.noFire = true;
	player.invincibility = 0;
	// A voluntary F dismount is not a wipeout. Clear any blink introduced by
	// morphing; beginBirdWipeout deliberately reapplies its own timer afterward.
	player.blink = 0;
	birdRideFireCooldown[id] = 0;
	birdRideFireWasPressed[id] = false;
	birdRideSelectWasPressed[id] = false;
	birdRideMounted[id] = false;
	birdRideMeleeCollisionGuard[id] = false;
	birdRideTransformCooldown[id] = BIRD_TRANSFORM_COOLDOWN;
	birdRideFlightStateCleanup[id] = BIRD_FLIGHT_STATE_CLEANUP;
	jjSample(player.xPos, player.yPos, SOUND::COMMON_JUMP);
}

void mountBird(jjPLAYER@ player) {
	const uint id = player.localPlayerID;
	if (id >= 4 || !birdRideReady[id] || birdRideMounted[id])
		return;
	if (birdRideMountCooldown[id] > 0) {
		const int seconds = (birdRideMountCooldown[id] + 69) / 70;
		jjAlert("|SUPERBIRD recovering - " + seconds +
			(seconds == 1 ? " second!" : " seconds!"));
		return;
	}

	// Preserve a rabbit morph chosen while dismounted.
	birdRideCharacter[id] = rabbitCharacter(player);
	player.morphTo(CHAR::BIRD, false);
	clearBirdRideFlightState(player);
	enforceBirdRideFur(player, id);
	if (birdRideStoredHealth[id] > 0)
		player.health = birdRideStoredHealth[id];
	player.xSpeed = 0;
	player.ySpeed = 0;
	player.noFire = false;
	player.invincibility = -2;
	birdRideFireCooldown[id] = 0;
	birdRideFireWasPressed[id] = false;
	birdRideSelectWasPressed[id] = player.keySelect;
	birdRideMounted[id] = true;
	birdRideTransformCooldown[id] = BIRD_TRANSFORM_COOLDOWN;
	birdRideWipeoutRecovery[id] = 0;
	birdRideFlightStateCleanup[id] = 0;
	birdRideMeleeCollisionGuard[id] = false;
	birdRideWipeoutActive[id] = false;
	jjSample(player.xPos, player.yPos, SOUND::COMMON_JUMP);
}

void toggleBirdRide(jjPLAYER@ player) {
	const uint id = player.localPlayerID;
	if (id >= 4 || !birdRideReady[id])
		return;

	// Keep the five-second wipeout message authoritative while it is active.
	if (!birdRideMounted[id] && birdRideMountCooldown[id] > 0) {
		mountBird(player);
		return;
	}
	if (birdRideTransformCooldown[id] > 0) {
		if (!birdRideTransformWarningShown) {
			jjAlert("|Wait one second before changing form again!");
			birdRideTransformWarningShown = true;
		}
		return;
	}

	if (birdRideMounted[id])
		dismountBird(player);
	else
		mountBird(player);
}

void beginBirdWipeout(jjPLAYER@ player, uint id, int knockDirection) {
	if (id >= 4 || !birdRideReady[id] || !birdRideMounted[id])
		return;

	// Launch the mount away from the impact before morphing its rider back to
	// rabbit form. The separate visual keeps tumbling for the full recovery.
	birdRideWipeoutX[id] = player.xPos;
	birdRideWipeoutY[id] = player.yPos;
	birdRideWipeoutXSpeed[id] = float(knockDirection) * 7.0f;
	birdRideWipeoutYSpeed[id] = -5.0f;
	birdRideWipeoutAngle[id] = 0;
	birdRideWipeoutActive[id] = true;
	birdRideMountCooldown[id] = BIRD_WIPEOUT_COOLDOWN;

	dismountBird(player);
	// A wipeout is a knock-off rather than the normal upward dismount hop.
	player.xSpeed = float(knockDirection) * 2.0f;
	player.ySpeed = -2.0f;
	// Native blink provides visible flashing, damage immunity, and multiplayer
	// synchronization. Match it to the separate dizzy-animation timer below.
	birdRideWipeoutRecovery[id] = BIRD_WIPEOUT_RECOVERY;
	player.blink = BIRD_WIPEOUT_RECOVERY;
	jjSample(player.xPos, player.yPos, SOUND::COMMON_SMASH);
}

void updateBirdWipeout(uint id) {
	if (id >= 4 || !birdRideWipeoutActive[id])
		return;
	if (birdRideMountCooldown[id] <= 0) {
		birdRideWipeoutActive[id] = false;
		return;
	}

	birdRideWipeoutX[id] += birdRideWipeoutXSpeed[id];
	birdRideWipeoutXSpeed[id] *= 0.985f;
	birdRideWipeoutYSpeed[id] += 0.25f;
	const float nextY = birdRideWipeoutY[id] + birdRideWipeoutYSpeed[id];
	if (birdRideWipeoutYSpeed[id] > 0 &&
		jjMaskedPixel(int(birdRideWipeoutX[id]), int(nextY + 12.0f))) {
		// Settle on the first solid surface instead of disappearing through it.
		birdRideWipeoutYSpeed[id] = 0;
		birdRideWipeoutXSpeed[id] *= 0.82f;
	} else {
		birdRideWipeoutY[id] = nextY;
	}
	birdRideWipeoutAngle[id] =
		(birdRideWipeoutAngle[id] + int(birdRideWipeoutXSpeed[id] * 7.0f)) & 1023;
}

void restoreMountedBirdAfterRespawn(jjPLAYER@ player, uint id) {
	// JJ2 restores charCurr to the player's original rabbit when a multiplayer
	// respawn finishes. Reapply Bird form once, while preserving the health and
	// spawn protection assigned by the game.
	const int respawnHealth = player.health;
	player.morphTo(CHAR::BIRD, false);
	clearBirdRideFlightState(player);
	enforceBirdRideFur(player, id);
	player.health = respawnHealth;
	player.xSpeed = 0;
	player.ySpeed = 0;
	player.noFire = false;
	player.invincibility = -2;
	birdRideStoredHealth[id] = uint8(respawnHealth);
	birdRideFireCooldown[id] = 0;
	birdRideFireWasPressed[id] = false;
	birdRideSelectWasPressed[id] = player.keySelect;
	birdRideRunHeld[id] = false;
	birdRideNeedsRespawnReset[id] = false;
	birdRideTransformCooldown[id] = BIRD_TRANSFORM_COOLDOWN;
	birdRideWipeoutRecovery[id] = 0;
	birdRideFlightStateCleanup[id] = 0;
	birdRideMeleeCollisionGuard[id] = false;
}

bool mountedBirdHasGroundBelow(const jjPLAYER@ player) {
	if (player.platform != 0)
		return true;

	// coldSpotY identifies the active native Bird frame's ground contact point.
	// Sample across its feet so slopes and tile edges count as grounded too.
	const jjANIMFRAME@ frame = jjAnimFrames[player.curFrame];
	const int footY = int(player.yPos) +
		int(frame.hotSpotY) - int(frame.coldSpotY) + 1;
	const int centerX = int(player.xPos);
	return jjMaskedPixel(centerX - 8, footY) ||
		jjMaskedPixel(centerX, footY) ||
		jjMaskedPixel(centerX + 8, footY);
}

void steerBird(jjPLAYER@ player) {
	const uint id = player.localPlayerID;
	float x = 0;
	float y = 0;

	if (jjKey[KEY_LEFT])  x -= 1;
	if (jjKey[KEY_RIGHT]) x += 1;
	if (jjKey[KEY_UP])    y -= 1;
	if (jjKey[KEY_DOWN])  y += 1;

	// Never keep forcing the native Bird collider into the floor. Doing so
	// retriggers its impact sound and camera shake every game tick.
	if (y > 0 && mountedBirdHasGroundBelow(player))
		y = 0;

	// Normalize diagonals so they are not faster than straight flight.
	if (x != 0 && y != 0) {
		x *= 0.70710678f;
		y *= 0.70710678f;
	}

	// Use the state captured by onPlayerInput. That hook runs before the
	// native bird movement code can discard Run input.
	const bool dashing = id < 4 && birdRideRunHeld[id];
	const float speed = dashing ? BIRD_DASH_SPEED : BIRD_SPEED;
	player.xSpeed = x * speed;
	player.ySpeed = y * speed;
	if (x < 0) player.direction = -1;
	if (x > 0) player.direction = 1;

	// Do not let the player's configured movement keys add a second input
	// on top of the arrow-key steering above.
	player.keyLeft = false;
	player.keyRight = false;
	player.keyUp = false;
	player.keyDown = false;
}

void cycleBirdWeapon(jjPLAYER@ player) {
	uint8 nextWeapon = player.currWeapon;
	for (int attempt = 0; attempt < 9; ++attempt) {
		nextWeapon = nextWeapon % 9 + 1;
		if (nextWeapon == WEAPON::BLASTER || player.ammo[nextWeapon] > 0) {
			player.currWeapon = nextWeapon;
			return;
		}
	}
}

bool dismountedRabbitNearPlayer(const jjPLAYER@ player) {
	for (int i = 0; i < 32; ++i) {
		const jjPLAYER@ other = jjPlayers[i];
		if (i == player.playerID || !other.isActive || !other.isInGame || other.health <= 0)
			continue;

		// Start the collision guard just before the native buttstomp reaches a
		// player. This leaves the move, animation, and physics entirely native.
		if (abs(other.xPos - player.xPos) <= 40.0f &&
			abs(other.yPos - player.yPos) <= 64.0f)
			return true;
	}
	return false;
}

void updateDismountedMeleeCollisionGuard(jjPLAYER@ player, uint id) {
	if (id >= 4)
		return;

	const bool shouldGuard = birdRideReady[id] && !birdRideMounted[id] &&
		player.health > 0 && player.buttstomp < 121 &&
		dismountedRabbitNearPlayer(player);
	if (shouldGuard) {
		// A positive blink value prevents player-to-player collision. Two ticks
		// are enough to cover contact without disturbing the native move state.
		birdRideMeleeCollisionGuard[id] = true;
		if (player.blink < 2)
			player.blink = 2;
	} else {
		if (birdRideMeleeCollisionGuard[id] && player.blink <= 2 &&
			birdRideWipeoutRecovery[id] <= 0)
			player.blink = 0;
		birdRideMeleeCollisionGuard[id] = false;
	}
}

void onPlayerInput(jjPLAYER@ player) {
	const uint id = player.localPlayerID;
	if (id < 4 && birdRideReady[id] && birdRideMounted[id]) {
		birdRideRunHeld[id] = player.keyRun || player.running;

		// Bird morphs do not run JJ2's native weapon-switch routine, so cycle
		// through Blaster and every weapon for which the player has ammo.
		const bool selectPressed = player.keySelect;
		if (!birdRideChatOpen && selectPressed && !birdRideSelectWasPressed[id])
			cycleBirdWeapon(player);
		birdRideSelectWasPressed[id] = selectPressed;
		player.keySelect = false;
	} else if (id < 4 && birdRideReady[id] && player.health > 0 &&
		player.keyDown && player.platform == 0 &&
		dismountedRabbitNearPlayer(player)) {
		// Prime the contact guard during native buttstomp startup. Do not alter
		// keyDown, buttstomp, specialMove, or movement: JJ2 performs the move.
		birdRideMeleeCollisionGuard[id] = true;
		if (player.blink < 2)
			player.blink = 2;
	}
}

void fireFromBird(jjPLAYER@ player) {
	const uint id = player.localPlayerID;
	// The Bird morph has no native out-of-ammo fallback. Never leave it stuck
	// on an empty weapon, including one selected through a number-key binding.
	if (int(player.currWeapon) != int(WEAPON::BLASTER) && player.ammo[player.currWeapon] <= 0)
		cycleBirdWeapon(player);

	const bool firePressed = player.keyFire;
	const bool newPress = firePressed && !birdRideFireWasPressed[id];
	const int style = int(jjWeapons[player.currWeapon].style);
	// POPCORN weapons (the normal toaster style) emit a continuous stream at
	// a fixed six-tick interval instead of using the rabbit's normal delay.
	const int repeatDelay = style == int(WEAPON::POPCORN) ? 6 :
		(player.fastfire > 0 ? player.fastfire : 1);

	if (birdRideFireCooldown[id] > 0)
		--birdRideFireCooldown[id];

	// Normal weapons may be tapped faster than their hold-fire delay, just
	// like Jazz and Spaz. CAPPED weapons still enforce that delay, while
	// MISSILE weapons fire only once per button press.
	const bool tapShot = newPress &&
		(style != int(WEAPON::CAPPED) || birdRideFireCooldown[id] <= 0);
	const bool heldShot = firePressed && !newPress &&
		style != int(WEAPON::MISSILE) && birdRideFireCooldown[id] <= 0;

	if (tapShot || heldShot) {
		// Bird morphs do not run the rabbit firing routine, so explicitly
		// fire the currently selected weapon with normal ammo depletion.
		const uint firstNewObject = jjObjectCount;
		const int bulletID = player.fireBullet(WEAPON::CURRENT, true, true, DIRECTION::CURRENT);
		if (bulletID > 0 && bulletID < 4096)
			birdRideBirdBullet[uint(bulletID)] = true;
		// Multi-projectile weapons may not return an object ID. Tag every new
		// player bullet created by this firing action as a SUPERBIRD shot.
		for (uint i = 1; i < jjObjectCount; ++i) {
			jjOBJ@ shot = jjObjects[i];
			if (shot.isActive && shot.creatorType == CREATOR::PLAYER &&
				shot.creatorID == player.playerID &&
				shot.playerHandling == HANDLING::PLAYERBULLET &&
				(i >= firstNewObject || shot.state == STATE::START))
				birdRideBirdBullet[i] = true;
		}
		birdRideFireCooldown[id] = repeatDelay;

		// Match normal rabbit behavior by leaving a weapon as soon as its final
		// round is spent, so the next Fire press always has a usable weapon.
		if (int(player.currWeapon) != int(WEAPON::BLASTER) && player.ammo[player.currWeapon] <= 0)
			cycleBirdWeapon(player);
	}
	birdRideFireWasPressed[id] = firePressed;

	// Leave keyFire intact so held and repeated presses can be tracked on
	// subsequent ticks. Bird form has no native firing path to duplicate.
}

string birdRideAmmoText(jjPLAYER@ player) {
	const uint8 weapon = player.currWeapon;
	if (jjWeapons[weapon].infinite)
		return "^"; // JJ2's infinity glyph

	// jjPLAYER::ammo already exposes the HUD-ready quantity. Applying the
	// weapon multiplier here again incorrectly turns ordinary Toaster ammo
	// into zero because Toaster uses a larger internal pickup multiplier.
	return "" + player.ammo[weapon];
}

bool birdRideIsMounted(const jjPLAYER@ player) {
	if (player is null || !player.isLocal)
		return false;
	const int id = player.localPlayerID;
	return id >= 0 && id < 4 && birdRideReady[id] && birdRideMounted[id];
}

void preparePickupBlocking() {
	for (int i = 1; i < jjObjectCount; ++i) {
		jjOBJ@ obj = jjObjects[i];
		if (!obj.isActive) {
			if (i < 4096) {
				birdRideForcedPickupObject[i] = false;
				birdRideForcedPowerupObject[i] = false;
				birdRideBirdBullet[i] = false;
			}
			continue;
		}
		if (i < 4096 && obj.playerHandling != HANDLING::PLAYERBULLET)
			birdRideBirdBullet[i] = false;

		if (obj.playerHandling == HANDLING::PICKUP) {
			if (!obj.scriptedCollisions) {
				obj.scriptedCollisions = true;
				if (i < 4096) birdRideForcedPickupObject[i] = true;
			} else if (birdRideForcedPickupPreset[obj.eventID] && i < 4096) {
				birdRideForcedPickupObject[i] = true;
			}
		}

		if (birdRideIsWeaponPowerup(obj.eventID)) {
			if (!obj.scriptedCollisions) {
				obj.scriptedCollisions = true;
				if (i < 4096) birdRideForcedPowerupObject[i] = true;
			} else if (birdRideForcedPowerupPreset[obj.eventID] && i < 4096) {
				birdRideForcedPowerupObject[i] = true;
			}
		}
	}
}

int riderAnimSet(CHAR::Char character) {
	if (character == CHAR::SPAZ) return ANIM::SPAZ;
	if (jjIsTSF && character == CHAR::LORI) return ANIM::LORI;
	return ANIM::JAZZ;
}

uint mountedRiderFrame(CHAR::Char character) {
	const jjANIMATION@ anim = jjAnimations[
		jjAnimSets[riderAnimSet(character)].firstAnim + RABBIT::STAND
	];
	return anim.firstFrame + ((jjGameTicks / 7) % anim.frameCount);
}

uint mountedBirdFrame() {
	const jjANIMATION@ flight = jjAnimations[
		jjAnimSets[ANIM::BIRD].firstAnim + BIRD_FLIGHT_ANIM
	];
	return flight.firstFrame + ((jjGameTicks / 6) % flight.frameCount);
}

bool mountedBirdsCollide(const jjPLAYER@ first, const jjPLAYER@ second) {
	const jjANIMFRAME@ frame = jjAnimFrames[mountedBirdFrame()];
	return frame.doesCollide(
		int(first.xPos), int(first.yPos), first.direction,
		frame, int(second.xPos), int(second.yPos), second.direction, false
	);
}

float birdRideSpeedSquared(const jjPLAYER@ player) {
	return player.xSpeed * player.xSpeed + player.ySpeed * player.ySpeed;
}

void processMountedBirdCollisions() {
	array<bool> shouldWipe(4, false);
	array<int> knockDirection(4, 1);

	// Decide every local outcome before changing any character. This makes an
	// equal-speed split-screen collision wipe out both birds simultaneously.
	for (int localID = 0; localID < jjLocalPlayerCount && localID < 4; ++localID) {
		jjPLAYER@ player = jjLocalPlayers[localID];
		if (!player.isActive || !player.isInGame || player.health <= 0 ||
			!birdRideReady[localID] || !birdRideMounted[localID] ||
			player.charCurr != CHAR::BIRD)
			continue;

		const float playerSpeed = birdRideSpeedSquared(player);
		for (int otherID = 0; otherID < 32; ++otherID) {
			jjPLAYER@ other = jjPlayers[otherID];
			if (other.playerID == player.playerID || !other.isActive ||
				!other.isInGame || other.health <= 0 ||
				other.charCurr != CHAR::BIRD ||
				!mountedBirdsCollide(player, other))
				continue;

			const float otherSpeed = birdRideSpeedSquared(other);
			// Merely sharing a spawn point while both birds are stationary is not an
			// impact. This prevents an automatic wipeout at level start.
			if (playerSpeed < 0.01f && otherSpeed < 0.01f)
				continue;
			// The slower bird loses. Within a tiny tolerance the speeds count as
			// tied, so both clients independently apply a double wipeout.
			if (playerSpeed <= otherSpeed + 0.01f) {
				shouldWipe[localID] = true;
				if (player.xPos < other.xPos)
					knockDirection[localID] = -1;
				else if (player.xPos > other.xPos)
					knockDirection[localID] = 1;
				else
					knockDirection[localID] = player.direction < 0 ? 1 : -1;
				break;
			}
		}
	}

	for (int localID = 0; localID < jjLocalPlayerCount && localID < 4; ++localID) {
		if (shouldWipe[localID])
			beginBirdWipeout(jjLocalPlayers[localID], localID, knockDirection[localID]);
	}
}

void drawMountedRider(jjPLAYER@ player, CHAR::Char character) {
	// Use a board-free rabbit frame. The bird is drawn afterward so it
	// remains in front of the rider's feet.
	const uint frame = mountedRiderFrame(character);
	SPRITE::Mode spriteMode = SPRITE::PLAYER;
	int spriteParam = player.playerID;
	if (player.isLocal) {
		const int localID = player.localPlayerID;
		if (localID >= 0 && localID < 4 && birdRideColorMappingID[localID] >= 0) {
			spriteMode = SPRITE::MAPPING;
			spriteParam = birdRideColorMappingID[localID];
		}
	}

	jjDrawSpriteFromCurFrame(
		player.xPos,
		player.yPos + RIDER_Y_OFFSET,
		frame,
		player.direction,
		spriteMode,
		spriteParam
	);
}

void applyMountedShotWipeouts(jjPLAYER@ player, uint id) {
	if (id >= 4 || !birdRideReady[id] || !birdRideMounted[id] ||
		player.charCurr != CHAR::BIRD || player.health <= 0)
		return;

	// JJ2's native collision sees only the small Bird sprite. Treat the entire
	// visible rider-and-bird combination as one solid target instead, including
	// transparent gaps between limbs, wings, and tail. Enemy shots knock the
	// rider off without reducing the SUPERBIRD's stored health.
	for (int i = 1; i < jjObjectCount; ++i) {
		jjOBJ@ bullet = jjObjects[i];
		if (!bullet.isActive || bullet.state == STATE::START)
			continue;

		const bool enemyShot = bullet.playerHandling == HANDLING::ENEMYBULLET ||
			((bullet.playerHandling == HANDLING::PLAYERBULLET ||
			bullet.playerHandling == HANDLING::PARTICLE) &&
			bullet.creatorType == CREATOR::PLAYER && bullet.creatorID < 32 &&
			jjPlayers[bullet.creatorID].isActive &&
			jjPlayers[bullet.creatorID].isEnemy(player));
		if (!enemyShot)
			continue;

		const float relativeX = bullet.xPos - player.xPos;
		const float relativeY = bullet.yPos - player.yPos;
		const float previousRelativeX = relativeX - bullet.xSpeed;
		const float previousRelativeY = relativeY - bullet.ySpeed;
		const bool insideNow =
			abs(relativeX) <= MOUNTED_HITBOX_HALF_WIDTH &&
			relativeY >= MOUNTED_HITBOX_TOP &&
			relativeY <= MOUNTED_HITBOX_BOTTOM;
		const bool insidePrevious =
			abs(previousRelativeX) <= MOUNTED_HITBOX_HALF_WIDTH &&
			previousRelativeY >= MOUNTED_HITBOX_TOP &&
			previousRelativeY <= MOUNTED_HITBOX_BOTTOM;
		if (insideNow || insidePrevious) {
			int knockDirection;
			if (bullet.xSpeed > 0.25f)
				knockDirection = 1;
			else if (bullet.xSpeed < -0.25f)
				knockDirection = -1;
			else
				knockDirection = bullet.xPos < player.xPos ? 1 : -1;

			// Consume the impact just like a normal player hit, but replace damage
			// with the same fall-off and five-second cooldown used by bird crashes.
			bullet.state = STATE::EXPLODE;
			beginBirdWipeout(player, id, knockDirection);
			break;
		}
	}
}

void drawMountedBird(jjPLAYER@ player) {
	// Draw the flight loop explicitly. Native Bird logic switches to a standing
	// animation on masked ground, which visually separates it from the rider.
	const uint frame = mountedBirdFrame();
	SPRITE::Mode spriteMode = SPRITE::PLAYER;
	int spriteParam = player.playerID;
	if (player.isLocal) {
		const int localID = player.localPlayerID;
		if (localID >= 0 && localID < 4 && birdRideColorMappingID[localID] >= 0) {
			spriteMode = SPRITE::MAPPING;
			spriteParam = birdRideColorMappingID[localID];
		}
	}

	jjDrawSpriteFromCurFrame(
		player.xPos,
		player.yPos,
		frame,
		player.direction,
		spriteMode,
		spriteParam
	);
}

void drawWaitingBird(jjPLAYER@ player, uint localID) {
	const uint frame = mountedBirdFrame();
	const bool hasMapping = localID < 4 && birdRideColorMappingID[localID] >= 0;
	if (localID < 4 && birdRideWipeoutActive[localID]) {
		const int direction = birdRideWipeoutXSpeed[localID] < 0 ? -1 : 1;
		jjDrawRotatedSpriteFromCurFrame(
			birdRideWipeoutX[localID],
			birdRideWipeoutY[localID],
			frame,
			birdRideWipeoutAngle[localID],
			float(direction),
			1.0f,
			hasMapping ? SPRITE::MAPPING : SPRITE::PLAYER,
			hasMapping ? birdRideColorMappingID[localID] : player.playerID
		);
		return;
	}

	const float side = player.direction < 0 ? 27.0f : -27.0f;
	const float bob = jjSin(jjGameTicks * 12) * 2.0f;
	const int direction = side < 0 ? 1 : -1;

	jjDrawSpriteFromCurFrame(
		player.xPos + side,
		player.yPos - 16.0f + bob,
		frame,
		direction,
		hasMapping ? SPRITE::MAPPING : SPRITE::PLAYER,
		hasMapping ? birdRideColorMappingID[localID] : player.playerID
	);
}

void showCarrotPopup(jjPLAYER@ player, const string &in text) {
	const uint id = player.localPlayerID;
	if (id >= 4)
		return;

	birdRideCarrotPopupText[id] = text;
	birdRideCarrotPopupTimer[id] = 70;
	birdRideCarrotPopupX[id] = player.xPos;
	birdRideCarrotPopupY[id] = player.yPos - 28.0f;
}

void drawCarrotPopups() {
	for (uint id = 0; id < 4; ++id) {
		if (birdRideCarrotPopupTimer[id] <= 0)
			continue;

		const int elapsed = 70 - birdRideCarrotPopupTimer[id];
		const float centerOffset = birdRideCarrotPopupText[id] == "FULL NRG" ? 27.0f : 7.0f;
		jjDrawString(
			birdRideCarrotPopupX[id] - centerOffset,
			birdRideCarrotPopupY[id] - elapsed * 0.35f,
			birdRideCarrotPopupText[id],
			STRING::SMALL,
			STRING::NORMAL
		);
		--birdRideCarrotPopupTimer[id];
	}
}

void onMain() {
	// Do this before player collision handling every tick, including for
	// pickups created dynamically by crates, generators, or other scripts.
	preparePickupBlocking();

	// Initialize every local player as soon as its player object is ready.
	for (int i = 0; i < jjLocalPlayerCount; ++i) {
		jjPLAYER@ player = jjLocalPlayers[i];
		if (player.isActive && player.isInGame) {
			prepareBirdRide(player);
			if (birdRideReady[i])
				enforceBirdRideFur(player, i);
		}
	}

	for (int i = 0; i < jjLocalPlayerCount && i < 4; ++i) {
		if (birdRideMountCooldown[i] > 0)
			--birdRideMountCooldown[i];
		if (birdRideTransformCooldown[i] > 0)
			--birdRideTransformCooldown[i];
		if (birdRideWipeoutRecovery[i] > 0)
			--birdRideWipeoutRecovery[i];
		jjPLAYER@ player = jjLocalPlayers[i];
		if (player.isActive && player.isInGame)
			updateDismountedMeleeCollisionGuard(player, i);
		updateBirdWipeout(i);
	}
	processMountedBirdCollisions();

	// Track the native chat prompt so letters typed into it never trigger
	// gameplay actions. Chat opens with the configured key and closes with
	// Enter or Escape.
	if (!birdRideChatOpen && jjKeyChat != 0 && jjKey[jjKeyChat])
		birdRideChatOpen = true;
	else if (birdRideChatOpen && (jjKey[KEY_ENTER] || jjKey[KEY_ESCAPE]))
		birdRideChatOpen = false;

	// F controls the first local player. This avoids one keyboard press
	// toggling every player in a split-screen game at the same time.
	const bool fIsDown = jjKey[KEY_F];
	if (!birdRideChatOpen && fIsDown && !birdRideFWasDown && jjLocalPlayerCount > 0) {
		jjPLAYER@ player = jjLocalPlayers[0];
		if (player.isActive && player.isInGame && player.health > 0)
			toggleBirdRide(player);
	}
	birdRideFWasDown = fIsDown;

	// This engine-level setting is the authoritative guard. IGNOREBULLET
	// leaves both the monitor and projectile completely unaffected.
	updateWeaponPowerupBulletHandling();

	// Any enemy shot touching the visible rider-and-bird area causes the local
	// victim to wipe out. Mounted invincibility prevents the native Bird hitbox
	// from losing health before this larger collision check runs.
	for (int i = 0; i < jjLocalPlayerCount && i < 4; ++i) {
		jjPLAYER@ player = jjLocalPlayers[i];
		if (player.isActive && player.isInGame)
			applyMountedShotWipeouts(player, i);
	}

	// Native bird form supplies collision and multiplayer position sync.
	// Draw the original rabbit on top to turn it into a visible mount.
	for (int i = 0; i < 32; ++i) {
		jjPLAYER@ player = jjPlayers[i];
		if (player.isActive && player.isInGame && player.charCurr == CHAR::BIRD) {
			drawMountedRider(player, rabbitCharacter(player));
			drawMountedBird(player);
		}
	}

	// The dismounted bird is a local visual companion beside its rider.
	for (int i = 0; i < jjLocalPlayerCount; ++i) {
		jjPLAYER@ player = jjLocalPlayers[i];
		if (player.isActive && player.isInGame && birdRideReady[i] && !birdRideMounted[i])
			drawWaitingBird(player, i);
	}

	drawCarrotPopups();
}

void onPlayer(jjPLAYER@ player) {
	const uint id = player.localPlayerID;
	if (id < 4 && birdRideReady[id]) {
		// Do not steer or fire during the roast/death sequence. Applying mounted
		// movement to the temporary respawn rabbit causes slow falling and broken
		// jumping after the player comes back.
		if (player.health <= 0) {
			if (birdRideMounted[id])
				birdRideNeedsRespawnReset[id] = true;
			player.noFire = true;
			birdRideFireCooldown[id] = 0;
			birdRideFireWasPressed[id] = false;
			birdRideSelectWasPressed[id] = false;
			birdRideRunHeld[id] = false;
			birdRideMeleeCollisionGuard[id] = false;
			return;
		}

		// A respawn changes charCurr back to charOrig. Restore the mounted Bird
		// before any custom movement is applied to the newly alive player.
		if (birdRideMounted[id] &&
			(birdRideNeedsRespawnReset[id] || player.charCurr != CHAR::BIRD))
			restoreMountedBirdAfterRespawn(player, id);

		// Native character logic may rewrite fur after morphTo returns. Enforce
		// the saved packed palette every tick, before any sprites are queued.
		enforceBirdRideFur(player, id);
		player.noFire = !birdRideMounted[id];
		if (birdRideMounted[id]) {
			// Keep the native Bird body immune at every tick. Projectile overlap is
			// converted into a wipeout by applyMountedShotWipeouts instead.
			player.invincibility = -2;
			clearBirdRideFlightState(player);
			steerBird(player);
			fireFromBird(player);
		} else {
			// Special moves remain completely native but harmless through canHurt.
			// Buttstomps also remain native; the short proximity guard in onMain
			// suppresses only their player collision without touching movement state.
			jjCharacters[player.charCurr].canHurt = false;
			// Clear only for the first few post-morph ticks, catching delayed
			// multiplayer state without disabling later rabbit flight pickups.
			if (birdRideFlightStateCleanup[id] > 0) {
				clearBirdRideFlightState(player);
				--birdRideFlightStateCleanup[id];
			}
			// Wipeouts use JJ2's native blinking state for exactly one second.
			// Reassert the remaining duration in case another behavior shortens it.
			if (birdRideWipeoutRecovery[id] > 0 &&
				player.blink < birdRideWipeoutRecovery[id])
				player.blink = birdRideWipeoutRecovery[id];
			if (birdRideWipeoutRecovery[id] > 0) {
				// Keep normal air/ground physics, but cap horizontal movement while
				// the rider is dizzy so recovery movement feels slightly sluggish.
				if (player.xSpeed > BIRD_WIPEOUT_MAX_X_SPEED)
					player.xSpeed = BIRD_WIPEOUT_MAX_X_SPEED;
				else if (player.xSpeed < -BIRD_WIPEOUT_MAX_X_SPEED)
					player.xSpeed = -BIRD_WIPEOUT_MAX_X_SPEED;
			}
			// Carrots may be collected as a rabbit, but rabbit form can never
			// hold more than one heart. Leave zero alone so damage is lethal.
			if (player.health > 1)
				player.health = 1;
			player.keyFire = false;
			birdRideFireWasPressed[id] = false;
		}
	}
}

void onRoast(jjPLAYER@ victim, jjPLAYER@ killer) {
	if (!victim.isLocal)
		return;
	const uint id = victim.localPlayerID;
	if (id < 4 && birdRideReady[id] && birdRideMounted[id]) {
		// Defer morphing until the game has completed its normal respawn. Morphing
		// during onRoast can interfere with the death animation and scoring.
		birdRideNeedsRespawnReset[id] = true;
		birdRideFireCooldown[id] = 0;
		birdRideFireWasPressed[id] = false;
		birdRideSelectWasPressed[id] = false;
		birdRideRunHeld[id] = false;
	}
}

bool onDrawAmmo(jjPLAYER@ player, jjCANVAS@ canvas) {
	const uint id = player.localPlayerID;
	if (id < 4 && birdRideReady[id] && birdRideMounted[id]) {
		// Recreate the normal Jazz/Spaz lower-right HUD: animated weapon
		// profile icon, then "x" and the adjusted ammo quantity.
		int weaponIndex = int(player.currWeapon) - 1;
		if (weaponIndex < 0 || weaponIndex >= 9)
			weaponIndex = 0;
		uint8 iconAnimation = BIRD_RIDE_WEAPON_ICONS[weaponIndex];
		if (weaponIndex == 0 && birdRideCharacter[id] == CHAR::SPAZ)
			++iconAnimation;
		const jjANIMATION@ icon = jjAnimations[jjAnimSets[ANIM::AMMO].firstAnim + iconAnimation];
		const uint8 frame = uint8((jjRenderFrame >> 2) % icon.frameCount);
		const int y = jjSubscreenHeight - 14;

		canvas.drawSprite(
			jjSubscreenWidth - 90,
			y,
			ANIM::AMMO,
			iconAnimation,
			frame
		);
		canvas.drawString(
			jjSubscreenWidth - 80,
			y,
			"x" + birdRideAmmoText(player),
			STRING::MEDIUM,
			STRING::NORMAL
		);
		return true;
	}
	return false;
}

void onObjectHit(jjOBJ@ obj, jjOBJ@ bullet, jjPLAYER@ player, int force) {
	if (obj is null || obj.objectID <= 0 || obj.objectID >= 4096)
		return;

	if (birdRideForcedPowerupObject[obj.objectID] &&
		birdRideIsWeaponPowerup(obj.eventID)) {
		// SUPERBIRD bullets pass through weapon-powerup monitors without
		// damaging them or granting their powerup.
		if (birdRideBulletCameFromBird(bullet))
			return;

		// Preserve normal monitor behavior for bullets fired as a rabbit.
		obj.scriptedCollisions = false;
		if (bullet !is null)
			bullet.objectHit(obj, HANDLING::SPECIAL);
		else if (player !is null)
			player.objectHit(
				obj,
				birdRideRabbitMonitorHitForce(player, obj, force),
				HANDLING::SPECIAL
			);

		if (obj.isActive && birdRideIsWeaponPowerup(obj.eventID)) {
			obj.scriptedCollisions = true;
			birdRideForcedPowerupObject[obj.objectID] = true;
		} else {
			birdRideForcedPowerupObject[obj.objectID] = false;
		}
		return;
	}

	if (player is null)
		return;
	if (!birdRideForcedPickupObject[obj.objectID] ||
		obj.playerHandling != HANDLING::PICKUP)
		return;

	// A mounted bird consumes the collision, not the item. This deliberately
	// leaves every player property and the pickup object completely unchanged.
	if (birdRideIsMounted(player))
		return;

	const uint id = player.localPlayerID;
	const bool isCarrot = obj.eventID == OBJECT::CARROT;
	const bool isFullEnergy = obj.eventID == OBJECT::FULLENERGY;
	const bool isHealthPickup = isCarrot || isFullEnergy;

	// Judge carrots by the saved SUPERBIRD health, not the rabbit's one-heart
	// limit. A full bird leaves carrots in place, just like a full rabbit.
	if (isHealthPickup && id < 4 && int(birdRideStoredHealth[id]) >= jjMaxHealth)
		return;

	// Jazz/Spaz should collect the same object normally after dismounting.
	// Temporarily bypass this scripted interceptor for that one collision.
	obj.scriptedCollisions = false;
	const bool collected = player.objectHit(obj, 0, HANDLING::PICKUP);

	if (isHealthPickup && id < 4 && collected) {
		if (isFullEnergy) {
			birdRideStoredHealth[id] = uint8(jjMaxHealth);
			showCarrotPopup(player, "FULL NRG");
		} else {
			const int newHealth = int(birdRideStoredHealth[id]) + 1;
			birdRideStoredHealth[id] = uint8(newHealth > jjMaxHealth ? jjMaxHealth : newHealth);
			showCarrotPopup(player, "+1");
		}

		// The health belongs to the SUPERBIRD. Rabbit form stays at one heart.
		if (player.health > 1)
			player.health = 1;
	}

	if (obj.isActive && obj.playerHandling == HANDLING::PICKUP) {
		obj.scriptedCollisions = true;
		birdRideForcedPickupObject[obj.objectID] = true;
	} else {
		birdRideForcedPickupObject[obj.objectID] = false;
	}
}

void onPlayerDraw(jjPLAYERDRAW& draw) {
	// This is the final hook before the native player sprite is rendered.
	// Reassert the exact same palette for the dismounted rabbit as well.
	if (draw.player.isLocal) {
		const int colorID = draw.player.localPlayerID;
		if (colorID >= 0 && colorID < 4 && birdRideReady[colorID]) {
			enforceBirdRideFur(draw.player, colorID);
			if (birdRideColorMappingID[colorID] >= 0) {
				if (draw.spriteMode == SPRITE::PLAYER)
					draw.spriteMode = SPRITE::MAPPING;
				else if (draw.spriteMode == SPRITE::TRANSLUCENTPLAYER)
					draw.spriteMode = SPRITE::TRANSLUCENTMAPPING;
				draw.spriteParam = uint8(birdRideColorMappingID[colorID]);
			}
		}
	}

	// The two-tick buttstomp collision guard is mechanical, not a wipeout.
	// Keep the native move sprite visible instead of flashing it.
	if (draw.player.isLocal && draw.player.charCurr != CHAR::BIRD) {
		const int guardID = draw.player.localPlayerID;
		if (guardID >= 0 && guardID < 4 &&
			birdRideMeleeCollisionGuard[guardID] && draw.player.blink <= 2 &&
			birdRideWipeoutRecovery[guardID] <= 0)
			draw.sprite = true;
	}

	// For the local rider, require our explicit wipeout timer instead of JJ2's
	// generic blink flag: morphing during a normal F dismount may briefly set it.
	// Remote riders use the synchronized blink value; voluntary dismounts clear
	// that value in dismountBird above.
	bool drawWipeoutDizzy = false;
	if (draw.player.charCurr != CHAR::BIRD) {
		if (draw.player.isLocal) {
			const int recoveryID = draw.player.localPlayerID;
			drawWipeoutDizzy = recoveryID >= 0 && recoveryID < 4 &&
				birdRideWipeoutRecovery[recoveryID] > 0;
		} else {
			drawWipeoutDizzy = draw.player.blink > 0;
		}
	}
	if (drawWipeoutDizzy) {
		const jjANIMATION@ dizzy = jjAnimations[
			jjAnimSets[draw.player.setID].firstAnim + RABBIT::STONED
		];
		if (dizzy.frameCount > 0)
			draw.curFrame = dizzy.firstFrame +
				((jjGameTicks / 5) % dizzy.frameCount);
	}

	// Replace the native bird sprite with our explicitly ordered pair:
	// rider first, bird second. Other player effects and names remain.
	if (draw.player.charCurr == CHAR::BIRD) {
		draw.sprite = false;
		// The rider sits above the bird's native origin, so move the native
		// player name high enough to clear the rider's head.
		draw.yOffset += MOUNTED_NAME_Y_OFFSET;
	}
}