Downloads containing sk8er.mut

Downloads
Name Author Game Mode Rating
TSF with JJ2+ Only: Sk8er Bunni Lark Mutator N/A Download file

File preview

#pragma name "Sk8er Bunni"

const int TICKS_PER_SECOND = 70;
const int MINIMUM_PICKUP_POINTS = 100;
const int HURT_TIME_PENALTY = 5 * TICKS_PER_SECOND;
const int REVERSE_GRAVITY_DURATION = 10 * TICKS_PER_SECOND;
const int FAST_FEET_SCORE = 1000;
const int GOLD_WEAPON_GEM_SCORE = 5000;
const uint8 GOLD_GEM_PALETTE_START = 40;
const int BURGER_COLLECTED_MARKER = 24681357;
const uint8 PACKET_CHALLENGE_SCORE = 167;
const uint8 PACKET_ROUND_STATE = 168;
const uint8 PACKET_ROUND_TIME_DELTA = 169;
const uint8 PACKET_BANANA_THROW = 170;
const uint8 PACKET_BANANA_WIPEOUT = 171;
const uint8 PACKET_BOMB_THROW = 172;
const uint8 PACKET_BOMB_EXPLODE = 173;
const uint8 PACKET_PLAYER_JOINED = 174;
const uint8 PACKET_BOMB_GEM_DROP = 175;
const uint8 PACKET_BOMB_GEM_COLLECT = 176;
const int SCORE_SYNC_INTERVAL = TICKS_PER_SECOND;
const int ROUND_STATE_SYNC_INTERVAL = TICKS_PER_SECOND / 2;
const int ROUND_DURATION = 60 * TICKS_PER_SECOND;
const int AUTO_RESTART_DURATION = 10 * TICKS_PER_SECOND;

const int BANANA_OBJECT_MARKER = 731905;
const int BANANA_LIFETIME = 12 * TICKS_PER_SECOND;
const int BANANA_ARM_TICKS = 8;
const int MAX_BANANAS_PER_PLAYER = 8;
const int STARTING_BANANA_AMMO = 5;
const int MAX_BANANA_AMMO = 20;
const float BANANA_THROW_X_SPEED = 5.5f;
const float BANANA_THROW_Y_SPEED = 5.25f;
const float BANANA_GRAVITY = 0.22f;
const float BANANA_MAX_FALL_SPEED = 8.0f;

// Event 43 is JJ2's Bomb object. Switch Weapon throws these custom bombs.
const uint8 SKATE_BOMB_EVENT = 43;
const int BOMB_OBJECT_MARKER = 731906;
const int BOMB_AMMO_PICKUP_MARKER = 731907;
const int WEAPON_POWERUP_GOLD_GEM_MARKER = 731908;
const int BOMB_LIFETIME = 5 * TICKS_PER_SECOND;
const int MAX_BOMBS_PER_PLAYER = 4;
const int STARTING_BOMB_AMMO = 5;
const int MAX_BOMB_AMMO = 10;
const int BOMB_EXPLOSION_HISTORY = 16;
const float BOMB_THROW_X_SPEED = 5.0f;
const float BOMB_MAX_NETWORK_X_SPEED = 48.0f;
const float BOMB_THROW_Y_SPEED = 4.5f;
const float BOMB_GRAVITY = 0.28f;
const float BOMB_MAX_FALL_SPEED = 9.0f;
const float BOMB_BLAST_RADIUS = 8.0f * 32.0f;
const float BOMB_KNOCKBACK_X_SPEED = 6.0f;
const float BOMB_KNOCKBACK_Y_SPEED = 3.5f;

const int BOMB_GEM_OBJECT_MARKER = 731908;
const int BOMB_GEM_LIFETIME = 10 * TICKS_PER_SECOND;
const int BOMB_GEM_FLIGHT_TICKS = 35;
const int BOMB_GEM_MIN_COUNT = 5;
const int BOMB_GEM_MAX_COUNT = 10;
const float BOMB_GEM_MIN_DISTANCE = 3.0f * 32.0f;
const float BOMB_GEM_MAX_DISTANCE = 5.0f * 32.0f;
const int BOMB_GEM_MASK_RADIUS = 4;
const float BOMB_GEM_BOUNCE_RETENTION = 0.88f;

const float NORMAL_SPEED = 12.0f;
const float DOWNHILL_SPEED = 18.0f;
const float SLIDE_EVENT_SPEED = 26.0f;

const float DOWNHILL_ACCELERATION = 0.30f;
const float DOWNHILL_START_SPEED = 2.50f;
const float SLIDE_EVENT_ACCELERATION = 0.85f;
const float CONTINUOUS_COAST_MIN_SPEED = 4.0f;
const float AIR_DIRECTION_LOCK_THRESHOLD = 0.50f;

const float WALL_JUMP_X_SPEED = 11.5f;
const float WALL_JUMP_Y_SPEED = -8.5f;

const int GROUND_DUST_INTERVAL = 3;
const float GROUND_DUST_MIN_SPEED = 1.25f;

const int ENEMY_BOUNCE_SCORE = 25;
const float ENEMY_BOUNCE_SPEED = 8.0f;
const int ENEMY_BOUNCE_COOLDOWN = 12;
const int ENEMY_ROCK_TICKS = 24;

const int ONE_SPIN_TICKS = 18;
const int WIPEOUT_DURATION = TICKS_PER_SECOND;
const int WIPEOUT_LYING_TICKS = 20;
const int UPRIGHT_ANGLE_TOLERANCE = 64;

// Wheel appearance and placement.
const float WHEEL_SCALE = 0.28f;
const float WHEEL_X_OFFSET = 11.0f;
const float WHEEL_Y_OFFSET = 30.0f;
const float WHEEL_ROTATION_PER_PIXEL = 46.0f;
const uint8 WHEEL_COLOR_RANGE = 72;

enum TimedRunState {
	RUN_COUNTDOWN,
	RUN_PLAYING,
	RUN_RESULTS
}

TimedRunState runState = RUN_COUNTDOWN;
int countdownTicks = 5 * TICKS_PER_SECOND;
int resultTicks = 0;
int autoRestartTicks = AUTO_RESTART_DURATION;
bool restartRequested = false;
int jazzVoiceCooldown = 0;
int multiplayerScoreSyncTicks = 0;
int roundStateSyncTicks = 0;

uint wheelFrame = 0;

array<bool> scoreInitialized(32, false);
array<int> challengeScore(32, 0);
array<bool> challengeJoined(32, false);
array<bool> playerConnectionKnown(32, false);
array<int> knownPlayerClientID(32, -1);
// This list belongs to the mutator module, so it survives level cycles and
// /r restarts. Player-list slots do not: the same connection may be assigned
// a different slot between rounds.
array<int> instructionJoinedClientIDs;
array<bool> previousInstructionFire(32, false);
array<bool> instructionJoinRequested(32, false);
array<bool> roundParticipant(32, false);
array<bool> playerFinished(32, false);
array<int> playerTimeLeftTicks(32, 0);
array<bool> previousJump(32, false);
array<bool> previousFire(32, false);
array<bool> previousMovementHeld(32, false);
array<bool> previousUppercut(32, false);
array<bool> previousWeaponSwitch(32, false);
array<bool> bombSwitchRequested(32, false);
array<bool> previousButtstomp(32, false);
array<bool> hurtStateInitialized(32, false);
array<uint8> previousHealth(32, 0);
array<int> previousBlink(32, 0);
array<int> reverseGravityTicks(32, 0);
array<int> platformBalanceTicks(32, 0);

array<int> runInertia(32, 0);
array<int> coastTicks(32, 0);
array<int> coastDirection(32, 1);
array<float> coastSpeed(32, 0.0f);
array<float> coastDeceleration(32, 0.0f);
array<bool> continuousCoastActive(32, false);

array<int> wallJumpCooldown(32, 0);
array<int> pendingGroundJumpTicks(32, 0);
array<int> observedSlopeDirection(32, 0);
array<int> observedSlopeTicks(32, 0);
array<float> previousSlopeX(32, 0.0f);
array<float> previousSlopeY(32, 0.0f);
array<bool> slopePositionKnown(32, false);

array<int> springScoreCooldown(32, 0);
array<int> lastScoredSpringObject(32, -1);
array<int> springMovementProtectionTicks(32, 0);
array<float> previousNativeXSpeed(32, 0.0f);
array<float> previousNativeYSpeed(32, 0.0f);
array<bool> nativeSpeedKnown(32, false);

array<int> visualSpinTicks(32, 0);
array<int> visualSpinTotalTicks(32, 0);
array<int> enemyBounceCooldown(32, 0);
array<int> enemyRockTicks(32, 0);
array<int> wipeoutTicks(32, 0);
array<bool> previousGrounded(32, false);
array<bool> groundStateInitialized(32, false);
array<string> wipeoutText(32, "");

array<int> lastSlideEventTile(32, -1);

array<int> phraseTicks(32, 0);
array<string> phraseText(32, "");
array<float> phraseX(32, 0.0f);
array<float> phraseY(32, 0.0f);
array<float> phraseXSpeed(32, 0.0f);

array<int> wheelAngle(32, 0);
array<float> previousWheelX(32, 0.0f);
array<bool> wheelPositionKnown(32, false);
array<int> groundDustCooldown(32, 0);
array<int> bananaSequence(32, 0);
array<int> bananaAmmo(32, STARTING_BANANA_AMMO);
array<int> bombSequence(32, 0);
array<int> bombAmmo(32, STARTING_BOMB_AMMO);
array<int> bombGemDropSequence(32, 0);
array<int> appliedBombExplosionSequence(
	32 * BOMB_EXPLOSION_HISTORY,
	0
);

// Populated from the native object presets. Only ordinary enemies are marked;
// bosses, boss parts, projectiles, and environmental hazards are excluded.
array<bool> skateboardBounceEnemyEvent(256, false);

array<string> coolPhrases = {
	"Cool tricks!",
	"Gnarly!",
	"Awesome!",
	"Dude!",
	"Whoa!",
	"Sweet!",
	"Radical!",
	"Totally rad!",
	"Nice one!"
};

array<string> firePhrases = {
	"Oi oi oi!",
	"Up the punx!",
	"Pick it up!",
	"Ska's not dead!",
	"Hey! Ho! Let's go!",
	"Gabba Gabba Hey!",
	"Unity!",
	"Rude boy!",
	"Rude girl!"
};

array<string> instructionLines = {
	"1. Collect as many points",
	"2. Collect BANANAs",
	"3. Press FIRE to throw",
	"4. Press CHANGE WEAPON",
	"5. Press JUMP",
	"6. Grab yourself an",
	"7. Be careful when",
	"8. Grab CLOCKS"
};

array<string> instructionDetailLines = {
	"as you can before the timer",
	"and BOMBs",
	"bananas at your foes.",
	"to throw a bomb.",
	"while you are in the air",
	"ANTI GRAVITY",
	"uppercutting and",
	"to receive"
};

array<string> instructionLastLines = {
	"runs out.",
	"as ammo!",
	"Total wipe out, dude.",
	"Kaboom, bro.",
	"to do COOL TRICKS!",
	"BURGER!",
	"buttstomping!",
	"extra time!"
};

array<uint8> instructionColors = {
	2, 3, 4, 5, 6, 7, 8, 9
};

array<string> wipeoutPhrases = {
	"Watch it!",
	"Wipe out!",
	"Ouch!",
	"Bummer dude!",
	"WIPE OUT!",
	"Wipe out!!"
};

array<uint8> ammoGemColors = {
	OBJECT::REDGEM,
	OBJECT::PURPLEGEM,
	OBJECT::GREENGEM,
	OBJECT::BLUEGEM
};

float absoluteFloat(float value) {
	return value < 0.0f ? -value : value;
}

int signOf(float value) {
	if (value < 0.0f) return -1;
	if (value > 0.0f) return 1;
	return 0;
}

float limitSpeed(float value, float maximum) {
	if (value > maximum) return maximum;
	if (value < -maximum) return -maximum;
	return value;
}

int playerSlot(const jjPLAYER@ player) {
	int id = int(player.playerID);
	if (id < 0 || id >= 32) return 0;
	return id;
}

void configureScriptedPickup(uint8 eventID) {
	jjObjectPresets[eventID].scriptedCollisions = true;
}

int scoreForPickup(const jjOBJ@ obj) {
	int points = int(obj.points);
	if (points <= 0)
		points = MINIMUM_PICKUP_POINTS;
	return points;
}

int scoreForSpring(uint8 eventID) {
	if (eventID == OBJECT::REDSPRING ||
		eventID == OBJECT::HORREDSPRING)
		return 100;

	if (eventID == OBJECT::GREENSPRING ||
		eventID == OBJECT::HORGREENSPRING)
		return 200;

	if (eventID == OBJECT::BLUESPRING ||
		eventID == OBJECT::HORBLUESPRING)
		return 300;

	return 0;
}

float speedForSpring(uint8 eventID) {
	if (eventID == OBJECT::REDSPRING ||
		eventID == OBJECT::HORREDSPRING)
		return 16.0f;

	if (eventID == OBJECT::GREENSPRING ||
		eventID == OBJECT::HORGREENSPRING)
		return 24.0f;

	if (eventID == OBJECT::BLUESPRING ||
		eventID == OBJECT::HORBLUESPRING)
		return 32.0f;

	return 0.0f;
}

class GuaranteedSpecialPickup : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) {
		obj.behave(BEHAVIOR::PICKUP);
	}

	bool onObjectHit(
		jjOBJ@ obj,
		jjOBJ@ bullet,
		jjPLAYER@ player,
		int force
	) {
		if (player is null)
			return false;

		int id = playerSlot(player);

		if (
			runState != RUN_PLAYING ||
			!roundParticipant[id] ||
			playerFinished[id]
		)
			return false;

		if (obj.eventID == OBJECT::EXTRATIME) {
			if (runState == RUN_PLAYING)
				adjustRoundTime(player, 5 * TICKS_PER_SECOND);
		}
		else if (obj.eventID != OBJECT::FASTFEET) {
			return false;
		}

		awardFloatingScore(
			player,
			obj.eventID == OBJECT::FASTFEET ?
				FAST_FEET_SCORE :
				scoreForPickup(obj)
		);
		jjSample(obj.xPos, obj.yPos, SOUND::COMMON_PICKUP1, 63);

		obj.behavior = BEHAVIOR::EXPLOSION2;
		obj.scriptedCollisions = false;
		obj.frameID = 0;
		return true;
	}
}

class FastfireToFastFeet : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) override {
		transformObjectToPreset(
			obj,
			OBJECT::FASTFEET
		);
	}
}

void gravityBurgerBehavior(jjOBJ@ obj) {
	obj.playerHandling = HANDLING::PARTICLE;
	obj.scriptedCollisions = false;
	obj.behave(BEHAVIOR::PICKUP);
	obj.playerHandling = HANDLING::PARTICLE;
}

void transformObjectToPreset(
	jjOBJ@ obj,
	uint8 eventID
) {
	jjOBJ@ preset = jjObjectPresets[eventID];

	obj.eventID = eventID;
	obj.behavior = preset.behavior;
	obj.curAnim = preset.curAnim;
	obj.killAnim = preset.killAnim;
	obj.frameID = preset.frameID;
	obj.animSpeed = preset.animSpeed;
	obj.special = preset.special;
	obj.state = preset.state;
	obj.energy = preset.energy;
	obj.points = preset.points;
	obj.playerHandling = preset.playerHandling;
	obj.bulletHandling = preset.bulletHandling;
	obj.scriptedCollisions = preset.scriptedCollisions;
	obj.isTarget = preset.isTarget;
	obj.isFreezable = preset.isFreezable;
	obj.isBlastable = preset.isBlastable;
	obj.deactivates = preset.deactivates;
	obj.doesHurt = preset.doesHurt;
	obj.counterEnd = preset.counterEnd;
	obj.xSpeed = preset.xSpeed;
	obj.ySpeed = preset.ySpeed;
	obj.xAcc = preset.xAcc;
	obj.yAcc = preset.yAcc;

	for (uint8 i = 0; i < 10; ++i)
		obj.var[i] = preset.var[i];

	obj.determineCurFrame();
	obj.behave(preset.behavior);
}

uint8 chooseAmmoGemColor(const jjOBJ@ obj) {
	uint choice;

	if (jjGameConnection == GAME::LOCAL) {
		choice = jjRandom() & 3;
	}
	else {
		// Online players derive the same color from stable object information so
		// gem values and challenge scores cannot disagree between computers.
		uint seed =
			uint(int(obj.xOrg)) * 73856093 +
			uint(int(obj.yOrg)) * 19349663 +
			uint(obj.eventID) * 83492791 +
			uint(obj.objectID) * 1640531513;

		choice = seed & 3;
	}

	return ammoGemColors[choice];
}

class RandomAmmoGem : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) override {
		transformObjectToPreset(
			obj,
			chooseAmmoGemColor(obj)
		);
	}
}

bool isWeaponPowerupEvent(uint8 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;
}

void initializeWeaponPowerupGoldGem(jjOBJ@ obj) {
	jjOBJ@ preset = jjObjectPresets[OBJECT::REDGEM];

	// Copy an ordinary gem's properties while preserving the placed weapon
	// powerup's event ID, which keeps its JCS/MLLE generator association intact.
	obj.curAnim = preset.curAnim;
	obj.killAnim = preset.killAnim;
	obj.frameID = preset.frameID;
	obj.animSpeed = preset.animSpeed;
	obj.special = preset.special;
	obj.state = preset.state;
	obj.energy = preset.energy;
	obj.points = preset.points;
	obj.playerHandling = preset.playerHandling;
	obj.bulletHandling = preset.bulletHandling;
	obj.scriptedCollisions = preset.scriptedCollisions;
	obj.isTarget = preset.isTarget;
	obj.isFreezable = preset.isFreezable;
	obj.isBlastable = preset.isBlastable;
	obj.deactivates = preset.deactivates;
	obj.doesHurt = preset.doesHurt;
	obj.counterEnd = preset.counterEnd;
	obj.xSpeed = preset.xSpeed;
	obj.ySpeed = preset.ySpeed;
	obj.xAcc = preset.xAcc;
	obj.yAcc = preset.yAcc;

	for (uint8 i = 0; i < 10; ++i)
		obj.var[i] = preset.var[i];

	obj.playerHandling = HANDLING::PICKUP;
	obj.scriptedCollisions = true;
	obj.age = WEAPON_POWERUP_GOLD_GEM_MARKER;
	obj.determineCurFrame();
}

class WeaponPowerupToGoldGem : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) override {
		if (obj.age != WEAPON_POWERUP_GOLD_GEM_MARKER)
			initializeWeaponPowerupGoldGem(obj);

		obj.behave(BEHAVIOR::PICKUP, false);
		obj.playerHandling = HANDLING::PICKUP;
		obj.scriptedCollisions = true;
		obj.behavior = this;
	}

	void onDraw(jjOBJ@ obj) {
		if (
			obj is null ||
			!obj.isActive ||
			obj.state == STATE::KILL ||
			obj.state == STATE::DONE ||
			obj.state == STATE::EXPLODE
		)
			return;

		jjDrawSpriteFromCurFrame(
			obj.xPos,
			obj.yPos,
			obj.curFrame,
			obj.direction,
			SPRITE::SINGLEHUE,
			GOLD_GEM_PALETTE_START
		);
	}

	bool onObjectHit(
		jjOBJ@ obj,
		jjOBJ@ bullet,
		jjPLAYER@ player,
		int force
	) {
		if (bullet !is null)
			return true;

		if (player is null)
			return false;

		int id = playerSlot(player);

		if (
			runState != RUN_PLAYING ||
			!roundParticipant[id] ||
			playerFinished[id]
		)
			return true;

		awardMulticolorGoldGemScore(player);
		jjSample(
			obj.xPos,
			obj.yPos,
			SOUND::COMMON_PICKUP1,
			63
		);

		obj.behavior = BEHAVIOR::EXPLOSION2;
		obj.scriptedCollisions = false;
		obj.frameID = 0;
		return true;
	}

	bool onGetActive(jjOBJ@ obj) {
		return obj !is null && obj.isActive;
	}

	void onSetActive(jjOBJ@ obj, bool setTo) {
		if (obj !is null && !setTo)
			obj.delete();
	}
}

bool chooseBombAmmoPickup(const jjOBJ@ obj) {
	if (jjGameConnection == GAME::LOCAL)
		return (jjRandom() & 1) != 0;

	// Use stable object data online so every computer chooses the same pickup.
	uint seed =
		uint(int(obj.xOrg)) * 73856093 +
		uint(int(obj.yOrg)) * 19349663 +
		uint(obj.eventID) * 83492791 +
		uint(obj.objectID) * 1640531513;

	return (seed & 1) != 0;
}

void initializeMixedAmmoPickup(
	jjOBJ@ obj,
	jjBEHAVIOR pickupBehavior,
	bool makeBomb
) {
	jjOBJ@ pickupPreset =
		jjObjectPresets[OBJECT::BANANA];

	jjOBJ@ visualPreset =
		jjObjectPresets[
			makeBomb ?
				SKATE_BOMB_EVENT :
				OBJECT::BANANA
		];

	uint16 originalPoints = obj.points;

	obj.eventID =
		makeBomb ? SKATE_BOMB_EVENT : OBJECT::BANANA;
	obj.behavior = pickupBehavior;
	obj.curAnim = visualPreset.curAnim;
	obj.killAnim = visualPreset.killAnim;
	obj.frameID = visualPreset.frameID;
	obj.animSpeed = pickupPreset.animSpeed;
	obj.special = pickupPreset.special;
	obj.state = pickupPreset.state;
	obj.energy = pickupPreset.energy;
	obj.points =
		originalPoints > 0 ?
			originalPoints : pickupPreset.points;
	obj.playerHandling = HANDLING::PICKUP;
	obj.bulletHandling = pickupPreset.bulletHandling;
	obj.scriptedCollisions = true;
	obj.isTarget = false;
	obj.isFreezable = false;
	obj.isBlastable = false;
	obj.deactivates = pickupPreset.deactivates;
	obj.doesHurt = 0;
	obj.counterEnd = pickupPreset.counterEnd;
	obj.xSpeed = pickupPreset.xSpeed;
	obj.ySpeed = pickupPreset.ySpeed;
	obj.xAcc = pickupPreset.xAcc;
	obj.yAcc = pickupPreset.yAcc;

	for (uint8 i = 0; i < 10; ++i)
		obj.var[i] = pickupPreset.var[i];

	obj.var[5] =
		makeBomb ? BOMB_AMMO_PICKUP_MARKER : 0;
	obj.determineCurFrame();
	obj.behave(pickupBehavior);
}

class MixedBananaBombPickup : jjBEHAVIORINTERFACE {
	jjBEHAVIOR pickupBehavior;

	MixedBananaBombPickup(jjBEHAVIOR behavior) {
		pickupBehavior = behavior;
	}

	void onBehave(jjOBJ@ obj) override {
		initializeMixedAmmoPickup(
			obj,
			pickupBehavior,
			chooseBombAmmoPickup(obj)
		);
	}
}

class BouncerAmmoToBanana : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) override {
		transformObjectToPreset(
			obj,
			OBJECT::BANANA
		);
	}
}

class AmmoCrateToGemCrate : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) override {
		transformObjectToPreset(
			obj,
			OBJECT::GEMCRATE
		);
	}
}

class CarrotToExtraTime : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) override {
		transformObjectToPreset(
			obj,
			OBJECT::EXTRATIME
		);
	}
}

class FullEnergyToBurger : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) override {
		transformObjectToPreset(
			obj,
			OBJECT::BURGER
		);
	}
}

void configureCarrotReplacements() {
	jjObjectPresets[OBJECT::CARROT].behavior =
		CarrotToExtraTime();

	jjObjectPresets[OBJECT::FULLENERGY].behavior =
		FullEnergyToBurger();
}

void configureAmmoGemReplacements() {
	for (
		int eventID = OBJECT::ICEAMMO3;
		eventID <= OBJECT::GUN9AMMO3;
		++eventID
	) {
		if (eventID == OBJECT::BOUNCERAMMO3)
			continue;

		jjObjectPresets[eventID].behavior =
			RandomAmmoGem();
	}

	// Bouncer +3 ammo is now a collectible banana-food event. The existing
	// banana pickup handler adds it to the player's banana-ammo counter.
	jjObjectPresets[OBJECT::BOUNCERAMMO3].behavior =
		BouncerAmmoToBanana();

	jjObjectPresets[OBJECT::GUNCRATE].behavior =
		AmmoCrateToGemCrate();

	for (
		int eventID = OBJECT::ICEAMMO15;
		eventID <= OBJECT::TOASTERAMMO15;
		++eventID
	) {
		jjObjectPresets[eventID].behavior =
			AmmoCrateToGemCrate();
	}
}

void configureWeaponPowerupReplacements() {
	jjObjectPresets[OBJECT::BLASTERPOWERUP].behavior =
		WeaponPowerupToGoldGem();
	jjObjectPresets[OBJECT::BOUNCERPOWERUP].behavior =
		WeaponPowerupToGoldGem();
	jjObjectPresets[OBJECT::ICEPOWERUP].behavior =
		WeaponPowerupToGoldGem();
	jjObjectPresets[OBJECT::SEEKERPOWERUP].behavior =
		WeaponPowerupToGoldGem();
	jjObjectPresets[OBJECT::RFPOWERUP].behavior =
		WeaponPowerupToGoldGem();
	jjObjectPresets[OBJECT::TOASTERPOWERUP].behavior =
		WeaponPowerupToGoldGem();
	jjObjectPresets[OBJECT::TNTPOWERUP].behavior =
		WeaponPowerupToGoldGem();
	jjObjectPresets[OBJECT::GUN8POWERUP].behavior =
		WeaponPowerupToGoldGem();
	jjObjectPresets[OBJECT::GUN9POWERUP].behavior =
		WeaponPowerupToGoldGem();
}

void configureMixedBananaBombPickups() {
	jjBEHAVIOR nativeBananaBehavior =
		jjObjectPresets[OBJECT::BANANA].behavior;

	jjObjectPresets[OBJECT::BANANA].behavior =
		MixedBananaBombPickup(nativeBananaBehavior);

	// Seeker +3 no longer becomes a gem. It uses the same 50/50 banana/bomb
	// ammo selection as placed Banana events.
	jjObjectPresets[OBJECT::SEEKERAMMO3].behavior =
		MixedBananaBombPickup(nativeBananaBehavior);
}

bool isExcludedBossEvent(uint8 eventID) {
	return
		eventID == OBJECT::TUFBOSS ||
		eventID == OBJECT::BILSY ||
		eventID == OBJECT::XMASBILSY ||
		eventID == OBJECT::QUEEN ||
		eventID == OBJECT::UTERUS ||
		eventID == OBJECT::UTERUSSPIKEBALL ||
		eventID == OBJECT::CRAB ||
		eventID == OBJECT::BUBBA ||
		eventID == OBJECT::DEVILDEVAN ||
		eventID == OBJECT::DEVANROBOT ||
		eventID == OBJECT::ROBOT ||
		eventID == OBJECT::BOLLY ||
		eventID == OBJECT::TWEEDLEBOSS;
}

class HarmlessSkateEnemy : jjBEHAVIORINTERFACE {
	jjBEHAVIOR originalBehavior;

	HarmlessSkateEnemy(jjBEHAVIOR behavior) {
		originalBehavior = behavior;
	}

	void onBehave(jjOBJ@ obj) override {
		// Some native enemy behaviors restore HANDLING::ENEMY every tick, so set
		// the harmless handling both before and after running the native code.
		obj.playerHandling = HANDLING::PARTICLE;
		obj.scriptedCollisions = false;
		obj.behave(originalBehavior);
		obj.playerHandling = HANDLING::PARTICLE;
		obj.scriptedCollisions = false;
	}
}

void configureHarmlessSkateEnemies() {
	if (
		jjGameMode != GAME::SP &&
		jjGameMode != GAME::COOP
	)
		return;

	for (int eventID = 1; eventID < 256; ++eventID) {
		jjOBJ@ preset = jjObjectPresets[eventID];

		if (
			preset.playerHandling != HANDLING::ENEMY ||
			isExcludedBossEvent(uint8(eventID))
		)
			continue;

		skateboardBounceEnemyEvent[eventID] = true;
		preset.behavior = HarmlessSkateEnemy(preset.behavior);
		preset.playerHandling = HANDLING::PARTICLE;
		preset.scriptedCollisions = false;
	}
}

void onLevelLoad() {
	jjCharacters[CHAR::JAZZ].airJump = AIR::NONE;
	jjCharacters[CHAR::JAZZ].doubleJumpCountMax = 0;
	jjCharacters[CHAR::JAZZ].helicopterDurationMax = 0;

	configureHarmlessSkateEnemies();

	for (int eventID = 1; eventID < 256; ++eventID) {
		if (jjObjectPresets[eventID].playerHandling == HANDLING::PICKUP)
			jjObjectPresets[eventID].scriptedCollisions = true;
	}

	jjObjectPresets[OBJECT::FASTFEET].scriptedCollisions = true;
	jjObjectPresets[OBJECT::FASTFEET].behavior =
		GuaranteedSpecialPickup();

	jjObjectPresets[OBJECT::FASTFIRE].behavior =
		FastfireToFastFeet();

	jjObjectPresets[OBJECT::BURGER].playerHandling =
		HANDLING::PARTICLE;
	jjObjectPresets[OBJECT::BURGER].scriptedCollisions = false;
	jjObjectPresets[OBJECT::BURGER].behavior =
		gravityBurgerBehavior;

	jjObjectPresets[OBJECT::EXTRATIME].scriptedCollisions = true;
	jjObjectPresets[OBJECT::EXTRATIME].behavior =
		GuaranteedSpecialPickup();

	configureCarrotReplacements();

	// Keep all springs completely native.
	jjObjectPresets[OBJECT::REDSPRING].scriptedCollisions = false;
	jjObjectPresets[OBJECT::GREENSPRING].scriptedCollisions = false;
	jjObjectPresets[OBJECT::BLUESPRING].scriptedCollisions = false;
	jjObjectPresets[OBJECT::HORREDSPRING].scriptedCollisions = false;
	jjObjectPresets[OBJECT::HORGREENSPRING].scriptedCollisions = false;
	jjObjectPresets[OBJECT::HORBLUESPRING].scriptedCollisions = false;

	configureAmmoGemReplacements();
	configureMixedBananaBombPickups();
	configureWeaponPowerupReplacements();

	if (jjAnimSets[ANIM::PICKUPS].firstAnim == 0)
		jjAnimSets[ANIM::PICKUPS].load();

	jjObjectPresets[OBJECT::SILVERCOIN].frameID = 0;
	jjObjectPresets[OBJECT::SILVERCOIN].determineCurFrame();
	wheelFrame = jjObjectPresets[OBJECT::SILVERCOIN].curFrame;
}

void resetSkateboardRound() {
	runState = RUN_COUNTDOWN;
	countdownTicks = 5 * TICKS_PER_SECOND;
	resultTicks = 0;
	autoRestartTicks = AUTO_RESTART_DURATION;
	restartRequested = false;
	jazzVoiceCooldown = 0;
	multiplayerScoreSyncTicks = 0;
	roundStateSyncTicks = 0;

	for (int i = 0; i < 32; ++i) {
		scoreInitialized[i] = false;
		challengeScore[i] = 0;
		roundParticipant[i] = false;
		playerFinished[i] = false;
		playerTimeLeftTicks[i] = 0;

		previousJump[i] = false;
		previousFire[i] = false;
		previousMovementHeld[i] = false;
		previousUppercut[i] = false;
		previousWeaponSwitch[i] = false;
		bombSwitchRequested[i] = false;
		previousButtstomp[i] = false;

		hurtStateInitialized[i] = false;
		previousHealth[i] = 0;
		previousBlink[i] = 0;

		reverseGravityTicks[i] = 0;
		platformBalanceTicks[i] = 0;

		runInertia[i] = 0;
		coastTicks[i] = 0;
		coastDirection[i] = 1;
		coastSpeed[i] = 0.0f;
		coastDeceleration[i] = 0.0f;
		continuousCoastActive[i] = false;

		wallJumpCooldown[i] = 0;
		pendingGroundJumpTicks[i] = 0;
		observedSlopeDirection[i] = 0;
		observedSlopeTicks[i] = 0;
		slopePositionKnown[i] = false;

		springScoreCooldown[i] = 0;
		lastScoredSpringObject[i] = -1;
		springMovementProtectionTicks[i] = 0;
		nativeSpeedKnown[i] = false;

		visualSpinTicks[i] = 0;
		visualSpinTotalTicks[i] = 0;
		enemyBounceCooldown[i] = 0;
		enemyRockTicks[i] = 0;
		wipeoutTicks[i] = 0;
		previousGrounded[i] = false;
		groundStateInitialized[i] = false;
		wipeoutText[i] = "";

		lastSlideEventTile[i] = -1;

		phraseTicks[i] = 0;
		phraseText[i] = "";

		wheelAngle[i] = 0;
		wheelPositionKnown[i] = false;
		groundDustCooldown[i] = 0;
		bananaSequence[i] = 0;
		bananaAmmo[i] = STARTING_BANANA_AMMO;
		bombSequence[i] = 0;
		bombAmmo[i] = STARTING_BOMB_AMMO;
		bombGemDropSequence[i] = 0;

		for (
			int history = 0;
			history < BOMB_EXPLOSION_HISTORY;
			++history
		)
			appliedBombExplosionSequence[
				i * BOMB_EXPLOSION_HISTORY + history
			] = 0;
	}

	// A /r restart does not reload the level, so remove old projectiles and
	// bomb-dropped gems before the next countdown begins.
	for (int i = 1; i < jjObjectCount; ++i) {
		jjOBJ@ obj = jjObjects[i];

		if (
			obj.isActive &&
			(
				(
					obj.eventID == OBJECT::BANANA &&
					obj.var[5] == BANANA_OBJECT_MARKER
				) ||
				(
					obj.eventID == SKATE_BOMB_EVENT &&
					obj.var[5] == BOMB_OBJECT_MARKER
				) ||
				(
					obj.var[5] == BOMB_GEM_OBJECT_MARKER
				)
			)
		)
			obj.delete();
	}

}

void onLevelBegin() {
	resetSkateboardRound();
}

void onLevelReload() {
	resetSkateboardRound();
}

// A multiplayer /r starts a new timed challenge without reloading the level.
void onGameStart(bool firstTime) {
	if (!firstTime)
		resetSkateboardRound();
}

bool isRoundAuthority() {
	return
		jjGameConnection == GAME::LOCAL ||
		jjIsServer;
}

bool isEligibleRoundPlayer(const jjPLAYER@ player) {
	return
		player !is null &&
		player.isActive &&
		player.isInGame &&
		!player.isSpectating;
}

bool instructionConnectionHasJoined(int clientID) {
	for (
		uint i = 0;
		i < instructionJoinedClientIDs.length();
		++i
	) {
		if (instructionJoinedClientIDs[i] == clientID)
			return true;
	}

	return false;
}

void rememberInstructionConnection(int clientID) {
	if (
		clientID < 0 ||
		instructionConnectionHasJoined(clientID)
	)
		return;

	instructionJoinedClientIDs.insertLast(clientID);
}

void markInstructionConnectionJoined(jjPLAYER@ player) {
	if (
		player is null ||
		!player.isActive
	)
		return;

	int clientID = int(player.clientID);
	rememberInstructionConnection(clientID);

	// A connection may own multiple splitscreen players. Once that connection
	// dismisses the one-time instructions, let all of its players join.
	for (int i = 0; i < 32; ++i) {
		jjPLAYER@ connectedPlayer = jjPlayers[i];

		if (
			connectedPlayer.isActive &&
			int(connectedPlayer.clientID) == clientID
		) {
			challengeJoined[i] = true;
			instructionJoinRequested[i] = false;
		}
	}
}

void registerPlayerConnection(jjPLAYER@ player) {
	if (
		player is null ||
		!player.isActive
	)
		return;

	int id = playerSlot(player);
	int clientID = int(player.clientID);

	if (
		playerConnectionKnown[id] &&
		knownPlayerClientID[id] == clientID
	)
		return;

	playerConnectionKnown[id] = true;
	knownPlayerClientID[id] = clientID;
	challengeJoined[id] =
		instructionConnectionHasJoined(clientID);
	previousInstructionFire[id] = false;
	instructionJoinRequested[id] = false;
}

void updatePlayerConnections() {
	for (int i = 0; i < 32; ++i) {
		jjPLAYER@ player = jjPlayers[i];

		if (player.isActive)
			registerPlayerConnection(player);
	}
}

bool hasEligibleJoinedPlayer() {
	for (int i = 0; i < 32; ++i) {
		if (
			isEligibleRoundPlayer(jjPlayers[i]) &&
			challengeJoined[i]
		)
			return true;
	}

	return false;
}

int currentRoundTimeForLateJoiner() {
	int remainingTime = 0;

	for (int i = 0; i < 32; ++i) {
		if (
			roundParticipant[i] &&
			!playerFinished[i] &&
			playerTimeLeftTicks[i] > remainingTime
		)
			remainingTime = playerTimeLeftTicks[i];
	}

	return remainingTime;
}

void initializeLateRoundParticipant(
	jjPLAYER@ player,
	int remainingTime
) {
	if (
		player is null ||
		!isEligibleRoundPlayer(player) ||
		remainingTime <= 0
	)
		return;

	int id = playerSlot(player);

	if (roundParticipant[id])
		return;

	roundParticipant[id] = true;
	playerFinished[id] = false;
	playerTimeLeftTicks[id] = remainingTime;
	scoreInitialized[id] = false;
	challengeScore[id] = 0;
	bananaAmmo[id] = STARTING_BANANA_AMMO;
	bombAmmo[id] = STARTING_BOMB_AMMO;
	reverseGravityTicks[id] = 0;
	platformBalanceTicks[id] = 0;
	wipeoutTicks[id] = 0;
	wipeoutText[id] = "";
	runInertia[id] = 0;
	coastTicks[id] = 0;
	coastSpeed[id] = 0.0f;
	continuousCoastActive[id] = false;
}

void joinConnectionToCurrentRound(int clientID) {
	if (
		!isRoundAuthority() ||
		runState != RUN_PLAYING
	)
		return;

	int remainingTime = currentRoundTimeForLateJoiner();

	if (remainingTime <= 0)
		return;

	for (int i = 0; i < 32; ++i) {
		jjPLAYER@ player = jjPlayers[i];

		if (
			player.isActive &&
			int(player.clientID) == clientID &&
			challengeJoined[i]
		)
			initializeLateRoundParticipant(
				player,
				remainingTime
			);
	}
}

void joinWaitingPlayersToCurrentRound() {
	if (
		!isRoundAuthority() ||
		runState != RUN_PLAYING
	)
		return;

	int remainingTime = currentRoundTimeForLateJoiner();

	if (remainingTime <= 0)
		return;

	bool joinedAnyPlayer = false;

	for (int i = 0; i < 32; ++i) {
		jjPLAYER@ player = jjPlayers[i];

		if (
			!challengeJoined[i] ||
			roundParticipant[i] ||
			!isEligibleRoundPlayer(player)
		)
			continue;

		initializeLateRoundParticipant(
			player,
			remainingTime
		);

		if (roundParticipant[i])
			joinedAnyPlayer = true;
	}

	if (
		joinedAnyPlayer &&
		jjIsServer
	)
		sendRoundState();
}

void requestChallengeJoin(jjPLAYER@ player) {
	if (
		player is null ||
		!player.isLocal
	)
		return;

	registerPlayerConnection(player);

	int id = playerSlot(player);
	markInstructionConnectionJoined(player);

	if (
		jjGameConnection == GAME::LOCAL ||
		jjIsServer
	) {
		joinConnectionToCurrentRound(
			int(player.clientID)
		);

		if (jjIsServer)
			sendRoundState();

		return;
	}

	jjSTREAM packet;
	packet.push(PACKET_PLAYER_JOINED);
	packet.push(uint8(id));
	jjSendPacket(packet);
}

void captureRoundParticipants() {
	for (int i = 0; i < 32; ++i) {
		roundParticipant[i] =
			isEligibleRoundPlayer(jjPlayers[i]) &&
			challengeJoined[i];

		playerFinished[i] = false;
		playerTimeLeftTicks[i] =
			roundParticipant[i] ?
				ROUND_DURATION : 0;
	}
}

bool allRoundParticipantsFinished() {
	for (int i = 0; i < 32; ++i) {
		if (
			roundParticipant[i] &&
			!playerFinished[i]
		)
			return false;
	}

	return true;
}

void beginRoundResults() {
	runState = RUN_RESULTS;
	resultTicks = 0;
	autoRestartTicks = AUTO_RESTART_DURATION;

	if (jjIsServer)
		sendRoundState();
}

void requestRoundRestart() {
	if (restartRequested)
		return;

	restartRequested = true;

	if (jjGameMode == GAME::BATTLE)
		jjChat("/r");
	else
		jjNxt(
			jjLevelFileName,
			true,
			true
		);
}

void updateRoundPlayerTimers() {
	bool authority = isRoundAuthority();

	for (int i = 0; i < 32; ++i) {
		if (
			!roundParticipant[i] ||
			playerFinished[i]
		)
			continue;

		if (
			authority &&
			!isEligibleRoundPlayer(jjPlayers[i])
		) {
			playerTimeLeftTicks[i] = 0;
			playerFinished[i] = true;
			continue;
		}

		if (playerTimeLeftTicks[i] > 0)
			--playerTimeLeftTicks[i];

		if (playerTimeLeftTicks[i] <= 0) {
			playerTimeLeftTicks[i] = 0;
			playerFinished[i] = true;
		}
	}

	if (
		authority &&
		allRoundParticipantsFinished()
	)
		beginRoundResults();
}

void onMain() {
	updatePlayerConnections();
	joinWaitingPlayersToCurrentRound();

	if (jazzVoiceCooldown > 0)
		--jazzVoiceCooldown;

	if (runState == RUN_COUNTDOWN) {
		bool authority = isRoundAuthority();

		if (
			countdownTicks > 0 &&
			(
				(
					authority &&
					hasEligibleJoinedPlayer()
				) ||
				(
					!authority &&
					countdownTicks <
						5 * TICKS_PER_SECOND
				)
			)
		)
			--countdownTicks;

		if (countdownTicks <= 0) {
			countdownTicks = 0;

			if (authority) {
				captureRoundParticipants();
				runState = RUN_PLAYING;

				if (jjIsServer)
					sendRoundState();
			}
		}
	}
	else if (runState == RUN_PLAYING) {
		updateRoundPlayerTimers();
	}
	else {
		++resultTicks;

		if (autoRestartTicks > 0)
			--autoRestartTicks;

		if (
			autoRestartTicks <= 0 &&
			isRoundAuthority()
		)
			requestRoundRestart();
	}

	for (int i = 0; i < 32; ++i) {
		if (phraseTicks[i] > 0) {
			--phraseTicks[i];
			phraseX[i] += phraseXSpeed[i];
			phraseY[i] -= 0.55f;
		}

		// Local wipeouts count down in onPlayer. Network-reported wipeouts for
		// remote players need their own visual countdown on each observer.
		if (
			wipeoutTicks[i] > 0 &&
			jjPlayers[i].isActive &&
			!jjPlayers[i].isLocal
		) {
			--wipeoutTicks[i];

			if (wipeoutTicks[i] <= 0) {
				wipeoutTicks[i] = 0;
				wipeoutText[i] = "";
			}
		}
	}

	if (
		jjGameMode == GAME::BATTLE &&
		jjGameConnection != GAME::LOCAL
	) {
		++multiplayerScoreSyncTicks;

		if (multiplayerScoreSyncTicks >= SCORE_SYNC_INTERVAL) {
			multiplayerScoreSyncTicks = 0;
			sendAllLocalChallengeScores();
		}

		if (jjIsServer) {
			++roundStateSyncTicks;

			if (roundStateSyncTicks >= ROUND_STATE_SYNC_INTERVAL) {
				roundStateSyncTicks = 0;
				sendRoundState();
			}
		}
	}
}

bool hasGroundSurfaceAt(
	int x,
	int y,
	int gravityDirection
) {
	bool previousMasked = jjMaskedPixel(
		x,
		y + gravityDirection * 7
	);

	for (int distance = 8; distance <= 31; ++distance) {
		bool masked = jjMaskedPixel(
			x,
			y + gravityDirection * distance
		);

		if (!previousMasked && masked)
			return true;

		previousMasked = masked;
	}

	return false;
}

bool isOnGround(const jjPLAYER@ player) {
	if (player.platform > 0)
		return true;

	int x = int(player.xPos);
	int y = int(player.yPos);
	int gravityDirection = player.antiGrav ? -1 : 1;

	if (!player.antiGrav && player.ySpeed < -0.5f)
		return false;

	if (player.antiGrav && player.ySpeed > 0.5f)
		return false;

	array<int> xOffsets = {
		-14, -9, -4, 0, 4, 9, 14
	};

	for (uint i = 0; i < xOffsets.length(); ++i) {
		if (
			hasGroundSurfaceAt(
				x + xOffsets[i],
				y,
				gravityDirection
			)
		)
			return true;
	}

	return false;
}

// Returns -1 when the slope goes down toward the left.
// Returns +1 when it goes down toward the right.
int downhillDirection(const jjPLAYER@ player) {
	if (!isOnGround(player))
		return 0;

	if (player.antiGrav)
		return 0;

	int x = int(player.xPos);
	int y = int(player.yPos) - 32;
	const int scanLength = 112;

	array<int> offsets = {
		-16, -12, -8, -4, 0, 4, 8, 12, 16
	};

	int firstOffset = 0;
	int firstSurface = 0;
	int lastOffset = 0;
	int lastSurface = 0;
	bool foundSurface = false;

	for (uint i = 0; i < offsets.length(); ++i) {
		int surface = jjMaskedTopVLine(
			x + offsets[i],
			y,
			scanLength
		);

		if (surface > scanLength)
			continue;

		if (!foundSurface) {
			firstOffset = offsets[i];
			firstSurface = surface;
			foundSurface = true;
		}

		lastOffset = offsets[i];
		lastSurface = surface;
	}

	if (!foundSurface || firstOffset == lastOffset)
		return 0;

	int difference = lastSurface - firstSurface;

	if (difference >= 2)
		return 1;

	if (difference <= -2)
		return -1;

	return 0;
}

void updateObservedSlope(jjPLAYER@ player) {
	int id = playerSlot(player);

	if (!slopePositionKnown[id]) {
		previousSlopeX[id] = player.xPos;
		previousSlopeY[id] = player.yPos;
		slopePositionKnown[id] = true;
		return;
	}

	float deltaX = player.xPos - previousSlopeX[id];
	float deltaY = player.yPos - previousSlopeY[id];

	previousSlopeX[id] = player.xPos;
	previousSlopeY[id] = player.yPos;

	if (
		absoluteFloat(deltaX) > 24.0f ||
		absoluteFloat(deltaY) > 24.0f
	) {
		observedSlopeTicks[id] = 0;
		return;
	}

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

	int travelDirection = signOf(deltaX);
	bool surfaceMovement = isOnGround(player);

	if (
		!surfaceMovement ||
		travelDirection == 0 ||
		absoluteFloat(deltaX) < 0.10f
	)
		return;

	if (deltaY < -0.10f) {
		observedSlopeDirection[id] = -travelDirection;
		observedSlopeTicks[id] = 14;
	}
	else if (deltaY > 0.10f) {
		observedSlopeDirection[id] = travelDirection;
		observedSlopeTicks[id] = 14;
	}
}

int effectiveDownhillDirection(const jjPLAYER@ player) {
	int id = playerSlot(player);

	if (
		observedSlopeTicks[id] > 0 &&
		observedSlopeDirection[id] != 0
	)
		return observedSlopeDirection[id];

	return downhillDirection(player);
}

int inputDirection(const jjPLAYER@ player) {
	if (player.keyLeft && !player.keyRight)
		return -1;

	if (player.keyRight && !player.keyLeft)
		return 1;

	return 0;
}

bool isTouchingWall(
	const jjPLAYER@ player,
	int direction
) {
	int x = int(player.xPos) + direction * 13;
	int y = int(player.yPos);
	int solidPixels = 0;

	if (jjMaskedPixel(x, y - 10))
		++solidPixels;

	if (jjMaskedPixel(x, y))
		++solidPixels;

	if (jjMaskedPixel(x, y + 10))
		++solidPixels;

	return solidPixels >= 2;
}

int eventTileAt(int xPixel, int yPixel) {
	int tileX = xPixel / 32;
	int tileY = yPixel / 32;

	if (tileX < 0 || tileY < 0)
		return -1;

	if (
		tileX >= int(jjLayerWidth[4]) ||
		tileY >= int(jjLayerHeight[4])
	)
		return -1;

	if (
		jjEventGet(uint16(tileX), uint16(tileY)) !=
		AREA::SLIDE
	)
		return -1;

	return tileX + tileY * 65536;
}

int touchedSlideEventTile(const jjPLAYER@ player) {
	int x = int(player.xPos);
	int y = int(player.yPos);

	int tile = eventTileAt(x, y + 16);
	if (tile >= 0)
		return tile;

	tile = eventTileAt(x - 10, y + 16);
	if (tile >= 0)
		return tile;

	tile = eventTileAt(x + 10, y + 16);
	if (tile >= 0)
		return tile;

	return eventTileAt(x, y);
}

void makeSparks(
	const jjPLAYER@ player,
	int amount = 1
) {
	for (int i = 0; i < amount; ++i) {
		jjPARTICLE@ spark =
			jjAddParticle(PARTICLE::SPARK);

		if (spark is null)
			continue;

		spark.xPos =
			player.xPos -
			player.direction *
			(6.0f + float(jjRandom() & 7));

		spark.yPos = player.yPos + 17.0f;

		spark.xSpeed =
			-player.xSpeed * 0.10f +
			float(int(jjRandom() & 31) - 15) / 20.0f;

		spark.ySpeed =
			-0.5f -
			float(jjRandom() & 15) / 18.0f;
	}
}

void updateGroundDust(jjPLAYER@ player) {
	int id = playerSlot(player);

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

	bool onNormalGround =
		runState == RUN_PLAYING &&
		wipeoutTicks[id] <= 0 &&
		!player.antiGrav &&
		player.platform <= 0 &&
		isOnGround(player) &&
		touchedSlideEventTile(player) < 0 &&
		absoluteFloat(player.xSpeed) >=
			GROUND_DUST_MIN_SPEED;

	if (!onNormalGround) {
		groundDustCooldown[id] = 0;
		return;
	}

	if (groundDustCooldown[id] > 0)
		return;

	groundDustCooldown[id] = GROUND_DUST_INTERVAL;

	jjPARTICLE@ dust = jjAddParticle(PARTICLE::SMOKE);

	if (dust is null)
		return;

	int movementDirection = signOf(player.xSpeed);

	if (movementDirection == 0)
		movementDirection = player.direction < 0 ? -1 : 1;

	dust.xPos =
		player.xPos -
		float(movementDirection) *
			(9.0f + float(jjRandom() & 7));

	dust.yPos =
		player.yPos +
		27.0f +
		float(int(jjRandom() & 3) - 1);

	dust.xSpeed =
		-player.xSpeed * 0.06f +
		float(int(jjRandom() & 15) - 7) /
			30.0f;

	dust.ySpeed =
		-0.15f -
		float(jjRandom() & 7) /
			30.0f;

	dust.smoke.countdown = 26;
}

void sendChallengeScore(const jjPLAYER@ player) {
	if (
		jjGameMode != GAME::BATTLE ||
		jjGameConnection == GAME::LOCAL
	)
		return;

	int id = playerSlot(player);

	jjSTREAM packet;
	packet.push(PACKET_CHALLENGE_SCORE);
	packet.push(uint8(id));
	packet.push(challengeScore[id]);
	jjSendPacket(packet);
}

void sendAllLocalChallengeScores() {
	if (
		jjGameMode != GAME::BATTLE ||
		jjGameConnection == GAME::LOCAL
	)
		return;

	for (int i = 0; i < jjLocalPlayerCount; ++i) {
		const jjPLAYER@ localPlayer = jjLocalPlayers[i];

		if (
			localPlayer !is null &&
			localPlayer.isActive
		)
			sendChallengeScore(localPlayer);
	}
}

int findSkateBanana(
	int ownerID,
	int sequence
) {
	for (int i = 1; i < jjObjectCount; ++i) {
		const jjOBJ@ obj = jjObjects[i];

		if (
			obj.isActive &&
			obj.eventID == OBJECT::BANANA &&
			obj.creatorType == CREATOR::PLAYER &&
			int(obj.creatorID) == ownerID &&
			obj.var[5] == BANANA_OBJECT_MARKER &&
			obj.var[6] == sequence
		)
			return i;
	}

	return 0;
}

int countSkateBananas(int ownerID) {
	int count = 0;

	for (int i = 1; i < jjObjectCount; ++i) {
		const jjOBJ@ obj = jjObjects[i];

		if (
			obj.isActive &&
			obj.eventID == OBJECT::BANANA &&
			obj.creatorType == CREATOR::PLAYER &&
			int(obj.creatorID) == ownerID &&
			obj.var[5] == BANANA_OBJECT_MARKER
		)
			++count;
	}

	return count;
}

int findOldestSkateBanana(int ownerID) {
	int oldestObjectID = 0;
	int oldestAge = -1;

	for (int i = 1; i < jjObjectCount; ++i) {
		const jjOBJ@ obj = jjObjects[i];

		if (
			obj.isActive &&
			obj.eventID == OBJECT::BANANA &&
			obj.creatorType == CREATOR::PLAYER &&
			int(obj.creatorID) == ownerID &&
			obj.var[5] == BANANA_OBJECT_MARKER &&
			obj.counter > oldestAge
		) {
			oldestAge = obj.counter;
			oldestObjectID = i;
		}
	}

	return oldestObjectID;
}

void removeSkateBanana(
	int ownerID,
	int sequence
) {
	int objectID = findSkateBanana(
		ownerID,
		sequence
	);

	if (objectID > 0)
		jjObjects[objectID].delete();
}

bool canBananaTripPlayer(
	int ownerID,
	const jjPLAYER@ victim
) {
	if (
		ownerID < 0 ||
		ownerID >= 32 ||
		victim is null
	)
		return false;

	int victimID = playerSlot(victim);
	const jjPLAYER@ owner = jjPlayers[ownerID];

	return
		runState == RUN_PLAYING &&
		roundParticipant[ownerID] &&
		!playerFinished[ownerID] &&
		roundParticipant[victimID] &&
		!playerFinished[victimID] &&
		owner.isActive &&
		victim.isActive &&
		victim.isInGame &&
		owner.isEnemy(victim);
}

bool bananaTouchesPlayer(
	const jjOBJ@ banana,
	const jjPLAYER@ victim
) {
	if (!isOnGround(victim))
		return false;

	int bananaGravity =
		banana.var[7] < 0 ? -1 : 1;

	int playerGravity =
		victim.antiGrav ? -1 : 1;

	if (bananaGravity != playerGravity)
		return false;

	float xDistance =
		victim.xPos - banana.xPos;

	if (xDistance < 0.0f)
		xDistance = -xDistance;

	float footY =
		victim.yPos +
		float(playerGravity) * 24.0f;

	float yDistance = footY - banana.yPos;

	if (yDistance < 0.0f)
		yDistance = -yDistance;

	return
		xDistance <= 15.0f &&
		yDistance <= 13.0f;
}

void relayBananaWipeout(
	int ownerID,
	int victimID,
	int sequence
) {
	if (jjGameConnection == GAME::LOCAL)
		return;

	jjSTREAM packet;
	packet.push(PACKET_BANANA_WIPEOUT);
	packet.push(uint8(ownerID));
	packet.push(uint8(victimID));
	packet.push(sequence);
	jjSendPacket(packet);
}

void applyBananaWipeout(
	int ownerID,
	int victimID,
	int sequence
) {
	removeSkateBanana(ownerID, sequence);

	if (
		victimID < 0 ||
		victimID >= 32
	)
		return;

	jjPLAYER@ victim = jjPlayers[victimID];

	if (
		canBananaTripPlayer(ownerID, victim) &&
		wipeoutTicks[victimID] <= 0
	)
		startWipeout(victim);
}

bool tryBananaWipeout(jjOBJ@ banana) {
	int ownerID = int(banana.creatorID);

	for (int i = 0; i < jjLocalPlayerCount; ++i) {
		jjPLAYER@ victim = jjLocalPlayers[i];

		if (
			!canBananaTripPlayer(ownerID, victim) ||
			wipeoutTicks[playerSlot(victim)] > 0 ||
			!bananaTouchesPlayer(banana, victim)
		)
			continue;

		int victimID = playerSlot(victim);
		int sequence = banana.var[6];

		applyBananaWipeout(
			ownerID,
			victimID,
			sequence
		);

		relayBananaWipeout(
			ownerID,
			victimID,
			sequence
		);

		return true;
	}

	return false;
}

bool bananaHasGroundAt(
	float x,
	float y,
	int gravityDirection
) {
	int groundY =
		int(y) + gravityDirection * 7;

	return
		jjMaskedPixel(int(x) - 3, groundY) ||
		jjMaskedPixel(int(x), groundY) ||
		jjMaskedPixel(int(x) + 3, groundY);
}

void skateboardBananaBehavior(jjOBJ@ banana) {
	if (banana.state == STATE::START) {
		banana.state = STATE::FLY;
		banana.playerHandling = HANDLING::PARTICLE;
		banana.bulletHandling = HANDLING::IGNOREBULLET;
		banana.scriptedCollisions = false;
		banana.isTarget = false;
		banana.deactivates = false;
		banana.counter = 0;
		banana.determineCurFrame();
	}

	int ownerID = int(banana.creatorID);

	if (
		runState != RUN_PLAYING ||
		ownerID < 0 ||
		ownerID >= 32 ||
		!roundParticipant[ownerID] ||
		playerFinished[ownerID] ||
		!jjPlayers[ownerID].isActive
	) {
		banana.delete();
		return;
	}

	++banana.counter;

	if (banana.counter >= BANANA_LIFETIME) {
		banana.delete();
		return;
	}

	int gravityDirection =
		banana.var[7] < 0 ? -1 : 1;

	if (banana.state == STATE::FLY) {
		banana.var[9] =
			(
				banana.var[9] +
				int(banana.xSpeed * 30.0f)
			) &
			1023;

		float nextX =
			banana.xPos + banana.xSpeed;

		float nextY =
			banana.yPos + banana.ySpeed;

		int horizontalDirection =
			banana.xSpeed < 0.0f ? -1 : 1;

		bool hitWall =
			banana.xSpeed != 0.0f &&
			jjMaskedPixel(
				int(nextX) +
					horizontalDirection * 6,
				int(nextY)
			);

		if (hitWall) {
			banana.xSpeed *= -0.35f;
			nextX = banana.xPos;
		}

		bool movingTowardGround =
			banana.ySpeed *
				float(gravityDirection) >=
			0.0f;

		if (
			movingTowardGround &&
			bananaHasGroundAt(
				nextX,
				nextY,
				gravityDirection
			)
		) {
			banana.xPos = nextX;
			banana.yPos = nextY;

			for (int i = 0; i < 12; ++i) {
				if (!jjMaskedPixel(
					int(banana.xPos),
					int(banana.yPos)
				))
					break;

				banana.yPos -=
					float(gravityDirection);
			}

			banana.xSpeed = 0.0f;
			banana.ySpeed = 0.0f;
			banana.state = STATE::WAIT;
			banana.var[9] =
				gravityDirection > 0 ? 0 : 512;
		}
		else {
			banana.xPos = nextX;
			banana.yPos = nextY;
			banana.ySpeed +=
				float(gravityDirection) *
				BANANA_GRAVITY;

			if (
				banana.ySpeed *
					float(gravityDirection) >
				BANANA_MAX_FALL_SPEED
			)
				banana.ySpeed =
					float(gravityDirection) *
					BANANA_MAX_FALL_SPEED;
		}
	}
	else if (
		banana.counter >= BANANA_ARM_TICKS &&
		tryBananaWipeout(banana)
	)
		return;

	banana.determineCurFrame();

	jjDrawRotatedSpriteFromCurFrame(
		banana.xPos,
		banana.yPos,
		banana.curFrame,
		banana.var[9]
	);
}

void initializeSkateBanana(
	jjOBJ@ banana,
	int ownerID,
	int sequence,
	float x,
	float y,
	float xSpeed,
	float ySpeed,
	int gravityDirection
) {
	banana.xOrg = x;
	banana.yOrg = y;
	banana.xPos = x;
	banana.yPos = y;
	banana.xSpeed = xSpeed;
	banana.ySpeed = ySpeed;
	banana.creatorID = uint16(ownerID);
	banana.creatorType = CREATOR::PLAYER;
	banana.direction = xSpeed < 0.0f ? -1 : 1;
	banana.var[5] = BANANA_OBJECT_MARKER;
	banana.var[6] = sequence;
	banana.var[7] = gravityDirection < 0 ? -1 : 1;
	banana.var[9] = 0;
	banana.counter = 0;
	banana.playerHandling = HANDLING::PARTICLE;
	banana.bulletHandling = HANDLING::IGNOREBULLET;
	banana.scriptedCollisions = false;
	banana.isTarget = false;
	banana.deactivates = false;
	banana.state = STATE::START;
	banana.behavior = skateboardBananaBehavior;

	// Run the first frame immediately. This prevents a newly created banana
	// from spending one tick with its former pickup/inactive behavior.
	skateboardBananaBehavior(banana);

	jjSample(
		x,
		y,
		SOUND::MONKEY_THROW,
		48
	);
}

bool spawnSkateBanana(
	int ownerID,
	int sequence,
	float x,
	float y,
	float xSpeed,
	float ySpeed,
	int gravityDirection
) {
	if (
		ownerID < 0 ||
		ownerID >= 32 ||
		sequence <= 0
	)
		return false;

	if (findSkateBanana(ownerID, sequence) > 0)
		return true;

	int objectID = 0;

	if (
		countSkateBananas(ownerID) >=
		MAX_BANANAS_PER_PLAYER
	)
		objectID = findOldestSkateBanana(ownerID);

	if (objectID <= 0) {
		objectID = jjAddObject(
			OBJECT::BANANA,
			x,
			y,
			uint16(ownerID),
			CREATOR::PLAYER,
			BEHAVIOR::INACTIVE
		);
	}

	// If JJ2 has no free object slot, reuse this player's oldest peel. This
	// guarantees a new throw once that player already has a peel in the level.
	if (objectID <= 0)
		objectID = findOldestSkateBanana(ownerID);

	if (objectID <= 0)
		return false;

	initializeSkateBanana(
		jjObjects[objectID],
		ownerID,
		sequence,
		x,
		y,
		xSpeed,
		ySpeed,
		gravityDirection
	);

	return true;
}

bool throwSkateBanana(jjPLAYER@ player) {
	if (
		player is null ||
		!player.isLocal
	)
		return false;

	int ownerID = playerSlot(player);

	if (bananaAmmo[ownerID] <= 0)
		return false;

	int direction =
		player.direction < 0 ? -1 : 1;

	int gravityDirection =
		player.antiGrav ? -1 : 1;

	++bananaSequence[ownerID];

	if (bananaSequence[ownerID] <= 0)
		bananaSequence[ownerID] = 1;

	int sequence = bananaSequence[ownerID];
	float x =
		player.xPos + float(direction) * 18.0f;

	float y =
		player.yPos -
		float(gravityDirection) * 8.0f;

	float xSpeed =
		float(direction) * BANANA_THROW_X_SPEED +
		player.xSpeed * 0.30f;

	float ySpeed =
		-float(gravityDirection) *
		BANANA_THROW_Y_SPEED;

	bool spawned = spawnSkateBanana(
		ownerID,
		sequence,
		x,
		y,
		xSpeed,
		ySpeed,
		gravityDirection
	);

	if (!spawned)
		return false;

	--bananaAmmo[ownerID];

	if (jjGameConnection == GAME::LOCAL)
		return true;

	jjSTREAM packet;
	packet.push(PACKET_BANANA_THROW);
	packet.push(uint8(ownerID));
	packet.push(sequence);
	packet.push(x);
	packet.push(y);
	packet.push(xSpeed);
	packet.push(ySpeed);
	packet.push(int8(gravityDirection));
	jjSendPacket(packet);
	return true;
}

int findSkateBomb(
	int ownerID,
	int sequence
) {
	for (int i = 1; i < jjObjectCount; ++i) {
		const jjOBJ@ obj = jjObjects[i];

		if (
			obj.isActive &&
			obj.eventID == SKATE_BOMB_EVENT &&
			obj.creatorType == CREATOR::PLAYER &&
			int(obj.creatorID) == ownerID &&
			obj.var[5] == BOMB_OBJECT_MARKER &&
			obj.var[6] == sequence
		)
			return i;
	}

	return 0;
}

int countSkateBombs(int ownerID) {
	int count = 0;

	for (int i = 1; i < jjObjectCount; ++i) {
		const jjOBJ@ obj = jjObjects[i];

		if (
			obj.isActive &&
			obj.eventID == SKATE_BOMB_EVENT &&
			obj.creatorType == CREATOR::PLAYER &&
			int(obj.creatorID) == ownerID &&
			obj.var[5] == BOMB_OBJECT_MARKER
		)
			++count;
	}

	return count;
}

int findOldestSkateBomb(int ownerID) {
	int oldestObjectID = 0;
	int oldestAge = -1;

	for (int i = 1; i < jjObjectCount; ++i) {
		const jjOBJ@ obj = jjObjects[i];

		if (
			obj.isActive &&
			obj.eventID == SKATE_BOMB_EVENT &&
			obj.creatorType == CREATOR::PLAYER &&
			int(obj.creatorID) == ownerID &&
			obj.var[5] == BOMB_OBJECT_MARKER &&
			obj.counter > oldestAge
		) {
			oldestAge = obj.counter;
			oldestObjectID = i;
		}
	}

	return oldestObjectID;
}

void removeSkateBomb(
	int ownerID,
	int sequence
) {
	int objectID = findSkateBomb(ownerID, sequence);

	if (objectID > 0)
		jjObjects[objectID].delete();
}

bool canBombKnockPlayer(
	int ownerID,
	const jjPLAYER@ victim
) {
	if (
		ownerID < 0 ||
		ownerID >= 32 ||
		victim is null
	)
		return false;

	int victimID = playerSlot(victim);
	const jjPLAYER@ owner = jjPlayers[ownerID];

	return
		runState == RUN_PLAYING &&
		roundParticipant[ownerID] &&
		!playerFinished[ownerID] &&
		roundParticipant[victimID] &&
		!playerFinished[victimID] &&
		owner.isActive &&
		victim.isActive &&
		victim.isInGame &&
		owner.isEnemy(victim);
}

bool skateBombTouchesEnemy(const jjOBJ@ bomb) {
	int ownerID = int(bomb.creatorID);

	for (int i = 0; i < 32; ++i) {
		const jjPLAYER@ victim = jjPlayers[i];

		if (!canBombKnockPlayer(ownerID, victim))
			continue;

		if (
			absoluteFloat(victim.xPos - bomb.xPos) <= 18.0f &&
			absoluteFloat(victim.yPos - bomb.yPos) <= 26.0f
		)
			return true;
	}

	return false;
}

bool isSkateBombBreakableContainer(const jjOBJ@ target) {
	if (
		target is null ||
		!target.isActive ||
		target.state == STATE::KILL ||
		target.state == STATE::DONE ||
		target.state == STATE::EXPLODE
	)
		return false;

	uint8 eventID = target.eventID;

	return
		target.behavior == BEHAVIOR::MONITOR ||
		eventID == OBJECT::GUNCRATE ||
		eventID == OBJECT::CARROTCRATE ||
		eventID == OBJECT::ONEUPCRATE ||
		eventID == OBJECT::BOMBCRATE ||
		eventID == OBJECT::SPRINGCRATE ||
		eventID == OBJECT::GEMCRATE ||
		eventID == OBJECT::GEMBARREL ||
		eventID == OBJECT::CARROTBARREL ||
		eventID == OBJECT::ONEUPBARREL ||
		eventID == OBJECT::GUNBARREL ||
		eventID == OBJECT::TRIGGERCRATE ||
		eventID == OBJECT::BIRDCAGE ||
		eventID == OBJECT::DESTRUCTSCENERY ||
		(
			eventID >= OBJECT::ICEAMMO15 &&
			eventID <= OBJECT::TOASTERAMMO15

[preview ends here]