Downloads containing mayAstralWitchcraftCode.asc

Downloads
Name Author Game Mode Rating
JJ2+ Only: Astral Witchcraft minmay Multiple N/A Download file

File preview

/*

     .              .                 •        .
              •                 .                       .
  • ╔═╗┌─┐┌┬┐┬─┐┌─┐┐    ╗ ╗┬┌┬┐┌─┐┐ ┐┌─┐┬─┐┌─┐┌─┐┌┬┐
 .  ╠═╣└─┐ │ ├┬┘├─┤│  . ║║║│ │ │  ├─┤│  ├┬┘├─┤├┤  │ .
    ╚ ╚└─┘ └ └└─└ └└─┘  ╚╩╝└ └ └─┘└ └└─┘└└─└ └└   └ 
.               .                  •      .            
      •                     .                        .
            .                     .         •
            

minmay, 2024

One day, the witches got bored of living in huts, so they expropriated the
queen's castle, redecorated it a bit, and launched it into space. Then they got
bored of that too, so they decided to fight each other, but in like, a friendly
way.

Astral Witchcraft is a Capture + Battle level with too many gimmicks. It has a
slightly asymmetrical layout, a tileset you probably haven't seen before, color,
and generally makes it really easy to roast and get roasted.

All weapons are always powered up. Available weapons are:
- the Blaster, but with a bigger, bluer hitbox
- the Zapper, a full-fledged custom weapon making its debut in this level. This
  is not the level it was originally intended to debut in.
  The Zapper shoots electric bolts that can bounce off walls; shoot it at angled
  walls for trick shots!
  The powered-up Zapper can bounce up to three times. (The un-powered-up Zapper
  can only bounce once, but the un-powered-up Zapper doesn't appear in this
  level anyway.)
  Feel free to use and modify in your own levels!
- the Spinner, a less full-fledged custom weapon. The Spinner shoots simple
  bullets upwards, which avoid ceilings/floors and accelerate horizontally. It
  has significant spread (synchronized between players in online games! usually!)
  You can use this in your own levels too but you probably don't want to.

Get ammo for your weapons from Magic Ammo Zones. These zones are vulnerable to
attacks from other weapons, so watch out!

You'll also find potion pickups pretty much everywhere. Once you've gulped down
25 of these, buttstomp to release an explosion of extra-long-lasting,
extra-bouncy (up to 7 bounces!) Zapper bolts. This is certain to decimate any
nearby foes, and probably some far away ones too, but drinking 25 potions
without dying is easier said than done.

The CTF version was originally "finished" around the end of October 2023, then I
sat on it for a while expecting to release it alongside some pretty extensive
script libraries, probably after at least one other level. The Halloween Battle
Contest made me decide to release it now; sure, it wasn't originally designed as
a Battle level and it shows, but the theme fits well, right?

GRAPHICS CREDITS:

Upper tileset portion from
https://brullov.itch.io/2d-platformer-asset-pack-castle-of-despair

Lower tileset portion from
https://szadiart.itch.io/sidescroll-worlds-castle-pack

Leaves from
https://www.jazz2online.com/downloads/4172/omen-woods/

Roots/branches from
https://www.jazz2online.com/downloads/894/swamps-of-the-sleeping-jaguar/

Background from
https://screamingbrainstudios.itch.io/seamless-space-backgrounds

Spinner sprites from
https://bdragon1727.itch.io/free-effect-bullet-impact-explosion-32x32

Magic Blaster sprites from
https://bdragon1727.itch.io/fire-pixel-bullet-16x16

All of these have been edited.

SOUND CREDITS:

mayAstralThunder.ogg:
http://freesound.org/people/nicStage/sounds/64460/              CC BY 3.0
http://freesound.org/people/Ephemeral_Rift/sounds/77370/        CC BY-NC 3.0

MUSIC:

"A Bard's Tale..." by DipA:
https://modarchive.org/index.php?request=view_by_moduleid&query=35186

*/
const bool SCREENSHOT_MODE = false;
const bool DISABLE_HUD = SCREENSHOT_MODE;

const WEAPON::Weapon ZAPPER_WEAPON_SLOT = WEAPON::BOUNCER;

// dumb hack: we replace the regular ammo animation for the weapon slot
// so if you change the weapon slot you also have to change
// SPINNER_AMMO_ANIM_OFFSET accordingly
const WEAPON::Weapon SPINNER_WEAPON_SLOT = WEAPON::ICE;
const int SPINNER_AMMO_ANIM_OFFSET = 28;

// Dedicating a weapon slot to buttstomps allows us to save on bullet packets
// for the buttstomp nova (one fireBullet call to shoot this weapon, then create
// the real bullets clientside)
const WEAPON::Weapon BUTTSTOMP_WEAPON_SLOT = WEAPON::GUN9;


const OBJECT::Object BUTTSTOMP_POWER_PICKUP = OBJECT::MILK;
// Buttstomping with at least BUTTSTOMP_MIN_FOOD food will convert all food into
// a nova of bullets.
const int BUTTSTOMP_MIN_FOOD = 25;
// Food can't exceed this. For simplicity I have it the same as the minimum, but
// the code handles higher values fine, if you want to let players get even more
// powerful novas.
// A sugar rush happening would ruin everything (and also get clients kicked
// because I set jjSugarRushAllowed to false) so keep this well below 99; a
// player collecting several food pickups on the same frame is entirely
// possible, so 98 is not far enough away from 99.
const int MAX_FOOD = BUTTSTOMP_MIN_FOOD;

// sample slots for custom sounds
const SOUND::Sample ZAPPER_NOVA_SOUND = SOUND::UTERUS_SCISSORS1;
const SOUND::Sample NOVA_CHARGE_SOUND = SOUND::UTERUS_SCISSORS2;

// Only objects with the PULZELIGHT or STEADYLIGHT eventID can have varying
// radius with LIGHT::FLICKER.
const OBJECT::Object FLOATING_LIGHT_SOURCE_OBJECT = OBJECT::PULZELIGHT;


const int CANDLE_FRAME_COUNT = 6;

const jjLAYER@ TILE_INFO_LAYER = jjLayers[1];

// this level's invisible ceiling is handled in script instead of just being
// invisible masks or something because uhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh
// anyway if your xPos is at least CEILING_START_X then your yPos gets clamped
// to the invisible trapezoid when you get shot up really high or try to wall
// jump out of the level or whatever
// (the xPos check is because JJ2+ likes to use the top left corner as a default
// place to put players with no clear correct location, like spectators)
const float CEILING_START_X = 12.0f*TILE_SIZE;
const float CEILING_DOME_WIDTH = 80.0f*TILE_SIZE;
const float CEILING_Y = 10.5f*TILE_SIZE;

#include "MinimalMayLib.asc"
#pragma require "MinimalMayLib.asc"
#include "MayModUtils.asc"
#pragma require "MayModUtils.asc"
#pragma require "mayAstralGothEva.png"
#pragma require "mayAstralWitchcraft_blaster.png"
#pragma require "mayLemniscate.png"
#pragma require "mayAstralWitchcraft_nebula.pal"
#pragma require "mayAstralCharge.ogg"
#pragma require "mayAstralThunder.ogg"
#pragma require "maySilence.wav"
// Offer even if uploadMusic is off; the level's visuals rely on the music.
// Nothing awful will happen if you don't have it, so it's still not a require.
#pragma offer "jj2edit_bardtale.mo3"

int levelAnimSet = -1;

int nebulaMappingIndex = -1;
int nebulaFrameIndex = -1;

jjRNG rng(jjUnixTimeMs());

enum AstralWitchcraftAnimations {
	ANIM_MAGIC_BLASTER,
	ANIM_MAGIC_BLASTER_UP,
	ANIM_SPINNER,
	ANIM_SPINNER_KILL,
	ANIM_SPINNER_KILL_PU,
	ANIM_SPINNER_KILL_ENEMY,
	ANIM_LEMNISCATES,
	ANIM_FLOATING_CANDELABRUM,
	ANIM_FLOATING_CANDLE,
	ANIM_GOTH_EVA,
	ANIM_STATIC_SPRITES, // each frame is a sprite for something non-animated
}
enum AstralWitchcraftStaticSprites {
	FLOATING_DEBRIS_START,
	FLOATING_DEBRIS_LAST = FLOATING_DEBRIS_START + 32,
	FOOD_METER_BG_JAZZ,
	FOOD_METER_BG_SPAZ,
	FOOD_METER_BG_LORI,
	MINIMAP_LEMNISCATE,
	//
	NUM_STATIC_SPRITES,
}
const array<uint> ANIMATION_FRAME_COUNTS = {
	12, // magic blaster
	12, // magic blaster up
	12, // spinner
	4,  // spinner killAnim
	4,  // powered up spinner killAnim
	4,  // enemy spinner killAnim (not used)
	10, // lemniscates, each frame is a color
	CANDLE_FRAME_COUNT,  // candelabrum from tileset
	CANDLE_FRAME_COUNT,  // candle from tileset
	1,  // motionless stoic goth eva
	NUM_STATIC_SPRITES,
};

int _getAnimIndex(int animEnum) {
	return jjAnimSets[levelAnimSet].firstAnim+animEnum;
}
int _getFirstFrameIndex(int animEnum) {
	return jjAnimations[jjAnimSets[levelAnimSet].firstAnim+animEnum].firstFrame;
}
int _getStaticSpriteFrameIndex(int staticSpriteIndex) {
	return jjAnimations[jjAnimSets[levelAnimSet].firstAnim+ANIM_STATIC_SPRITES].firstFrame+staticSpriteIndex;
	//return 0;
}

/*============================================================================*\
*
* AMMO SOURCES
*
* Areas that continuously give ammo to players inside. Unlike with regular
* pickups and powerups, any number of players can share these with no loss of
* efficiency.
*
* To prevent camping from being too effective, the Zapper ammo source is in a
* location vulnerable to Spinner attacks, and the Spinner ammo source is
* vulnerable to Zapper attacks. But with teamwork, perhaps you can find a way to
* control both of them anyway...
* 
\*============================================================================*/

// This is NOT a jjOBJ behavior, it only exists within the script.
// Represents an elliptical area that trickles ammo to a player while they're
// inside it.

// general purpose mapping that right-cycles the brightest 6 colors of each
// hue section in the sprite colors: 16-21, 24-29, 32-37, 40-45, 48-53, 64-69,
// 72-77, 80-85, and 88-93.
int ammoSourceMappingIndex = -1;
array<uint8> ammoSourceMap8(256);
SpriteParticleData @ammoSourceParticles;
namespace AmmoSourceColor {
enum Color {
	GREEN,
	RED,
	BLUE,
	ORANGE,
	PINK,
	DONT_USE_THIS, // that wacky yellow sprite color section
	BEIGE,
	GREY,
	TURQUOISE,
	LAVENDER,
	NUM_AMMO_SOURCE_COLORS,
}
}
void _setupAmmoSourceGraphics() {
	// load sprite and create color shifted versions and particle data
	array<uint8> shift8Map(256);
	for (int i = 1; i < 256; i++) {
		shift8Map[i] = (i+8)%256;
	}
	jjPIXELMAP lemniscateImage("mayLemniscate.png");
	@ammoSourceParticles = SpriteParticleData(lemniscateImage);
	for (int i = 0; i < AmmoSourceColor::NUM_AMMO_SOURCE_COLORS; i++) {
		jjANIMFRAME@ frame = jjAnimFrames[_getFirstFrameIndex(ANIM_LEMNISCATES)+i];
		lemniscateImage.save(frame);
		frame.hotSpotX = -(lemniscateImage.width >> 1);
		frame.hotSpotY = -(lemniscateImage.height >> 1);
		lemniscateImage.recolor(shift8Map);
	}
	
	// initialize mapping with identity
	ammoSourceMappingIndex = jjSpriteModeFirstFreeMapping();
	for (int i = 0; i < 256; i++) {
		ammoSourceMap8[i] = i;
	}
	jjSpriteModeSetMapping(ammoSourceMappingIndex, ammoSourceMap8);
	
	// generate minimap sprite, this is for use with SINGLECOLOR only.
	// This is an exercise in silly ways to do things, don't do this just
	// load a png.
	jjPIXELMAP minimapLemniscate(7,3);
	for (int x = 0; x < 7; x++) {
		for (int y = 0; y < 3; y++) {
			minimapLemniscate[x,y] = ((x%3+1)/2) ^ (y & 1);
			// for 8-pixel-wide lemniscates. perhaps you have a
			// source centered in a map where the center is at a
			// tile edge instead of a tile center?
			//minimapLemniscate[x,y] = ((x+1)/2+y)%2;
		}
	}
	jjANIMFRAME@ frame = jjAnimFrames[_getStaticSpriteFrameIndex(MINIMAP_LEMNISCATE)];
	minimapLemniscate.save(frame);
	frame.hotSpotX = -3;
	frame.hotSpotY = -1;
}
void _updateAmmoSourceGraphics() {
	const int COLORS_LENGTH = 6;
	// cycle colors every other tick
	if (!SCREENSHOT_MODE && (jjGameTicks & 1 != 0)) {
		for (int i = SPRITE_GRADIENTS_START; i < SPRITE_COLORS_END; i += SPRITE_GRADIENT_LENGTH) {
			// yes, this rotates the 3 varying "sprite colors" next to the
			// yellow colors, we don't care
			rotateArraySegmentLeft_uint8(ammoSourceMap8, i, COLORS_LENGTH);
		}
		jjSpriteModeSetMapping(ammoSourceMappingIndex, ammoSourceMap8);
	}
}

// a player's position can be this many pixels away from the edge of the ellipse
// and still receive ammo
const float AMMO_SOURCE_RADIUS_LENIENCY = 16.0f;
class AmmoSource {
	int ammoType;
	AmmoSourceColor::Color color;
	int x;
	int y;
	int radius;
	int16 pickupAnim;
	int16 pickupAnimPowerup;
	
	// sourcePickupAnim: curAnim-style index of the animation that will be
	//   used when drawing the symbol. For clarity you're encouraged to use
	//   the sprite for the weapon's ammo pickup.
	// sourcePickupAnimPowerup: same thing but for when the viewing player
	//   has the powerup corresponding to the source. -1 (default) to use
	//   the same sprite as un-powered-up.
	// sourceRadius: x radius of the ellipse the ammo source occupies. The
	//   y radius is half this value.
	AmmoSource(int sourceAmmoType, int sourceX, int sourceY, int16 sourcePickupAnim, int16 sourcePickupAnimPowerup = -1, int sourceRadius = 128, AmmoSourceColor::Color sourceColor = 0) {
		ammoType = sourceAmmoType;
		x = sourceX;
		y = sourceY;
		pickupAnim = sourcePickupAnim;
		pickupAnimPowerup = sourcePickupAnimPowerup == -1 ? pickupAnim : sourcePickupAnimPowerup;
		radius = sourceRadius;
		color = sourceColor;
	}
	
	void update() {
		// normalized angle
		float angle = 1.0f - (SCREENSHOT_MODE ? 70 : jjGameTicks)/(radius*15.0f) % 1.0f;
		float scale = min(1.0f, radius/120.0f);
		jjDrawRotatedSpriteFromCurFrame(x, y, _getFirstFrameIndex(ANIM_LEMNISCATES)+color, int(angle*1023), scale, scale, SPRITE::MAPPING, ammoSourceMappingIndex);
		
		int maxAmmo = getMaxAmmo(ammoType);
		// give ammo (and fx) to local players within radius
		for (int i = 0; i < jjLocalPlayerCount; i++) {
			jjPLAYER@ player = jjLocalPlayers[i];
			if (player.ammo[ammoType] < maxAmmo) {
				float px = player.xPos;
				float py = player.yPos;
				float pxDist = px-x;
				float pyDist = py-y;
				float playerDistance = sqrt(pxDist*pxDist + pyDist*pyDist*4.0f);
				if (playerDistance <= radius+AMMO_SOURCE_RADIUS_LENIENCY) {
					// 20 ammo per second. This is enough to really spam the weapon, so Astral Witchcraft
					// places ammo sources in spots where spamming their respective weapon isn't especially
					// useful.
					if (jjGameTicks % 7 == 0) {
						player.ammo[ammoType] = min(player.ammo[ammoType] + jjWeapons[ammoType].multiplier * 2, getMaxAmmo(ammoType));
						// maybe should be a custom sound that harmonizes with music etc?
						jjSample(x, y, SOUND::COMMON_MONITOR, 63, int(22050+22050*float(player.ammo[ammoType])/float(maxAmmo)));
					}
					
					// draw a shimmery line from the center to the player
					int pixelCount = int(playerDistance) >> 2;
					for (int j = 0; j < pixelCount; j++) {
						float dist = float(rng() / MAX_UINT64_DOUBLE);
						int pcolor = rng() & 3;
						// 1/4th of pixels are white
						if (pcolor == 3) {
							pcolor = 15;
						} else {
						// other pixels are the first few colors in the gradient
							pcolor += SPRITE_GRADIENTS_START+color*SPRITE_GRADIENT_LENGTH;
						}
						
						jjDrawPixel(x+pxDist*dist, y+pyDist*dist, pcolor);
					}
				}
			}
		}
		
		// circle particles
		if (pointIsCloseToCamera(x, y, radius)) {
			for (int i = 0; i < 3; i++) {
				jjPARTICLE@ part = jjAddParticle(PARTICLE::ICETRAIL);
				if (part !is null) {
					part.icetrail.color = SPRITE_GRADIENTS_START+color*SPRITE_GRADIENT_LENGTH;
					part.icetrail.colorStop = SPRITE_GRADIENTS_START+(color+1)*SPRITE_GRADIENT_LENGTH;
				
					float pangle = float(rng() / MAX_UINT64_DOUBLE) * PI * 2.0f;
					
					float sine = sin(pangle);
					float cosine = cos(pangle);
					part.xPos = x+radius*sine;
					part.yPos = y+radius*cosine*0.5f;
				}
			}
		}
	}
}

array<AmmoSource@> levelAmmoSources;
void _updateAmmoSources() {
	//MayUtils::profilerEnter("updateAmmoSources");
	_updateAmmoSourceGraphics();
	uint len = levelAmmoSources.length();
	for (uint i = 0; i < len; i++) {
		levelAmmoSources[i].update();
	}
	//MayUtils::profilerLeave("updateAmmoSources");
}

void _addAmmoSource(int sourceAmmoType, int sourceX, int sourceY, int16 sourcePickupAnim, int16 sourcePickupAnimPowerup = -1, int sourceRadius = 128, AmmoSourceColor::Color sourceColor = 0) {
	levelAmmoSources.insertLast(AmmoSource(sourceAmmoType, sourceX, sourceY, sourcePickupAnim, sourcePickupAnimPowerup, sourceRadius, sourceColor));
}
const uint16 TILE_CANDLE_1 = TILE::ANIMATED;
const uint16 TILE_CANDLE_2 = TILE::ANIMATED | 2;
const uint16 TILE_CANDLE_3 = TILE::ANIMATED | 3;
const uint16 TILE_CANDELABRUM = TILE::ANIMATED | 4;
const uint16 TILE_TORCH = TILE::ANIMATED | 1;
void _processTileAndEventMaps() {
	// Astral Witchcraft uses the event map to mark some locations
	int levelWidth = jjLayerWidth[4];
	int levelHeight = jjLayerHeight[4];
	uint lastEventID = 0;
	for (int y = 0; y < levelHeight; y++) {
		for (int x = 0; x < levelWidth; x++) {
			// When called with an offset of -12 and width of 32 or
			// -32, jjParameterGet() returns the full bitfield for
			// the tile, including the event ID.
			uint eventBits = jjParameterGet(x, y, -12, 32);
			uint eventID = eventBits & 0xFF;
			switch (eventID) {
				case MCE_EVENT_ID:
					// Ammo sources are marked by MCE Events with an
					// Event field within the range of this level's
					// limited weapons and Initial Delay on.
					if (eventBits & 0x10000000 != 0) { // initial delay bit
						uint eventField = (eventBits & 0xFF000) >> 12;
						uint delayField = (eventBits & 0xFF00000) >> 20;
						
						if (eventField >= 2 && eventField <= MAX_WEAPON) {
							// The Event field is the ammo type, and
							// the Delay field is the radius.
							AmmoSourceColor::Color color;
							switch (eventField) {
								case 2: // powered up zapper
									color = AmmoSourceColor::LAVENDER;
									break;
								case 3: // powered up spinner
									color = AmmoSourceColor::GREEN;
									break;
								default:
									color = AmmoSourceColor::GREY;
									break;
							}
							
							_addAmmoSource(eventField, x*TILE_SIZE+16, y*TILE_SIZE+16, 0, 0, delayField, color);
						}
					}
					break;
			}
			lastEventID = eventID;
			
			for (int layer = 3; layer <= 4; layer++) {
				uint16 tileID = jjTileGet(layer, x, y);
				bool hflipped = tileID & TILE::HFLIPPED != 0;
				tileID &= ~TILE::HFLIPPED;
				tileID &= ~TILE::VFLIPPED;
				if (_tileIsGraffiti(tileID)) {
					_addGraffitiTile(tileID, x, y);
				}
				int hflipAdd = hflipped ? 32 : 0;
				int hflipSign = hflipped ? -1 : 1;
				switch (tileID) {
					case TILE_CANDLE_1:
						_addCandleEmitter(CANDLE_EMITTER, x*TILE_SIZE+17*hflipSign+hflipAdd, y*TILE_SIZE+20);
						break;
					case TILE_CANDLE_2:
						_addCandleEmitter(CANDLE_EMITTER, x*TILE_SIZE+26*hflipSign+hflipAdd, y*TILE_SIZE+25);
						break;
					case TILE_CANDLE_3:
						_addCandleEmitter(CANDLE_EMITTER, x*TILE_SIZE+4*hflipSign+hflipAdd, y*TILE_SIZE+20);
						break;
					case TILE_CANDELABRUM:
						_addCandleEmitter(CANDLE_EMITTER, x*TILE_SIZE+12*hflipSign+hflipAdd, y*TILE_SIZE+14);
						_addCandleEmitter(CANDLE_EMITTER, x*TILE_SIZE+17*hflipSign+hflipAdd, y*TILE_SIZE+11);
						_addCandleEmitter(CANDLE_EMITTER, x*TILE_SIZE+21*hflipSign+hflipAdd, y*TILE_SIZE+14);
						break;
					case TILE_TORCH:
						_addCandleEmitter(TORCH_EMITTER, x*TILE_SIZE+17*hflipSign+hflipAdd, y*TILE_SIZE+24);
						break;
					default:
						break;
				}
			}
		}
	}
}

// can you tell this hook was added as a hack for this level...
void onDrawMinimap(MayLib::MayMinimap@ minimap, jjCANVAS@ canvas, int x, int y, float scale) {
	uint len = levelAmmoSources.length();
	int frame = _getStaticSpriteFrameIndex(MINIMAP_LEMNISCATE);
	for (uint i = 0; i < len; i++) {
		uint8 color = levelAmmoSources[i].color;
		if (color == AmmoSourceColor::LAVENDER) {
			color = 90;
		} else {
			color = color*8+16;
		}
		canvas.drawSpriteFromCurFrame(minimap.drawX(levelAmmoSources[i].x),
			minimap.drawY(levelAmmoSources[i].y),
			frame,
			0,
			SPRITE::SINGLECOLOR,
			color);
	}
}

/*============================================================================*\
*
* CANDLE EMITTERS
*
* Just keeps track of the positions of all the candle tiles so particles can
* easily be created on them. 
* 
\*============================================================================*/
enum CANDLE_EMITTER_TYPE {
	CANDLE_EMITTER,
	TORCH_EMITTER,
}
class AstralCandleEmitter {
	float x;
	float y;
	CANDLE_EMITTER_TYPE type;
	
	AstralCandleEmitter(CANDLE_EMITTER_TYPE typ, float xPos, float yPos) {
		x = xPos;
		y = yPos;
		type = typ;
	}
}
array<AstralCandleEmitter@> candleEmitters;
void _addCandleEmitter(CANDLE_EMITTER_TYPE type, float x, float y) {
	candleEmitters.insertLast(AstralCandleEmitter(type, x, y));
}
void _updateCandleEmitters() {
	//MayUtils::profilerEnter("updateCandleEmitters");
	if (!jjLowDetail && !SCREENSHOT_MODE) {
		uint len = candleEmitters.length();
		float candleLeadIntensity = max(0,(leadIntensity-0.8f)*5.0f);
		for (uint i = 0; i < len; i++) {
			AstralCandleEmitter@ emitter = candleEmitters[i];
			if (pointIsCloseToCamera(int(emitter.x), int(emitter.y), 0)) {
				if (candleLeadIntensity > 0.0f && (emitter.type == TORCH_EMITTER || rng() & 3 == 0)) {
					jjPARTICLE@ part = jjAddParticle(PARTICLE::ICETRAIL);
					if (part !is null) {
						part.xPos = emitter.x;
						part.yPos = emitter.y-4.0f;
						part.xSpeed = (int(leadNote+9)%12-6)*(candleLeadIntensity+0.5f)*0.02f+rng()/MAX_UINT64_DOUBLE*0.03f;
						part.ySpeed = -(0.4f)*(candleLeadIntensity+0.5f);
						part.icetrail.color = 40;
						part.icetrail.colorStop = 47;
					}
				}
			}
		}
	}
	//MayUtils::profilerLeave("updateCandleEmitters");
}

/*============================================================================*\
*
* PARTICLE HELPER THING
*
* Just stores a list of non-transparent pixels in an image so that particles can
* easily be emitted from its shape, basically.
*
* This isn't well suited for large or dense images, for which random sampling
* would be more appropriate.
* 
\*============================================================================*/

class SpriteParticleData {
	array<float> offsets; // x,y interleaved
	array<float> perimeterOffsets;
	
	// start inclusive, end exclusive
	SpriteParticleData(const jjPIXELMAP& in spriteImage, int startX = 0, int startY = 0, int endX = -1, int endY = -1, int hotSpotX = 9999, int hotSpotY = 9999) {
		if (endX == -1) endX = spriteImage.width;
		if (endY == -1) endY = spriteImage.height;
		if (hotSpotX == 9999) hotSpotX = -(endX-startX)/2;
		if (hotSpotY == 9999) hotSpotY = -(endY-startY)/2;
		for (int y = startY; y < endY; y++) {
			for (int x = startX; x < endX; x++) {
				if (spriteImage[x,y] != 0) {
					offsets.insertLast(float(x + hotSpotX));
					offsets.insertLast(float(y + hotSpotY));
				}
				if (x == startX || x == endX-1 || y == startY || y == endY-1
				|| spriteImage[x-1,y] == 0 || spriteImage[x+1,y] == 0
				|| spriteImage[x,y-1] == 0 || spriteImage[x,y+1] == 0) {
					perimeterOffsets.insertLast(float(x + hotSpotX));
					perimeterOffsets.insertLast(float(y + hotSpotY));
				}
			}
		}
	}
	
	// empty constructor used as optimization by _setupGraffiti() (the
	// graffiti is already scanned per pixel there so no sense in scanning
	// it again in SpriteParticleData)
	// what, you weren't expecting a *clean* script, were you?
	SpriteParticleData() {}
	
	// rotateClockwise is 0.0-1.0, so 0.5f for a 180 degree rotation
	jjPARTICLE@ addParticle(PARTICLE::Type ptype, float x, float y, float speedPerPixel = 0.0f, float rotateClockwise = 0.0f, bool perimeterOnly = false) {
		jjPARTICLE@ rval = jjAddParticle(ptype);
		if (rval !is null) {
			uint64 index = rng();
			float xOffset, yOffset;
			if (perimeterOnly) {
				index %= perimeterOffsets.length() >> 1;
				xOffset = perimeterOffsets[index*2];
				yOffset = perimeterOffsets[index*2+1];
			} else {
				index %= offsets.length() >> 1;
				xOffset = offsets[index*2];
				yOffset = offsets[index*2+1];
			}
			rotateClockwise *= PI*2.0f;
			float sine = sin(rotateClockwise);
			float cosine = cos(rotateClockwise);
			rval.xPos = x+xOffset*cosine+yOffset*sine;
			rval.yPos = y+xOffset*sine+yOffset*cosine;
			rval.xSpeed = (xOffset*cosine+yOffset*sine)*speedPerPixel;
			rval.ySpeed = (xOffset*sine+yOffset*cosine)*speedPerPixel;
		}
		return rval;
	}
}

/*============================================================================*\
*
* COLOR STUFF
* 
\*============================================================================*/
const float gamma = 2.2;
const float gammaInv = 1.0/gamma;

jjPAL nebulaBasePalette();
jjPAL nebulaCurrentPalette();
array<uint8> nebulaMap8(256);

enum NEBULA_COLOR_PROPERTIES {
	// Behind everything there's black, more or less. This property lets us
	// mix in a custom "background color".
	DARKBACKGROUNDNESS,
	
	// The red and purple nebulae are distinct and cover a lot of space,
	// definitely the coolest part.
	REDCLOUDNESS,
	
	// The cyan clouds are smaller and appear in front of the red ones.
	CYANCLOUDNESS,
	
	// Four stars are near-white and brighter than the rest.
	BRIGHTSTARNESS,
	
	// Outside the red nebulae are a lot of single-pixel cyan stars of
	// varying intensity.
	CYANSTARNESS,
	
	// And deep blue stars, dimmer but larger than the cyan ones.
	BLUESTARNESS,
	
	// There are red stars too, but their colors are too closely shared with
	// the red nebulae to do anything with them.
	
	NUM_NEBULA_COLOR_PROPERTIES,
}
array<string> nebulaColorPropertyNames = {"darkBackgroundness","redCloudness","cyanCloudness","brightStarness","cyanStarness","blueStarness"};

array<float> nebulaColorProperties(NUM_NEBULA_COLOR_PROPERTIES*256);

// doesn't take saturation into account, as our image's saturation is quite
// uniform anyway
float _hueProximity(uint8 hue1, uint8 hue2) {
	int dif = int(hue2)-int(hue1);
	if (dif < 0) dif = -dif;
	if (dif > 128) dif = 256-dif;
	return max(0.0f,1.0f-(dif/42.67f)); // 0 for a difference of 60+ degrees
}
void _setupBackground() {
	// set up nebula mapping. At time of writing, MLLE won't load special
	// palette indices from palette files, which usually makes sense, but
	// the nebula is not a normal tileset, it's only for use with MAPPING,
	// and uses 240 colors.
	// So I load the palette (and generate the index mapping) here instead.
	nebulaBasePalette.load("mayAstralWitchcraft_nebula.pal");
	
	uint8 redCloudHue = nebulaBasePalette.color[197].getHue();
	uint8 cyanCloudHue = nebulaBasePalette.color[246].getHue();
	uint8 cyanStarHue = nebulaBasePalette.color[253].getHue();
	uint8 blueStarHue = nebulaBasePalette.color[242].getHue();
	
	
	// Calculate color properties
	for (int i = 1; i < 256; i++) {
		uint8 hue = nebulaBasePalette.color[i].getHue();
		float lightness = nebulaBasePalette.color[i].getLight()/255.0f;
		float saturation = nebulaBasePalette.color[i].getSat()/255.0f;
		
		// convert to normalized linear space
		float r = (nebulaBasePalette.color[i].red/255.0f)**gamma;
		float g = (nebulaBasePalette.color[i].green/255.0f)**gamma;
		float b = (nebulaBasePalette.color[i].blue/255.0f)**gamma;
		
		float darkBackgroundness = (1.0f-lightness)**16.0f;
		float redCloudness = r*8.0f*_hueProximity(hue, redCloudHue);
		float cyanCloudness = (b*2.0f+g*1.5f)*_hueProximity(hue, cyanCloudHue);
		
		// stars ended up unused and rolled into cyan clouds; the script
		// draws shiny independent stars manually instead.
		float cyanStarness = 0.0f; //_hueProximity(hue, cyanStarHue)**8.0f*8.0f;
		float blueStarness = 0.0f; //_hueProximity(hue, blueStarHue)*saturation*saturation*6.0f;
		float brightStarness = 0.0f;
		
		float denom = darkBackgroundness+redCloudness+cyanCloudness+brightStarness+cyanStarness+blueStarness;
		
		int start = i*NUM_NEBULA_COLOR_PROPERTIES;
		nebulaColorProperties[start+DARKBACKGROUNDNESS] = darkBackgroundness/denom;
		nebulaColorProperties[start+REDCLOUDNESS] = redCloudness/denom;
		nebulaColorProperties[start+CYANCLOUDNESS] = cyanCloudness/denom;
		nebulaColorProperties[start+CYANSTARNESS] = cyanStarness/denom;
		nebulaColorProperties[start+BLUESTARNESS] = blueStarness/denom;
	}

	for (int i = 1; i < 256; i++) {
		// afterthought saturation boost
		// (no, this gamma correction is not full srgb/linear conversion)
		//nebulaBasePalette.color[i].red = uint8(((nebulaBasePalette.color[i].red/255.0f)**gamma*1.2f)**gammaInv*255.0f);
		//nebulaBasePalette.color[i].blue = uint8(((nebulaBasePalette.color[i].blue/255.0f)**gamma*1.2f)**gammaInv*255.0f);
		
		// in 8-bit, we just use a static mapping for the background
		nebulaMap8[i] = jjBackupPalette.findNearestColor(nebulaBasePalette.color[i]);
	}
	
	nebulaCurrentPalette = nebulaBasePalette; // copies color values
	
	nebulaMappingIndex = jjSpriteModeFirstFreeMapping();
	jjSpriteModeSetMapping(nebulaMappingIndex, nebulaMap8, nebulaCurrentPalette);
	
	jjLayers[7].spriteMode = SPRITE::MAPPING;
	jjLayers[7].spriteParam = nebulaMappingIndex;
}

float crystalIntensity = 0.0f;
float cymbalIntensity = 0.0f;
float bassIntensity = 0.0f;
float guitarIntensity = 0.0f;
float leadIntensity = 0.0f;
float snareIntensity = 0.0f;
float drum6Intensity = 0.0f;
float tambourineIntensity = 0.0f;
float orchestralSnareIntensity = 0.0f;
float orchestralSnareDecay = 0.03f;
float highBrassIntensity = 0.0f;
uint8 crystalNote = 0;
uint8 leadNote = 0;
uint8 guitarNote = 0;
uint8 highBrassNoteLeft = 0;
uint8 highBrassNoteRight = 0;

float lastChordR = 0.0f;
float lastChordG = 0.0f;
float lastChordB = 0.0f;
float targetChordR = 0.0f;
float targetChordG = 0.0f;
float targetChordB = 0.0f;
float chordTime = 1.0f;

int lastModRow = -1;

int backgroundDebugMode = 0;
void _updateBackground() {
	// Nothing to do here in 8-bit (not enough colors) or low detail (layer doesn't even get drawn).
	if (jjColorDepth == 8 || jjLowDetail || SCREENSHOT_MODE) return;
	//MayUtils::profilerEnter("updateBackground");
	
	if (backgroundDebugMode != 0) {
		for (int i = 1; i < 256; i++) {
			uint8 intensity = uint8((nebulaColorProperties[i*NUM_NEBULA_COLOR_PROPERTIES+backgroundDebugMode-1])**gammaInv*255);
			nebulaCurrentPalette.color[i].red = intensity;
			nebulaCurrentPalette.color[i].green = intensity;
			nebulaCurrentPalette.color[i].blue = intensity;
		}
	} else {
		int currentOrder = jjGetModOrder();
		int currentRow = jjGetModRow();
		string key = currentOrder+","+currentRow;
		if (currentRow != lastModRow && CHORD::chordData.exists(key)) {
			int color = CHORD::chordColors[int(CHORD::chordData[key])];
			float chordMix = min(1.0f, chordTime);
			lastChordR = lastChordR*(1.0f-chordMix)+targetChordR*chordMix;
			lastChordG = lastChordG*(1.0f-chordMix)+targetChordG*chordMix;
			lastChordB = lastChordB*(1.0f-chordMix)+targetChordB*chordMix;
			targetChordR = (((color >> 16) & 0xFF)/255.0f)**gamma;
			targetChordG = (((color >> 8) & 0xFF)/255.0f)**gamma;
			targetChordB = ((color & 0xFF)/255.0f)**gamma;
			chordTime = 0.0f;
			lastModRow = currentRow;
		}
		
		chordTime += 0.057f;
		float chordMix = min(1.0f, chordTime);
		float chordR = lastChordR*(1.0f-chordMix)+targetChordR*chordMix;
		float chordG = lastChordG*(1.0f-chordMix)+targetChordG*chordMix;
		float chordB = lastChordB*(1.0f-chordMix)+targetChordB*chordMix;
		
		for (int i = 1; i < 256; i++) {
			int start = i*NUM_NEBULA_COLOR_PROPERTIES;
			float darkBackgroundness = nebulaColorProperties[start+DARKBACKGROUNDNESS];
			float redCloudness = nebulaColorProperties[start+REDCLOUDNESS];
			float cyanCloudness = nebulaColorProperties[start+CYANCLOUDNESS];
			float cyanStarness = nebulaColorProperties[start+CYANSTARNESS];
			float blueStarness = nebulaColorProperties[start+BLUESTARNESS];
			
			// convert to normalized linear space
			float r = (nebulaBasePalette.color[i].red/255.0f)**gamma;
			float g = (nebulaBasePalette.color[i].green/255.0f)**gamma;
			float b = (nebulaBasePalette.color[i].blue/255.0f)**gamma;
			
			//r += 1.0f*cymbalIntensity*darkBackgroundness;
			//r += 0.2f*bassIntensity*darkBackgroundness;
			
			float lightness = nebulaBasePalette.color[i].getLight()/255.0f;
			b += cymbalIntensity*darkBackgroundness*lightness**2.0f*4.0f;
			
			float flutter = 1.0f + sin((jjRenderFrame & 7)/8.0f*PI)*0.17f;
			r = r*(1.0f-cyanCloudness) + chordR*cyanCloudness*lightness*flutter;
			g = g*(1.0f-cyanCloudness) + chordG*cyanCloudness*lightness*flutter;
			b = b*(1.0f-cyanCloudness) + chordB*cyanCloudness*lightness*flutter;
			
			r += (snareIntensity+orchestralSnareIntensity)*redCloudness*0.5f;
			b += (snareIntensity+orchestralSnareIntensity)*redCloudness;
			
			b += drum6Intensity*redCloudness*0.2f;
			
			//b *= 1.0f+tambourineIntensity*cyanCloudness*16.0f;
			//g *= 1.0f+tambourineIntensity*cyanCloudness*8.0f;
			
			/*float crystalTone = crystalNote%12/12.0f;
			float crystalR = max(0.0f,1.0f-crystalTone);
			float crystalG = max(0.0f,min(crystalTone, 2.0f-crystalTone));
			float crystalB = max(0.0f,min(crystalTone-1.0f, 3.0f-crystalTone));
			float crystalIntensityInv = 1.0f-crystalIntensity*cyanStarness;
			float lightness = nebulaBasePalette.color[i].getLight()/255.0f;
			r = r*crystalIntensityInv + crystalIntensity*cyanStarness*crystalR*lightness;
			g = g*crystalIntensityInv + crystalIntensity*cyanStarness*crystalG*lightness;
			b = b*crystalIntensityInv + crystalIntensity*cyanStarness*crystalB*lightness;*/
			
			r = max(0.0f, min(1.0f, r));
			g = max(0.0f, min(1.0f, g));
			b = max(0.0f, min(1.0f, b));
			nebulaCurrentPalette.color[i].red = uint8(r**gammaInv*255);
			nebulaCurrentPalette.color[i].green = uint8(g**gammaInv*255);
			nebulaCurrentPalette.color[i].blue = uint8(b**gammaInv*255);
		}
	}
	/*if (MayLib::keyPressed(0x44)) { // 'D'
		backgroundDebugMode += 1;
		if (backgroundDebugMode > NUM_NEBULA_COLOR_PROPERTIES) backgroundDebugMode = 0;
		if (backgroundDebugMode != 0) jjAlert(nebulaColorPropertyNames[backgroundDebugMode-1]);
	}*/
	
	crystalIntensity = max(0.0f, crystalIntensity-0.01f);
	cymbalIntensity = max(0.0f, cymbalIntensity-0.01f);
	bassIntensity = max(0.0f, bassIntensity-0.04f);
	snareIntensity = max(0.0f, snareIntensity-0.014f);
	drum6Intensity = max(0.0f, drum6Intensity-0.05f);
	tambourineIntensity = max(0.0f, tambourineIntensity-0.05f);
	orchestralSnareIntensity = max(0.0f, orchestralSnareIntensity-orchestralSnareDecay);
	
	jjSpriteModeSetMapping(nebulaMappingIndex, nebulaMap8, nebulaCurrentPalette);
	jjLayers[7].spriteMode = SPRITE::MAPPING;
	
	//MayUtils::profilerLeave("updateBackground");
}

jjPAL mainCurrentPalette();
array<uint8> mainIndexMapping(256);
int mainMappingIndex;
void _setupMainMapping() {
	mainCurrentPalette = jjBackupPalette; // copies color values
	mainCurrentPalette.gradient(24, 0, 32, 219, 127, 255, GRAFFITI_PALETTE_START, GRAFFITI_PALETTE_GROUP_LENGTH*3, 1.0f, true);
	mainCurrentPalette.apply();

	for (int i = 0; i < 255; i++) {
		mainIndexMapping[i] = i;
	}
	
	mainMappingIndex = jjSpriteModeFirstFreeMapping();
	
	jjSpriteModeSetMapping(mainMappingIndex, mainIndexMapping, mainCurrentPalette);
	
	for (int i = 2; i < 6; i++) {
		jjLayers[i].spriteMode = SPRITE::MAPPING;
		jjLayers[i].spriteParam = mainMappingIndex;
	}
}

void _updateMainMapping() {
	if (SCREENSHOT_MODE) return;

	//MayUtils::profilerEnter("updateMainMapping");
	int graffitiHue = jjGameTicks;
	for (int i = 0; i < GRAFFITI_PALETTE_GROUP_LENGTH; i++) {
		int base = GRAFFITI_PALETTE_START;
		int cycle = (jjGameTicks >> 2) % GRAFFITI_PALETTE_GROUP_LENGTH;
		mainIndexMapping[base+i] = base+(i+cycle)%GRAFFITI_PALETTE_GROUP_LENGTH;
		mainCurrentPalette.color[base+i] = jjPalette.color[base+(i+cycle)%GRAFFITI_PALETTE_GROUP_LENGTH];
		base += GRAFFITI_PALETTE_GROUP_LENGTH;
		mainIndexMapping[base+i] = base+(i+cycle)%GRAFFITI_PALETTE_GROUP_LENGTH;
		mainCurrentPalette.color[base+i] = jjPalette.color[base+(i+cycle)%GRAFFITI_PALETTE_GROUP_LENGTH];
		base += GRAFFITI_PALETTE_GROUP_LENGTH;
		mainIndexMapping[base+i] = base+(i+cycle)%GRAFFITI_PALETTE_GROUP_LENGTH;
		mainCurrentPalette.color[base+i] = jjPalette.color[base+(i+cycle)%GRAFFITI_PALETTE_GROUP_LENGTH];
	}

	float brassR = 0.0f;
	float brassG = 0.0f;
	float brassB = 0.0f;
	switch (highBrassNoteLeft) {
		case ModNote::B6:
			if (highBrassNoteRight == ModNote::E6) { // third
				brassR = 0.7f;
				brassG = -0.4f;
				brassB = 1.2f;
			} else if (highBrassNoteRight == ModNote::B5) { // fifth (last)
				brassR = 0.7f;
				brassG = -0.4f;
				brassB = 1.2f;
			}
		case ModNote::C7:
			if (highBrassNoteRight == ModNote::F6) { // first
				brassR = 0.7f;
				brassG = -0.4f;
				brassB = 1.2f;
			} else if (highBrassNoteRight == ModNote::G6) { // second
				brassR = 1.3f;
				brassG = -0.5f;
				brassB = 0.9f;
			}
			break;
		case ModNote::E7: // fourth, right note is E6
			brassR = 1.3f;
			brassG = -0.5f;
			brassB = 0.9f;
			break;
		default:
			break;
	}
	for (int i = 96; i <= 104; i++) {
		// convert to normalized linear space
		float r = (jjBackupPalette.color[i].red/255.0f)**gamma;
		float g = (jjBackupPalette.color[i].green/255.0f)**gamma;
		float b = (jjBackupPalette.color[i].blue/255.0f)**gamma;
		
		r *= 1.0f+brassR*highBrassIntensity*1.0f;
		g *= 1.0f+brassG*highBrassIntensity*1.0f;
		b *= 1.0f+brassB*highBrassIntensity*1.0f;
		
		r = max(0.0f, min(1.0f, r));
		g = max(0.0f, min(1.0f, g));
		b = max(0.0f, min(1.0f, b));
		mainCurrentPalette.color[i].red = uint8(r**gammaInv*255);
		mainCurrentPalette.color[i].green = uint8(g**gammaInv*255);
		mainCurrentPalette.color[i].blue = uint8(b**gammaInv*255);
	}
	
	/*
	// the leaves flash for high guitar notes (this looked stupid so it's gone)
	for (int i = 208; i <= 213; i++) {
		// convert to normalized linear space
		float r = (jjBackupPalette.color[i].red/255.0f)**gamma;
		float g = (jjBackupPalette.color[i].green/255.0f)**gamma;
		float b = (jjBackupPalette.color[i].blue/255.0f)**gamma;
		
		if (guitarNote >= ModNote::A5 && !jjLowDetail) {
			r *= 1.0f+guitarIntensity*guitarNote*0.06f;
			g *= 1.0f+guitarIntensity*guitarNote*0.02f;
		}
		
		r = max(0.0f, min(1.0f, r));
		g = max(0.0f, min(1.0f, g));
		b = max(0.0f, min(1.0f, b));
		mainCurrentPalette.color[i].red = uint8(r**gammaInv*255);
		mainCurrentPalette.color[i].green = uint8(g**gammaInv*255);
		mainCurrentPalette.color[i].blue = uint8(b**gammaInv*255);
	}
	*/
	
	guitarIntensity = max(0.0f, guitarIntensity-0.04f);
	highBrassIntensity = max(0.0f, highBrassIntensity-0.03f);
	
	// candles/torches are controlled by lead
	float fireLightnessMult = 1.0f+leadIntensity**2;
	for (int i = 183; i <= 186; i++) {
		mainCurrentPalette.color[i].setHSL(jjBackupPalette.color[i].getHue(), jjBackupPalette.color[i].getSat(), uint8(min(255,int(jjBackupPalette.color[i].getLight())*fireLightnessMult)));
	}
	
	leadIntensity = max(0.0f, leadIntensity-0.01f);

	jjSpriteModeSetMapping(mainMappingIndex, mainIndexMapping, mainCurrentPalette);
	
	for (int i = 2; i < 6; i++) {
		jjLayers[i].spriteMode = SPRITE::MAPPING;
		jjLayers[i].spriteParam = mainMappingIndex;
	}
	
	//MayUtils::profilerLeave("updateMainMapping");
}

MayModUtils::MayModData @modData;

// We COULD automatically identify chords...but it's easier to just hardcode
// them. Dictionary keys are "[order],[row]".
namespace CHORD {
enum Chord {
	NONE,
	C,
	C_E,
	Dm,
	Dm_A, // Dm/A
	E,
	E_GS, // E/G#
	E_B,
	F,
	F_C,
	F_A,
	G,
	G_D,
	G_B,
	Am,
	Am_C,
	Am_E,
}
array<int> chordColors = {
	0x000000, // NONE
	0x701090, // C
	0x5018b0, // C/E
	0x4020bb, // Dm
	0x2010dd, // Dm/A
	0x2077c0, // E
	0x1060ff, // E/G#
	0x3080a0, // E/B
	0x6000ff, // F
	0x4010ff, // F/C
	0x8000ff, // F/A
	0x901020, // G
	0xb00020, // G/D
	0xc00010, // G/B
	0x109020, // Am
	0x00a030, // Am/C
	0x00b020, // Am/E
};
array<string> chordNames = {
	"none",
	"C",
	"C/E",
	"Dm",
	"Dm/A",
	"E",
	"E/G#",
	"E/B",
	"F",
	"F/C",
	"F/A",
	"G",
	"G/D",
	"G/B",
	"Am",
	"Am/C",
	"Am/E",
};
dictionary chordData = {
	{"4,0", Am_C},
	{"4,24", G_D},
	{"4,48", F},
	{"4,72", E},
	
	{"5,0", Am_C},
	{"5,24", G_D},
	{"5,48", F},
	{"5,72", E},
	
	{"6,0", Am_E},
	{"6,12", G_D},
	{"6,24", F_C},
	{"6,36", E},
	{"6,48", Am_E},
	{"6,60", G_D},
	{"6,72", Am_E},
	
	{"7,0", Am_E},
	{"7,12", G_D},
	{"7,24", F_C},
	{"7,36", E},
	{"7,48", Am_E},
	{"7,60", G_D},
	{"7,72", Am_E},
	
	{"8,0", Dm},
	{"8,24", G_D},
	{"8,48", F},
	{"8,72", E},
	
	{"9,0", Dm},
	{"9,24", G_D},
	{"9,48", F},
	{"9,72", E},
	
	{"10,0", Dm},
	{"10,12", C},
	{"10,24", F},
	{"10,36", G_D},
	{"10,48", Dm},
	{"10,60", C},
	{"10,72", Dm},
	
	{"11,0", Dm},
	{"11,12", C},
	{"11,24", F},
	{"11,36", G_D},
	{"11,48", Dm},
	{"11,60", C},
	{"11,72", Dm_A},
	
	{"12,0", Am},
	{"12,24", F_A},
	{"12,48", G},
	{"12,72", E_GS},
	
	{"13,0", Am},
	{"13,24", G},
	{"13,48", Am},
	
	{"14,24", F_A},
	{"14,48", G},
	{"14,72", E_GS},
	
	{"15,0", Am},
	{"15,24", G_B},
	{"15,48", Am},
	
	{"16,0", Am},
	{"16,24", C_E},
	{"16,48", G},
	{"16,72", F},
	
	{"17,0", E_B},
	{"17,48", Am},
	{"17,96", G_B},
	
	{"18,0", Am},
	{"18,24", C},
	{"18,48", G_B},
	{"18,72", Am_C},
	
	{"19,0", Am},
	{"19,24", C},
	{"19,48", G_B},
	{"19,72", Am_C},
	
	{"20,0", Am},
	{"20,24", C},
	{"20,48", G_B},
	{"20,72", Am_C},
	{"20,84", G_B},
	
	{"21,0", Am},
	{"21,24", C},
	{"21,48", E_B},
	{"21,72", Am_C},
	{"21,84", G_B},
	
	{"22,0", Am},
	{"22,24", C},
	{"22,48", E_B},
	{"22,72", Am_C},
	{"22,84", G_B},
	
	// last four patterns are repeated now
	{"23,0", Am},
	{"23,24", C},
	{"23,48", G_B},
	{"23,72", Am_C},
	
	{"24,0", Am},
	{"24,24", C},
	{"24,48", G_B},
	{"24,72", Am_C},
	{"24,84", G_B},
	
	{"25,0", Am},
	{"25,24", C},
	{"25,48", E_B},
	{"25,72", Am_C},
	{"25,84", G_B},
	
	{"26,0", Am},
	{"26,24", C},
	{"26,48", E_B},
	{"26,72", Am_C},
	{"26,84", G_B}
};
}
void onNotePlayed(uint note, uint instrument, uint volume, uint channel, uint reserved) {
	switch (instrument) {
		case 4: // bass
			bassIntensity = 1.0f;
			break;
		case 5: // clarinet lead
			if (jjGetModOrder() > 13 || (channel != 6 && channel != 8)) { // ignore echo channels
				leadIntensity = max(leadIntensity,volume/64.0f);
				leadNote = note;
			}
			break;
		case 9: // cymbal crash
			cymbalIntensity = 1.0f;
			break;
		case 11: // crystal rhodes
			crystalIntensity = max(crystalIntensity, volume/64.0f);
			// yes this will just use the last note in any chord.
			// Which is fine for this song.
			crystalNote = note;
			break;
		case 14: // nylon guitar
			if (note >= ModNote::A5 && guitarIntensity < (volume/64.0f*note)) {
				guitarIntensity = volume/64.0f;
				guitarNote = note;
			}
			break;
		case 18: // snare
			if (volume > 40) snareIntensity = max(snareIntensity, volume/64.0f);
			break;
		case 20: // drum 2, messy sample but it sounds like a drum kit tambourine in the song
			tambourineIntensity = max(tambourineIntensity, volume/64.0f);
			break;
		case 24: // drum 6, high pitched, combines nicely with the snare
			if (volume > 40) drum6Intensity = max(drum6Intensity, volume/64.0f);
			break;
		case 33:
		case 34: // orchestral snare, 33 is roll
			if (volume/64.0f >= orchestralSnareIntensity) {
				orchestralSnareIntensity = volume/64.0f;
				// roll decays slower
				orchestralSnareDecay = instrument == 33 ? 0.01f : 0.03f;
			}
			break;
		case 35: // brass
			{
				int order = jjGetModOrder();
				if (order == 21 || order == 22 || order == 25 || order == 26) {
					if (channel == 19) {
						highBrassNoteLeft = note;
					} else if (channel == 20) {
						highBrassNoteRight = note;
						highBrassIntensity = volume/64.0f;
					}
				}
			}
			break;
		default:
			break;
	}
}

/*============================================================================*\
*
* BACKGROUND PARTICLES
*
* Because sometimes jjPARTICLEs aren't enough. These are pretty specialized
* because performance.
* 
\*============================================================================*/

const int MAX_BACKGROUND_PARTICLES = 1024;
const float BACKGROUND_PARTICLES_WIDTH = 1024.0f;
const float BACKGROUND_PARTICLES_HEIGHT = 1024.0f;
enum BACKGROUND_PARTICLE_PROPERTIES {
	P_STARTTICK, // Lasts a bit over 66.5 hours before losing precision, it's fine
	P_LIFETIME,
	P_STARTX,
	P_STARTY,
	P_SPEEDX, // pixels/tick
	P_SPEEDY,
	P_PARALLAX_X, // Same meaning as jjLAYER::xSpeed
	P_PARALLAX_Y,
	P_COLOR, // palette index
	P_BIDICOLORLEN, // for bidirectional color loop, number of palette indices to use
	// for bidirectional color loop, colors more than BIDICOLORCUTOFF indices past P_COLOR will not be drawn at all (for flickering stars)
	P_BIDICOLORCUTOFF,
	P_BIDICOLORSPEED, // speed of bidirectional color loop in indices/frame (hint use values of less than 1)
	P_AIR_RESISTANCE, // this name looks stupid in a level set in space... (not implemented)
	P_ORBIT_SPEED, // circular orbit around start position (not implemented)
	NUM_BGP_PROPS,
}
array<float> backgroundParticles(MAX_BACKGROUND_PARTICLES*NUM_BGP_PROPS);

void addBackgroundParticles(int count,
	int lifetime, // pass 0 for infinite
	float startXMin = 0.0f, float startXMax = 1024.0f,
	float startYMin = 0.0f, float startYMax = 1024.0f,
	float speedXMin = 0.0f, float speedXMax = 0.0f,
	float speedYMin = 0.0f, float speedYMax = 0.0f,
	float parallaxMin = 1.0f, float parallaxMax = 1.0f,
	float color = 15,
	float bidiColorLen = 0,
	float bidiColorCutoff = 255.0f,
	float bidiColorSpeedMin = 0.0f, float bidiColorSpeedMax = 0.0f) {
	
	int added = 0;
	int i = 0;
	
	float realLifetime = lifetime == 0 ? 1.0e37f : lifetime;
	while (added < count && i < MAX_BACKGROUND_PARTICLES) {
		int baseIndex = i*NUM_BGP_PROPS;
		if (jjGameTicks-int(backgroundParticles[baseIndex+P_STARTTICK]) >= int(backgroundParticles[baseIndex+P_LIFETIME])) {
			// found inactive particle, replace it
			backgroundParticles[baseIndex+P_STARTTICK] = jjGameTicks;
			backgroundParticles[baseIndex+P_LIFETIME] = realLifetime;
			float mix = rng()/MAX_UINT64_DOUBLE;
			backgroundParticles[baseIndex+P_STARTX] = mix*startXMin+(1.0f-mix)*startXMax;
			mix = rng()/MAX_UINT64_DOUBLE;
			backgroundParticles[baseIndex+P_STARTY] = mix*startYMin+(1.0f-mix)*startYMax;
			mix = rng()/MAX_UINT64_DOUBLE;
			// speed and parallax get matched
			backgroundParticles[baseIndex+P_SPEEDX] = mix*speedXMin+(1.0f-mix)*speedXMax;
			backgroundParticles[baseIndex+P_SPEEDY] = mix*speedYMin+(1.0f-mix)*speedYMax;
			backgroundParticles[baseIndex+P_PARALLAX_X] = mix*parallaxMin+(1.0f-mix)*parallaxMax;
			backgroundParticles[baseIndex+P_PARALLAX_Y] = mix*parallaxMin+(1.0f-mix)*parallaxMax;
			
			backgroundParticles[baseIndex+P_COLOR] = color;
			backgroundParticles[baseIndex+P_BIDICOLORLEN] = bidiColorLen;
			backgroundParticles[baseIndex+P_BIDICOLORCUTOFF] = bidiColorCutoff;
			mix = rng()/MAX_UINT64_DOUBLE;
			backgroundParticles[baseIndex+P_BIDICOLORSPEED] = mix*bidiColorSpeedMin+(1.0f-mix)*bidiColorSpeedMax;;
			added++;
		}
		i++;
	}
}

// Yeah you get denser particles at lower max resolutions. No I don't really care.
const float BACKGROUND_PARTICLE_PLANE_WIDTH = jjResolutionMaxWidth+64.0f;
const float BACKGROUND_PARTICLE_PLANE_HEIGHT = jjResolutionMaxHeight+64.0f;
void _updateAndDrawBackgroundParticles(jjPLAYER@ p) {
	//MayUtils::profilerEnter("updateAndDrawBackgroundParticles");
	if (!jjLowDetail) {
		for (int i = 0; i < MAX_BACKGROUND_PARTICLES; i++) {
			int baseIndex = i*NUM_BGP_PROPS;
			float startTick = backgroundParticles[baseIndex+P_STARTTICK];
			float lifetime = backgroundParticles[baseIndex+P_LIFETIME];
			float elapsed = jjGameTicks-startTick;
			if (lifetime > elapsed) {
				float startX = backgroundParticles[baseIndex+P_STARTX];
				float startY = backgroundParticles[baseIndex+P_STARTY];
				float speedX = backgroundParticles[baseIndex+P_SPEEDX];
				float speedY = backgroundParticles[baseIndex+P_SPEEDY];
				
				float x = startX+speedX*elapsed;
				float y = startY+speedY*elapsed;
				
				int color = int(backgroundParticles[baseIndex+P_COLOR]);
				int bidiColorLen = int(backgroundParticles[baseIndex+P_BIDICOLORLEN]);
				int bidiOffset = 0;
				if (bidiColorLen > 0) {
					float bidiColorSpeed = backgroundParticles[baseIndex+P_BIDICOLORSPEED];
					bidiOffset = int(bidiColorSpeed*elapsed)%(bidiColorLen*2);
					if (bidiOffset > bidiColorLen) bidiOffset = bidiColorLen*2-bidiOffset;
					color += bidiOffset;
				}
				
				if (bidiOffset <= int(backgroundParticles[baseIndex+P_BIDICOLORCUTOFF])) {				
					x -= p.cameraX*backgroundParticles[baseIndex+P_PARALLAX_X];
					y -= p.cameraY*backgroundParticles[baseIndex+P_PARALLAX_Y];
					
					// angelscript's modulo operator, rather inconveniently
					// in this case, simply evaluates to 0 when the signs of
					// the operands don't match.
					if (x < 0.0f) x = BACKGROUND_PARTICLE_PLANE_WIDTH+(x%-BACKGROUND_PARTICLE_PLANE_WIDTH);
					if (y < 0.0f) y = BACKGROUND_PARTICLE_PLANE_HEIGHT+(y%-BACKGROUND_PARTICLE_PLANE_HEIGHT);
					jjDrawPixel(p.cameraX+x%BACKGROUND_PARTICLE_PLANE_WIDTH, p.cameraY+y%BACKGROUND_PARTICLE_PLANE_HEIGHT, color, SPRITE::TRANSLUCENT, 0, 6, 4);
				}
			}
			// else, particle is inactive
		}
	}
	//MayUtils::profilerLeave("updateAndDrawBackgroundParticles");
}

// a slightly faster version of the above that only works for Astral Witchcraft
// stars, removing checks that aren't needed by them
// very lazy optimization...
const int BP_FAST_BIDI_COLOR_LEN = 6;
const int BP_FAST_BIDI_COLOR_CUTOFF = 4;
const int BP_FAST_COLOR = 72;
void _updateAndDrawFastBackgroundStars(jjPLAYER@ p) {
	//MayUtils::profilerEnter("updateAndDrawFastBackgroundStars");
	if (!jjLowDetail) {
		for (int i = 0; i < MAX_BACKGROUND_PARTICLES; i++) {
			int baseIndex = i*NUM_BGP_PROPS;
			float elapsed = jjGameTicks;

			float startX = backgroundParticles[baseIndex+P_STARTX];
			float startY = backgroundParticles[baseIndex+P_STARTY];
			float speedX = backgroundParticles[baseIndex+P_SPEEDX];
			float speedY = backgroundParticles[baseIndex+P_SPEEDY];
			
			float x = startX+speedX*elapsed;
			float y = startY+speedY*elapsed;
			
			int color = BP_FAST_COLOR;
			int bidiOffset = 0;
			float bidiColorSpeed = backgroundParticles[baseIndex+P_BIDICOLORSPEED];
			bidiOffset = int(bidiColorSpeed*elapsed)%(BP_FAST_BIDI_COLOR_LEN*2);
			if (bidiOffset > BP_FAST_BIDI_COLOR_LEN) bidiOffset = BP_FAST_BIDI_COLOR_LEN*2-bidiOffset;
			
			if (bidiOffset <= BP_FAST_BIDI_COLOR_CUTOFF) {
				color += bidiOffset;			
				x -= p.cameraX*backgroundParticles[baseIndex+P_PARALLAX_X];
				y -= p.cameraY*backgroundParticles[baseIndex+P_PARALLAX_Y];
				
				// angelscript's modulo operator, rather inconveniently
				// in this case, simply evaluates to 0 when the signs of
				// the operands don't match.
				if (x < 0.0f) x = BACKGROUND_PARTICLE_PLANE_WIDTH+(x%-BACKGROUND_PARTICLE_PLANE_WIDTH);
				if (y < 0.0f) y = BACKGROUND_PARTICLE_PLANE_HEIGHT+(y%-BACKGROUND_PARTICLE_PLANE_HEIGHT);
				jjDrawPixel(p.cameraX+x%BACKGROUND_PARTICLE_PLANE_WIDTH, p.cameraY+y%BACKGROUND_PARTICLE_PLANE_HEIGHT, color, SPRITE::TRANSLUCENT, 0, 6, 4);
			}
		}
	}
	//MayUtils::profilerLeave("updateAndDrawFastBackgroundStars");
}

/*============================================================================*\
*
* WEAPONS
*
* Move these to a separate file?
*
* Two of Astral Witchcraft's weapons are not full custom weapons. This makes
* them harder to reuse in your own levels (let alone in mutators!), but saves me
* the trouble of making a lot of sprites. Also lets me do a few performance
* hacks, which is nice since both of these weapons spew out a lot of bullets.
* 
\*============================================================================*/

/*
*
* Magic Blaster
*
* Just a blaster retheme to fit the level better. In gameplay, this is the
* blaster with a bigger hitbox, it's unlikely you care about making a full
* weapon out of it.
*
*/
SpriteParticleData @magicBlasterParticles;
array<int> magicBlasterMappingIndices;

// This plain function is used instead of jjBEHAVIORINTERFACE because it offers
// better performance and I don't need any jjBEHAVIORINTERFACE features here.
void magicBlasterBehave(jjOBJ@ obj) {
	jjPLAYER@ creator;
	if (obj.creatorType == CREATOR::PLAYER) {
		@creator = jjPlayers[obj.creatorID];
		if (obj.state == STATE::START && creator.isLocal) {
			jjSample(obj.xPos, obj.yPos, SOUND::COMMON_TELPORT1, 63, 44100+(rng() & 7)*3150);
		}
	}
	obj.behave(BEHAVIOR::BULLET, false);
	if (obj.state == STATE::FLY) {
		// BEHAVIOR::BULLET increments frameID when counter & 3 == 0
		// so this takes it to 70fps
		if (obj.counter & 3 != 0) obj.frameID += 1;
		obj.frameID = obj.frameID%6;
		obj.determineCurFrame();
		for (int i = 0; i < jjLocalPlayerCount; i++) {
			// draw bullet in red if you can be hurt by it
			jjDrawSpriteFromCurFrame(obj.xPos, obj.yPos, obj.curFrame+(creator.isEnemy(jjLocalPlayers[i]) ? 6 : 0), obj.direction, playerID: jjLocalPlayers[i].playerID);
		}
	} else if (obj.state == STATE::EXPLODE) {
		if (pointIsCloseToCamera(int(obj.xPos), int(obj.yPos), 32)) {
			// yes, this is special == curAnim as an upwards bullet check.
			// But it makes sense here, because what we care about in this
			// particular case IS which anim it's using!
			float rotationAngle = obj.special == obj.curAnim ? 0.75f : (obj.direction >= 0 ? 0.0f : 0.5f);
			for (int i = 0; i < 64; i++) {
				jjPARTICLE@ part = magicBlasterParticles.addParticle(PARTICLE::SPARK, obj.xPos, obj.yPos, 0.05f, rotationAngle);
				if (part !is null) {
					// add a small random fuzz to the speeds to make
					// the sparks look more chaotic
					part.xSpeed += (rng() & 0xFF) / 255.0f - 0.5f;
					part.ySpeed += (rng() & 0xFF) / 255.0f - 0.5f;
					if (creator.isEnemy(jjLocalPlayers[0])) {
						part.spark.color = 24;
						part.spark.colorStop = 30;
					} else {
						part.spark.color = 32;
						part.spark.colorStop = 38;
					}
				}
			}
			jjSample(obj.xPos, obj.yPos, SOUND::COMMON_BURN, 24, 22050+(rng() & 7)*1024);
		}
		obj.delete();
	}
}

const int SPINNER_DURATION = 210;
// unpowered + powered up + enemy spinner are crammed into the same anim, to
// save on anim slots (there are only 1500 of them after all!)
const int SPINNER_FRAMES = 4;
const float SPINNER_ACCELERATION = 0.5f;
const float SPINNER_MAX_VELOCITY = 16.0f;
const float SPINNER_AIR_RESISTANCE = 0.3f;
const OBJECT::Object SPINNERBULLETPU = OBJECT::ICEBULLETPU;

/*
*
* Spinner
*
* Shoots up-diagonal with heavy air resistance, and accelerates in the
* direction it's facing. Clumsily tries to avoid hitting the ceiling (or floor).
* Initial velocity is slightly fuzzed, but in a manner that isn't *too* prone to
* online desyncs.
* If mouse aim is allowed, shoots in the direction it's aimed instead of just
* up-diagonal.
*
* This is a lot more unique than the magic blaster so it has more merit for
* turning into a proper custom weapon, but its assumption that the player's ammo
* property will be clean would be an issue there; the fuzzing will just break if
* you make it infinite or give it a multiplier other than 1. The mask check for
* identifying "ceilings"/"floors" is also extremely lazy and will not work well
* in levels with masks that aren't very thick.
*
*
* desync details:
*
* The fuzzing is handled based on the firing player's ammo. Every bullet packet
* received sets the corresponding player's ammo property, and this alone is
* enough to usually synchronize the fuzz between all players in the server.
*
* It fails if multiple bullet packets for the same player are received on the
* same frame. In this case, we only have the ammo value for the final bullet for
* that frame, and while we could usually infer it for the previous bullets, *we
* don't know which bullet is which*. So we just let that desync happen - it
* doesn't happen on good connections, and on bad connections, bullet
* positions are already desynced enough that a little more desync won't matter.
*
* Also it desyncs if players disagree on whether the player that fired the bullet
* is in antiGrav mode so you might wanna find a way to change that if you use it
* in a level with antiGrav ;p
*/

jjRNG spinnerRng(); // initial seed does not matter
void spinnerExplode(jjOBJ@ obj, bool isPowerup, bool isEnemy) {
	jjObjects[jjAddObject(OBJECT::EXPLOSION, obj.xPos, obj.yPos, obj.objectID, CREATOR::OBJECT)].curAnim = _getAnimIndex(
		isEnemy ? ANIM_SPINNER_KILL_ENEMY : (isPowerup ? ANIM_SPINNER_KILL_PU : ANIM_SPINNER_KILL));
	obj.delete();
}

void spinnerBehave(jjOBJ@ obj) {
	jjPLAYER@ creator;
	if (obj.creatorType == CREATOR::PLAYER) {
		@creator = jjPlayers[obj.creatorID];
		if (obj.state == STATE::START) {
			if (creator.isLocal) {
				jjSample(obj.xPos, obj.yPos, SOUND::INTRO_SWISH3, 63, 40000+(rng() & 7)*3150);
			}
			spinnerRng.seed(creator.ammo[SPINNER_WEAPON_SLOT]);
			float speed = 10.0f+(spinnerRng() / MAX_UINT64_DOUBLE) * 4.0f;
			if (jjAllowsMouseAim && (obj.xSpeed != 0.0f || obj.ySpeed != 0.0f)) {
				float mult = speed/sqrt(obj.xSpeed*obj.xSpeed+obj.ySpeed*obj.ySpeed);
				obj.xSpeed *= mult;
				obj.ySpeed *= mult;
			} else {
				obj.ySpeed = speed * (creator.antiGrav ? 1.0f : -1.0f);
			}
		}
	}
	if (obj.state == STATE::START) {
		obj.state = STATE::FLY;
		obj.xAcc = obj.direction < 0 ? -(SPINNER_ACCELERATION-SPINNER_AIR_RESISTANCE) : (SPINNER_ACCELERATION-SPINNER_AIR_RESISTANCE);
		if (!jjAllowsMouseAim) obj.xSpeed = (obj.direction < 0 ? -2.0f : 2.0f);
		obj.xSpeed += obj.var[7] / 65536.0f;
	}
	bool isPowerup = obj.eventID == SPINNERBULLETPU;
	if (--obj.counter <= 0 || obj.state == STATE::EXPLODE) {
		spinnerExplode(obj, isPowerup, creator.isEnemy(jjLocalPlayers[0]));
	} else if (obj.state == STATE::FLY) {
		obj.xSpeed += obj.xAcc;
		
		if (jjMaskedPixel(int(obj.xPos), int(obj.yPos-64))) {
			obj.ySpeed += 0.5f;
		}
		if (obj.ySpeed < 0.0f && jjMaskedPixel(int(obj.xPos), int(obj.yPos-32))) {
			obj.ySpeed += 2.0f;
		}
		if (jjMaskedPixel(int(obj.xPos), int(obj.yPos+64))) {
			obj.ySpeed -= 0.5f;
		}
		if (obj.ySpeed > 0.0f && jjMaskedPixel(int(obj.xPos), int(obj.yPos+32))) {
			obj.ySpeed -= 2.0f;
		}
		
		if (obj.xSpeed > SPINNER_MAX_VELOCITY) {
			obj.xSpeed = SPINNER_MAX_VELOCITY;
		} else if (obj.xSpeed < -SPINNER_MAX_VELOCITY) {
			obj.xSpeed = -SPINNER_MAX_VELOCITY;
		}
		if (obj.ySpeed > 0) {
			obj.ySpeed = obj.ySpeed - SPINNER_AIR_RESISTANCE;
			if (obj.ySpeed < 0) obj.ySpeed = 0;
		} else if (obj.ySpeed < 0) {
			obj.ySpeed = obj.ySpeed + SPINNER_AIR_RESISTANCE;
			if (obj.ySpeed > 0) obj.ySpeed = 0;
		}
		obj.xPos += obj.xSpeed;
		obj.yPos += obj.ySpeed;
		
		if (jjMaskedPixel(int(obj.xPos), int(obj.yPos))) {
			spinnerExplode(obj, isPowerup, creator.isEnemy(jjLocalPlayers[0]));
		} else {
			obj.frameID = (SPINNER_DURATION-obj.counter)%SPINNER_FRAMES;
			obj.determineCurFrame();
			for (int i = 0; i < jjLocalPlayerCount; i++) {
				// draw bullet in red if you can be hurt by it
				// yes there's no way for a player to tell if enemy bullets are
				// powered up...not an issue here because bullets in Astral Witchcraft
				// are always powered up, but if you want to use this weapon in more
				// normal levels, uhhh draw another red sprite or something?
				jjDrawSpriteFromCurFrame(obj.xPos, obj.yPos, obj.curFrame+(creator.isEnemy(jjLocalPlayers[i]) ? 8 : (isPowerup ? 4 : 0)), obj.direction, playerID: jjLocalPlayers[i].playerID);
			}
		}
	} else if (obj.state == STATE::KILL || obj.state == STATE::DEACTIVATE) {
		obj.delete();
	}
}

/*
*
* Buttstomp Nova
*
* This gets its own weapon so that the nova can be created with one fireBullet()
* call - and therefore one bullet packet in online games.
*
*/
jjRNG buttstompNovaRNG(); // initial seed does not matter
void buttstompNovaBehave(jjOBJ@ obj) {
	jjPLAYER@ creator;
	int bulletCount = 0;
	if (obj.creatorType == CREATOR::PLAYER) {
		@creator = jjPlayers[obj.creatorID];
		// A remote creator created this object via a bullet packet;
		// bullet packets set the player's ammo, and we use that ammo
		// for the number of bullets to create in the nova.
		//
		// However, we set the local player's ammo back to 0 immediately
		// after the fireBullet call, so that they can't actually select
		// this weapon (pandemonium would ensue if they could). So if
		// the nova is from a local player, we instead use their food
		// value. This is why we reset their food value to 0 here
		// instead of in the same place we reset their ammo to 0.
		// Simple! Not hacky at all!
		if (creator.isLocal) {
			bulletCount = creator.food;
			creator.food = 0;
		} else {
			bulletCount = creator.ammo[BUTTSTOMP_WEAPON_SLOT];
		}
	}
	
	// sanity check, theoretically you could receive a bullet packet with a
	// phony amount of ammo (up to 127)
	if (bulletCount > MAX_FOOD) bulletCount = MAX_FOOD;
	
	float xPos = obj.xPos;
	float yPos = obj.yPos;
	
	// zapper bullets quickly accelerate to MayZapper::BASE_SPEED
	_createZapperRing(xPos, yPos, bulletCount, 4.0f, obj.creatorID);
	
	// subtle fake stereo. 
	jjSample(xPos+32.0f, yPos, ZAPPER_NOVA_SOUND, 63);
	jjSample(xPos-32.0f, yPos, ZAPPER_NOVA_SOUND, 63, 40000);
	
	
	
	obj.delete();
}
void buttstompPowerPickupBehave(jjOBJ@ obj) {
	bool defaultDraw = true;
	if (obj.yPos < 848.0f && obj.xPos < 3520.0f) {
		defaultDraw = false;
		// draw like floating debris. No need to synchronize this
		// between players, it's purely visual.
		if (obj.var[7] == 0) {
			obj.var[7] = rng() & 0x7FFFFFFF; // floatSpeed
			obj.var[8] = rng() & 0x7FFFFFFF; // floatOffset
			obj.var[9] = rng() & 0x7FFFFFFF; // rotationSpeed
			obj.var[10] = rng() & 1023; // rotationOffset
		}
		float floatSpeed = (obj.var[7]/MAX_INT_DOUBLE)*0.01f+0.005f;
		float floatOffset = (obj.var[8]/MAX_INT_DOUBLE) * PI * 2.0f;
		float rotationSpeed = ((obj.var[9]/MAX_INT_DOUBLE) - 0.5f) * 8.0f + 4.0f;
		// much less up/down motion than debris since we don't want it to
		// be too far away from where its collision is
		float y = obj.yPos + sin(jjRenderFrame*floatSpeed+floatOffset)*3.0f;
		int angle = int(jjRenderFrame * rotationSpeed);
		jjDrawRotatedSpriteFromCurFrame(obj.xPos, y, obj.curFrame, angle+obj.var[10]);
	}
	obj.behave(BEHAVIOR::PICKUP, defaultDraw);
	
	// Don't allow these pickups to be shot down, it can hurt flow etc.
	// (The carrots, on the other hand, are meant to be shot down!)
	if (obj.state == STATE::FLOATFALL) obj.state = STATE::FLOAT;
}
// Zapper ammo doesn't look physical so we don't draw it like floating debris
// Also we give 20 ammo instead of 3
// (that part could be done through SEWeapon features instead...)
const int AMMO_PICKUP_AMMO = 20;
// the performance of this is ass btw but it's ok since we don't have very many
// ammo pickups (none at all in the CTF version!)
class AstralAmmoPickup : jjBEHAVIORINTERFACE {
	jjBEHAVIOR originalBehavior;
	AstralAmmoPickup(const jjBEHAVIOR &in behavior) {
		originalBehavior = behavior;
	}
	void onBehave(jjOBJ@ obj) {
		obj.behave(originalBehavior);
		// don't allow these to be shot down either
		if (obj.state == STATE::FLOATFALL) obj.state = STATE::FLOAT;
	}
	bool onObjectHit(jjOBJ@ obj, jjOBJ@ bullet, jjPLAYER@ player, int force) {
		if (bullet is null) {
			obj.behavior = originalBehavior;
			if (player.objectHit(obj, force, obj.playerHandling)) {
				int weapon = obj.var[3] + 1;
				int max = jjWeapons[weapon].maximum;
				player.ammo[weapon] = min(max, player.ammo[weapon]+AMMO_PICKUP_AMMO-3);
				return true;
			}
			obj.behavior = this;
		}
		return false;
	}
}
void _createZapperRing(float xPos, float yPos, int count, float speed, int playerID) {
	buttstompNovaRNG.seed(count);
	for (int i = 0; i < count; i++) {
		float angle = float(i+0.5f)/float(count)*PI*2;
		
		// zapper behavior plays a sound on start if it thinks it was
		// created by a local player, so claim it was object-created
		// and then change it
		jjOBJ@ bullet = jjObjects[jjAddObject(se::getPoweredBulletOfWeapon(ZAPPER_WEAPON_SLOT), xPos, yPos, playerID, CREATOR::OBJECT)];
		bullet.creatorType = CREATOR::PLAYER;
		bullet.xSpeed = sin(angle)*speed;
		bullet.ySpeed = cos(angle)*speed;
		
		// long duration, with some fuzz so they don't all disappear at once
		bullet.counterEnd = 140+(buttstompNovaRNG() & 31);
		// lots of extra bounces
		MayZapper::setBounces(bullet, 7);
		// suppress sounds for most of the bullets, otherwise they will
		// cut off the thunder sound very quickly
		if (i & 7 != 0) MayZapper::setHasSound(bullet, false);
	}
}


void _setupWeapons() {
	jjPIXELMAP spritesheet("mayAstralWitchcraft_blaster.png");
	jjAnimSets[levelAnimSet].load(spritesheet, 48, 48, firstAnimToOverwrite: _getAnimIndex(ANIM_MAGIC_BLASTER));
	
	jjSampleLoad(ZAPPER_NOVA_SOUND, "mayAstralThunder.ogg");
	jjSampleLoad(NOVA_CHARGE_SOUND, "mayAstralCharge.ogg");
	// lazy way to stop original explosion sound from being audible
	jjSampleLoad(SOUND::COMMON_EXPSM1, "maySilence.wav");
	
	/* 
	 * Magic blaster
	 */
	@magicBlasterParticles = SpriteParticleData(spritesheet, endX: 48, endY: 48);

	jjWeapons[WEAPON::BLASTER].defaultSample = false;
	jjObjectPresets[OBJECT::BLASTERBULLETPU].determineCurAnim(levelAnimSet, ANIM_MAGIC_BLASTER);
	jjObjectPresets[OBJECT::BLASTERBULLETPU].special = jjObjectPresets[OBJECT::BLASTERBULLETPU].determineCurAnim(levelAnimSet, ANIM_MAGIC_BLASTER_UP, false);
	jjObjectPresets[OBJECT::BLASTERBULLETPU].determineCurFrame();
	jjObjectPresets[OBJECT::BLASTERBULLETPU].behavior = magicBlasterBehave;
	jjObjectPresets[OBJECT::BLASTERBULLETPU].lightType = LIGHT::BRIGHT;
	jjObjectPresets[OBJECT::BLASTERBULLETPU].light = 8;

	/*
	 * Spinner
	 */
	jjWeapons[SPINNER_WEAPON_SLOT].defaultSample = false;
	jjWeapons[SPINNER_WEAPON_SLOT].style = WEAPON::NORMAL;
	jjWeapons[SPINNER_WEAPON_SLOT].spread = SPREAD::NORMAL;
	jjObjectPresets[SPINNERBULLETPU].determineCurAnim(levelAnimSet, ANIM_SPINNER);
	jjObjectPresets[SPINNERBULLETPU].special = jjObjectPresets[SPINNERBULLETPU].curAnim;
	jjObjectPresets[SPINNERBULLETPU].determineCurFrame();
	jjObjectPresets[SPINNERBULLETPU].behavior = spinnerBehave;
	jjObjectPresets[SPINNERBULLETPU].counter = SPINNER_DURATION;
	jjObjectPresets[SPINNERBULLETPU].special = 0; // can't shoot up
	jjObjectPresets[SPINNERBULLETPU].lightType = LIGHT::BRIGHT;
	jjObjectPresets[SPINNERBULLETPU].light = 8;
	jjObjectPresets[SPINNERBULLETPU].freeze = 0; // spinner should not freeze things
	// HACK: Instead of copying or interfering with the MLLE/SEWeapon ammo drawing
	// code just change the anim for the original weapon.
	jjANIMATION@ spinnerAmmoAnim = jjAnimations[jjAnimSets[ANIM::AMMO].firstAnim+SPINNER_AMMO_ANIM_OFFSET];
	spinnerAmmoAnim = jjAnimations[_getAnimIndex(ANIM_SPINNER)]; // copies properties
	spinnerAmmoAnim.firstFrame += 4; // powered up
	spinnerAmmoAnim.frameCount = 4;
	
	/*
	 * Buttstomp nova
	 */
	jjWeapons[BUTTSTOMP_WEAPON_SLOT].defaultSample = false;
	jjObjectPresets[BUTTSTOMP_POWER_PICKUP].determineCurAnim(ANIM::PICKUPS, 78); // potion
	jjObjectPresets[BUTTSTOMP_POWER_PICKUP].determineCurFrame();
	jjObjectPresets[BUTTSTOMP_POWER_PICKUP].behavior = buttstompPowerPickupBehave;
	jjObjectPresets[se::getBasicBulletOfWeapon(BUTTSTOMP_WEAPON_SLOT)].behavior = buttstompNovaBehave;
	
	/*
	 * Zapper detail
	 */
	int zapperPickupID = se::getAmmoPickupOfWeapon(ZAPPER_WEAPON_SLOT);
	jjObjectPresets[zapperPickupID].behavior = AstralAmmoPickup(jjObjectPresets[zapperPickupID].behavior);
}

/*============================================================================*\
*
* FLOATING DEBRIS, AND ALSO NON-FLOATING LIGHTS
* 
\*============================================================================*/

class FloatingDebrisInfo {
	int x;
	int y;
	float floatSpeed;
	float floatOffset;
	float rotationSpeed;
	int rotationOffset;
	uint animFrame;
}

array<FloatingDebrisInfo@> floatingDebris();
array<int> floatingDebrisFrameCounts();

void _setupFloatingDebris() {
	// Load frames from tiles
	int maxDebrisTiles = FLOATING_DEBRIS_LAST-FLOATING_DEBRIS_START+1;
	int debrisTileCount = 0;
	
	/*
	array<int> cumulativeWeights();
	int totalWeight = 0;
	while (debrisTileCount < maxDebrisTiles) {
		int tileId = TILE_INFO_LAYER.tileGet(debrisTileCount,0);
		// blank tile means no more debris tiles
		if (tileId == 0) break;
		
		// number of vertical repetitions of the tile is its weight
		int weight = 1;
		while (TILE_INFO_LAYER.tileGet(debrisTileCount,weight) == tileId) weight++;
		totalWeight += weight;
		cumulativeWeights.insertLast(totalWeight);
		
		if (tileId & TILE::ANIMATED == 0) {
			int frameIndex = _getStaticSpriteFrameIndex(FLOATING_DEBRIS_START+debrisTileCount);
			jjPIXELMAP(tileId).save(jjAnimFrames[frameIndex]);
		} else {
			// TODO: handle animated tiles
		}
	}*/
	
	/*
	*  Now place the debris. 
	*/
	rng.seed(134895);
	
	jjLAYER@ srcLayer = jjLayers[2];
	
	array<int> tileIdToFrame(4096);
	for (int y = 0; y < srcLayer.height; y++) {
		for (int x = 0; x < srcLayer.width; x++) {
			int tileId = srcLayer.tileGet(x, y);
			if (tileId != 0) {
				int frame = tileIdToFrame[tileId];
				if (frame == 0) {
					if (debrisTileCount >= maxDebrisTiles) {
						jjPrint("too many unique tiles in layer, increase debris limit!");
					} else {
						frame = _getStaticSpriteFrameIndex(FLOATING_DEBRIS_START+debrisTileCount++);
						tileIdToFrame[tileId] = frame;
						jjANIMFRAME @animFrame = jjAnimFrames[frame];
						jjPIXELMAP(tileId).trim().save(animFrame);
						animFrame.hotSpotX = -(animFrame.width >> 1);
						animFrame.hotSpotY = -(animFrame.height >> 1);
					}
				}
				
				FloatingDebrisInfo debris();
				debris.animFrame = frame;
				debris.x = x*TILE_SIZE + rng() % 31;
				debris.y = y*TILE_SIZE + rng() % 31;
				debris.floatSpeed = float(rng() / MAX_UINT64_DOUBLE)*0.01f+0.005f;
				debris.floatOffset = float(rng() / MAX_UINT64_DOUBLE)*PI*2;
				debris.rotationSpeed = (float(rng() / MAX_UINT64_DOUBLE)-0.5f)*8.0f+4.0f;
				debris.rotationOffset = rng() & 1023;
				
				floatingDebris.insertLast(debris);
			}
		}
	}
	
	_setupFloatingLightSources();
}

void _drawFloatingDebris(jjPLAYER@ player, jjCANVAS@ canvas) {
	//MayUtils::profilerEnter("drawFloatingDebris");
	int len = floatingDebris.length();
	int cameraX = int(player.cameraX);
	int cameraY = int(player.cameraY);
	const int CULL_DISTANCE = 32;
	int cullRightX = jjSubscreenWidth+32;
	int cullDownY = jjSubscreenHeight+32;
	int renderFrame = SCREENSHOT_MODE ? 700 : jjRenderFrame;
	for (int i = 0; i < len; i++) {
		FloatingDebrisInfo@ debris = floatingDebris[i];
		// calling pointIsCloseToCamera is too slow.
		int cameraDX = debris.x - cameraX;
		int cameraDY = debris.y - cameraY;
		if (cameraDX > -CULL_DISTANCE && cameraDX < cullRightX &&
			cameraDY > -CULL_DISTANCE && cameraDY < cullDownY) {
			int y = debris.y + int(sin(renderFrame*debris.floatSpeed+debris.floatOffset)*8.0f);
			int angle = debris.rotationOffset + int(renderFrame * debris.rotationSpeed);
			canvas.drawRotatedSpriteFromCurFrame(debris.x, debris.y, debris.animFrame, angle);
		}
	}
	//MayUtils::profilerLeave("drawFloatingDebris");
}

// The objectless approach above makes sense for drawing hundreds of bricks that
// don't need to do anything other than be floating bricks, but candles wouldn't
// be very good candles if they didn't emit light, so those are full objects, if
// particularly uninteractive ones.
//
// var[0] and var[1] are the pixel X and Y distances from the object's center of
// gravity to where it should emit light.
const int BASE_LIGHT_VAR = 5;
void floatingLightSourceBehave(jjOBJ@ obj) {
	if (obj.state == STATE::START) {
		// pulse light "Speed" parameter is used to indicate what thing
		// it is
		if (jjParameterGet(int(obj.xOrg)/TILE_SIZE, int(obj.yOrg)/TILE_SIZE, 0, 8) == 0) {
			// candle
			obj.curAnim = _getAnimIndex(ANIM_FLOATING_CANDLE);
			obj.var[0] = 0;
			obj.var[1] = -8;
			obj.var[BASE_LIGHT_VAR] = 8;
			obj.light = 8;

			// xSpeed is rotationSpeed
			obj.xSpeed = (float(rng() / MAX_UINT64_DOUBLE)-0.5f)*8.0f+4.0f;
		} else {
			// candelabrum
			obj.curAnim = _getAnimIndex(ANIM_FLOATING_CANDELABRUM);
			obj.var[0] = 0;
			obj.var[1] = -36;
			obj.var[BASE_LIGHT_VAR] = 16;
			obj.light = 16;
			
			// xSpeed is rotationSpeed
			// candelabrum is big, so it gets less speed
			obj.xSpeed = (float(rng() / MAX_UINT64_DOUBLE)-0.5f)*2.0f+1.0f;
		}
	
		// ySpeed is floatSpeed
		obj.ySpeed = float(rng() / MAX_UINT64_DOUBLE)*0.01f+0.005f;
		
		// yAcc is floatOffset
		obj.yAcc = float(rng() / MAX_UINT64_DOUBLE)*PI*2;
		
		// counter is rotationOffset, how consistent...
		obj.counter = rng() & 1023;
	
		// debate ur friends about how semantically appropriate this is
		obj.state = STATE::FLOAT;
	}
	
	int renderFrame = SCREENSHOT_MODE ? 700 : jjRenderFrame;
	
	float y = obj.yOrg + sin(renderFrame*obj.ySpeed+obj.yAcc)*8.0f;
	float x = obj.xOrg;
	
	int angle = obj.counter + int(renderFrame * obj.xSpeed);
	
	float lightXOffset = obj.var[0]*jjCos(angle)+obj.var[1]*jjSin(angle);
	float lightYOffset = obj.var[0]*jjSin(angle)+obj.var[1]*jjCos(angle);
	
	// light is emitted from the object's xPos and yPos
	obj.xPos = x+lightXOffset;
	obj.yPos = y+lightYOffset;
	
	// but we don't have to draw it there!
	obj.frameID = (renderFrame >> 2) % CANDLE_FRAME_COUNT;
	obj.determineCurFrame();
	jjDrawRotatedSpriteFromCurFrame(x, y, obj.curFrame, angle, 1, 1, SPRITE::MAPPING, mainMappingIndex);
	
	_updateLightObject(obj);
}

// BEHAVIOR::STEADYLIGHT does nothing but handle STATE::DEACTIVATE, and, this
// being a multiplayer level, we don't even care about that, so no need to call
// or recreate any original behavior here.
const array<LIGHT::Type> paramToLightType = {LIGHT::NORMAL, LIGHT::POINT, LIGHT::POINT2, LIGHT::FLICKER, LIGHT::BRIGHT, LIGHT::LASER, LIGHT::RING, LIGHT::RING2};
void steadyLightBehave(jjOBJ@ obj) {
	if (obj.state == STATE::START) {
		int type = paramToLightType[jjParameterGet(int(obj.xPos) >> 5, int(obj.yPos) >> 5, 0, 3)];
		int size = jjParameterGet(int(obj.xPos) >> 5, int(obj.yPos) >> 5, 3, 7) * 2;
		obj.lightType = type;
		// size is interpreted differently for this light type
		if (type == LIGHT::LASER) {
			obj.var[BASE_LIGHT_VAR] = size < 32 ? 32-size : 1;
		} else {
			obj.var[BASE_LIGHT_VAR] = size;
		}
		obj.state = STATE::SLEEP;
		obj.light = obj.var[BASE_LIGHT_VAR];
	}
	_updateLightObject(obj);
}

void _updateLightObject(jjOBJ@ obj) {
	switch (obj.lightType) {
		case LIGHT::FLICKER:
			obj.light = int(obj.var[BASE_LIGHT_VAR] + leadIntensity * 4.5f);
			break;
		default:
			break;
	}
}

void _setupFloatingLightSources() {
	// get animation frames from reference layer, this could be made a lot
	// less magic numbery...
	for (int i = 0; i < CANDLE_FRAME_COUNT; i++) {
		int frame = jjAnimations[_getAnimIndex(ANIM_FLOATING_CANDELABRUM)].firstFrame+i;
		jjANIMFRAME @animFrame = jjAnimFrames[frame];
		// candelabrum is 3 tiles tall
		jjPIXELMAP(i*TILE_SIZE, 0, TILE_SIZE, TILE_SIZE*3, TILE_INFO_LAYER).crop(8,0,16,96).save(animFrame);
		animFrame.hotSpotX = -(animFrame.width >> 1);
		animFrame.hotSpotY = -(animFrame.height >> 1);
	}
	for (int i = 0; i < CANDLE_FRAME_COUNT; i++) {
		int frame = jjAnimations[_getAnimIndex(ANIM_FLOATING_CANDLE)].firstFrame+i;
		jjANIMFRAME @animFrame = jjAnimFrames[frame];
		// candle is only one tile
		jjPIXELMAP((CANDLE_FRAME_COUNT+1+i)*TILE_SIZE, 0, TILE_SIZE, TILE_SIZE, TILE_INFO_LAYER).crop(13, 10, 6, 22).save(animFrame);
		// candle's center of mass is not particularly close to the sprite center...
		animFrame.hotSpotX = -3;
		animFrame.hotSpotY = -17;
	}
	
	jjOBJ@ preset = jjObjectPresets[FLOATING_LIGHT_SOURCE_OBJECT];
	preset.behavior = floatingLightSourceBehave;
	preset.bulletHandling = HANDLING::IGNOREBULLET;
	preset.isBlastable = false;
	preset.isTarget = false;
	preset.lightType = LIGHT::FLICKER;
	// does not interact with players or other objects (hopefully)
	preset.playerHandling = HANDLING::PARTICLE;
	preset.scriptedCollisions = false;
	preset.triggersTNT = false;
	
	jjObjectPresets[OBJECT::STEADYLIGHT].behavior = steadyLightBehave;
}

/*============================================================================*\
*
* SYMBOLS
*
* The witches covered the castle with magical graffiti. What do the symbols
* symbolize? I guess we'll never know!
*
\*============================================================================*/
const int GRAFFITI_TILES_START = 740; // inclusive
const int GRAFFITI_TILES_END = 880; // exclusive

// the actual colors of these indices aren't used, they're just targets for
// SPRITE::MAPPING
const uint8 GRAFFITI_PALETTE_START = 216;
const uint8 GRAFFITI_PALETTE_GROUP_LENGTH = 8;

bool _tileIsGraffiti(uint16 tileID) {
	return tileID >= GRAFFITI_TILES_START && tileID < GRAFFITI_TILES_END;
}

// tile IDs obviously fit into 16 bits and x and y fit into 8 bits each because
// the level is small, so...
array<uint> graffitiTiles();
void _addGraffitiTile(uint16 tileID, int x, int y) {
	graffitiTiles.insertLast(tileID | (y << 16) | (x << 24));
}

array<SpriteParticleData> graffitiTileEmitters(GRAFFITI_TILES_END-GRAFFITI_TILES_START);
void _setupGraffiti() {
	for (uint16 tileID = GRAFFITI_TILES_START; tileID < GRAFFITI_TILES_END; tileID++) {
		jjPIXELMAP tile(tileID);
		int arrayIndex = tileID-GRAFFITI_TILES_START;
		for (int py = 0; py < TILE_SIZE; py++) {
			for (int px = 0; px < TILE_SIZE; px++) {
				uint8 color = tile[px, py];
				if (color != 0) {
					if (color >= 94) {
						// dark group
						tile[px, py] = GRAFFITI_PALETTE_START + rng() % GRAFFITI_PALETTE_GROUP_LENGTH;
					} else if (color <= 89) {
						// bright group
						tile[px, py] = GRAFFITI_PALETTE_START + GRAFFITI_PALETTE_GROUP_LENGTH*2 + rng() % GRAFFITI_PALETTE_GROUP_LENGTH;
						graffitiTileEmitters[arrayIndex].offsets.insertLast(px);
						graffitiTileEmitters[arrayIndex].offsets.insertLast(py);
					} else {
						// medium group
						tile[px, py] = GRAFFITI_PALETTE_START + GRAFFITI_PALETTE_GROUP_LENGTH + rng() % GRAFFITI_PALETTE_GROUP_LENGTH;
					}
				}
			}
		}
		tile.save(tileID);
	}
}

void _updateGraffitiParticles() {
	//MayUtils::profilerEnter("updateGraffitiParticles");
	if (!jjLowDetail) {
		uint len = graffitiTiles.length();
		for (uint i = 0; i < len; i++) {
			uint info = graffitiTiles[i];
			uint x = (info & 0xFF000000) >> 24;
			uint y = (info & 0x00FF0000) >> 16;
			if ((jjGameTicks+i) & 3 == 0 && pointIsCloseToCamera(x*TILE_SIZE+16, y*TILE_SIZE+16, TILE_SIZE)) {
				uint16 tileID = info & 0xFFFF;
				jjPARTICLE@ part = graffitiTileEmitters[tileID-GRAFFITI_TILES_START].addParticle(PARTICLE::SPARK, x*TILE_SIZE, y*TILE_SIZE);
				if (part !is null) {
					part.xSpeed = (rng() & 0xFF) / 512.0f - 0.25f;
					part.ySpeed = (rng() & 0xFF) / 512.0f - 0.25f;
					part.spark.color = 88;
					part.spark.colorStop = 95;
				}
			}
		}
	}
	//MayUtils::profilerLeave("updateGraffitiParticles");
}

/*============================================================================*\
*
* RANDOM JUNK
* 
\*============================================================================*/

// Generally, JJ2+ doesn't offer "draw an arbitrary region of a sprite" (yet?),
// but drawSwingingVineSpriteFromCurFrame lets you efficiently cut pixels off
// the bottom of a sprite. Just need to replace normal transparent pixels with
// color 128.
const array<int> baseFoodMeterSprites = {
	jjAnimations[jjAnimSets[ANIM::JAZZ].firstAnim+RABBIT::FALLBUTTSTOMP].firstFrame+6,
	jjAnimations[jjAnimSets[ANIM::SPAZ].firstAnim+RABBIT::FALLBUTTSTOMP].firstFrame+7,
	// simpler to not do a version check here
	jjAnimations[jjAnimSets[ANIM::LORI].firstAnim+RABBIT::FALLBUTTSTOMP].firstFrame+2,
	};
void _setupFoodMeterGraphics() {
	// recolor array that changes transparent pixels to 128 for use with
	// drawSwingingVine and converts sprite colors to dark greys
	array<uint8> vineMap(256);
	vineMap[0] = 128;
	for (int i = 1; i < 256; i++) {
		vineMap[i] = i;
	}
	for (int i = 16; i < 96; i++) {
		vineMap[i] = 74+min(i & 7, 5);
	}

	for (int i = 0; i < (jjIsTSF ? 3 : 2); i++) {
		jjANIMFRAME@ originalFrame = jjAnimFrames[baseFoodMeterSprites[i]];
		jjPIXELMAP meterSprite(originalFrame);
		meterSprite.recolor(vineMap);
		jjANIMFRAME@ frame = jjAnimFrames[_getStaticSpriteFrameIndex(i == 0 ? FOOD_METER_BG_JAZZ : i == 1 ? FOOD_METER_BG_SPAZ : FOOD_METER_BG_LORI)];
		meterSprite.save(frame);
		frame.hotSpotX = originalFrame.hotSpotX;
		frame.hotSpotY = originalFrame.hotSpotY;
	}
}
void _drawFoodMeter(jjPLAYER@ player, jjCANVAS@ canvas) {
	//MayUtils::profilerEnter("drawFoodMeter");
	int charIndex;
	int vineSprite;
	switch (player.charCurr) {
		case CHAR::JAZZ:
			charIndex = 0;
			vineSprite = _getStaticSpriteFrameIndex(FOOD_METER_BG_JAZZ);
			break;
		case CHAR::SPAZ:
			charIndex = 1;
			vineSprite = _getStaticSpriteFrameIndex(FOOD_METER_BG_SPAZ);
			break;
		case CHAR::LORI:
			charIndex = 2;
			vineSprite = _getStaticSpriteFrameIndex(FOOD_METER_BG_LORI);
			break;
		// Birds and frogs can't buttstomp anyway so don't draw anything
		default:
			//MayUtils::profilerLeave("drawFoodMeter");
			return;
	}
	bool canStomp = player.food >= BUTTSTOMP_MIN_FOOD;
	float ratio = float(player.food) / MAX_FOOD;
	int x = jjSubscreenWidth-jjBorderWidth-jjAnimFrames[vineSprite].width-jjAnimFrames[vineSprite].hotSpotX;
	int y = jjSubscreenHeight-jjBorderHeight-jjAnimFrames[vineSprite].height-jjAnimFrames[vineSprite].hotSpotY;
	// drawSwingingVineSpriteFromCurFrame ignores hotSpotY, so add it manually
	// since we can only cut off the bottom of the sprite, the "background" is actually drawn after the "foreground"
	bool flash = canStomp && (jjRenderFrame & 4 == 0);
	canvas.drawSpriteFromCurFrame(x, y, baseFoodMeterSprites[charIndex], 0, SPRITE::PLAYER, player.playerID);
	if (flash) {
		canvas.drawSpriteFromCurFrame(x, y, baseFoodMeterSprites[charIndex], 0, SPRITE::TRANSLUCENTCOLOR, 15); // white
	}
	if (player.food < MAX_FOOD) {
		canvas.drawSwingingVineSpriteFromCurFrame(x, y+jjAnimFrames[vineSprite].hotSpotY, vineSprite, int((1.0f-ratio)*jjAnimFrames[vineSprite].height), 0, SPRITE::NORMAL);
	}
	//MayUtils::profilerLeave("drawFoodMeter");
}

SpriteParticleData @flagParticles;
SpriteParticleData @flagpoleParticles;
void _setupCTFBaseGraphics() {
	// no more time machine!
	jjPIXELMAP empty(1,1);
	jjANIMATION@ base = jjAnimations[jjAnimSets[ANIM::FLAG].firstAnim+1];
	empty.save(jjAnimFrames[base.firstFrame]);
	empty.save(jjAnimFrames[base.firstFrame+1]);
	jjANIMATION@ lights = jjAnimations[jjAnimSets[ANIM::FLAG].firstAnim+2];
	lights.frameCount = 1;
	empty.save(jjAnimFrames[lights.firstFrame]);

	jjANIMATION@ eva = jjAnimations[jjAnimSets[ANIM::FLAG].firstAnim+5];
	jjPIXELMAP evaSprite("mayAstralGothEva.png");
	evaSprite.save(jjAnimFrames[eva.firstFrame]);
	// move her closer to the flag
	jjAnimFrames[eva.firstFrame].hotSpotX = -48;
	
	// no more foot tapping or cheering (i was too lazy to do more than one frame)
	eva.frameCount = 1;
	jjANIMATION@ evaCheer = jjAnimations[jjAnimSets[ANIM::FLAG].firstAnim+6];
	evaCheer.firstFrame = eva.firstFrame;
	evaCheer.frameCount = 1;
	
	jjANIMFRAME@ flagFrame = jjAnimFrames[jjAnimations[jjAnimSets[ANIM::FLAG].firstAnim+3].firstFrame];
	jjPIXELMAP flagImage(flagFrame);
	// flag only
	@flagParticles = @SpriteParticleData(flagImage, 4, 3, -1, -1, flagFrame.hotSpotX-4, flagFrame.hotSpotY-3);
	// flagpole only
	@flagpoleParticles = @SpriteParticleData(flagImage, 0, 0, 3, -1);
}

/*
void _floodFillLight(int x, int y, const &in array<jjLAYER@> layersToConsider = null, const &in array<uint16> matchTileIDs = null) {
	if (layersToConsider is null) {
		layersToConsider = {jjLayers[4]};
	}
	int layerCount = layersToConsider.length();
	if (matchTileIDs is null) {
		matchTileIDs = {layersToConsider[0].tileGet(x, y)};
	}
	
	
}*/

/*============================================================================*\
*
* HOOKS
* 
\*============================================================================*/
void onLevelLoad() {
	MayLib::MayLibLevelConfig @config = MayLib::defaultMayLevelConfig();
	@config.minimapConfig.drawHook = @onDrawMinimap;
	MayLib::callOnLevelLoad(config);
	
	levelAnimSet = MayLib::nextCustomAnimSet();
	if (levelAnimSet == -1) {
		jjConsole("mayAstralWitchcraft.j2as: could not allocate anim set, script will now crash");
	}
	jjAnimSets[levelAnimSet].allocate(ANIMATION_FRAME_COUNTS);
	
	_setupMainMapping();
	_setupBackground();
	_setupGraffiti();
	_setupAmmoSourceGraphics();
	_setupWeapons();
	_setupFloatingDebris();
	_setupFloatingLightSources();
	_setupFoodMeterGraphics();
	_setupCTFBaseGraphics();
	_processTileAndEventMaps();
	
	jjLayerOrderSet(array<jjLAYER@>={jjLayers[3], jjLayers[4], jjLayers[5], jjLayers[6], jjLayers[7], jjLayers[8]});
	
	@modData = MayModUtils::MayModData("jj2edit_bardtale.mo3");
	modData.setNotePlayedHook(@onNotePlayed);
	
	if (SCREENSHOT_MODE) {
		jjANIMFRAME@ transparent = jjAnimFrames[jjAnimations[jjAnimSets[ANIM::FONT].firstAnim].firstFrame];
		jjPIXELMAP(1,1).save(transparent);
		// this isn't very nice but hey it's only for taking a screenshot
		for (uint anim = jjAnimSets[ANIM::FONT].firstAnim; anim < jjAnimSets[ANIM::FROG].firstAnim; anim++) {
			for (uint frame = jjAnimations[anim].firstFrame; frame < jjAnimations[anim].frameCount; frame++) {
				jjAnimFrames[frame] = transparent;
			}
		}
		for (uint anim = jjAnimSets[ANIM::PLUS_FONT].firstAnim; anim < jjAnimSets[ANIM::PLUS_MENUFONT].firstAnim; anim++) {
			for (uint frame = jjAnimations[anim].firstFrame; frame < jjAnimations[anim].frameCount; frame++) {
				jjAnimFrames[frame] = transparent;
			}
		}
	}
}

void onLevelBegin() {
	MayLib::callOnLevelBegin();
	//MayLib::warnMultiplayerOnly();
	
	jjWeapons[1].allowed = true;
	jjWeapons[1].allowedPowerup = true;
	jjWeapons[ZAPPER_WEAPON_SLOT].allowed = true;
	jjWeapons[ZAPPER_WEAPON_SLOT].allowedPowerup = true;
	jjWeapons[ZAPPER_WEAPON_SLOT].maximum = 100;
	jjWeapons[SPINNER_WEAPON_SLOT].allowed = true;
	jjWeapons[SPINNER_WEAPON_SLOT].allowedPowerup = true;
	jjWeapons[SPINNER_WEAPON_SLOT].maximum = 100;
	jjWeapons[BUTTSTOMP_WEAPON_SLOT].allowed = true;
	jjWeapons[BUTTSTOMP_WEAPON_SLOT].allowedPowerup = true;
	
	// This level has food objects, but does not let you get sugar rushes
	// from them.
	jjSugarRushAllowed = false;
	
	// adjust steady light positions a bit so that the light looks more
	// centered on their tiles
	for (int i = 1; i < jjObjectCount; i++) {
		jjOBJ@ obj = jjObjects[i];
		if (obj.isActive && obj.eventID == OBJECT::STEADYLIGHT) {
			obj.xPos += 8.0f;
			obj.yPos += 8.0f;
		}
	}
	
	addBackgroundParticles(count: 1024, lifetime: 0,
		speedXMin: -0.08f, speedXMax: -0.12f,
		speedYMin: 0.04f, speedYMax: 0.02f,
		parallaxMin: 0.08f, parallaxMax: 0.12f,
		color: 72,
		bidiColorLen: 6,
		bidiColorCutoff: 4,
		bidiColorSpeedMin: 0.06f, bidiColorSpeedMax: 0.3f);
}

void onMain() {
	MayLib::callOnMain();
	modData.updateOnMain();
	_updateAmmoSources();
	_updateBackground();
	_updateMainMapping();
	_updateGraffitiParticles();
	_updateCandleEmitters();
	
	// particle maintenance
	//MayUtils::profilerEnter("particle maintenance");
	for (int i = 0; i < MAX_PARTICLES; i++) {
		jjPARTICLE @particle = jjParticles[i];
		switch (particle.type) {
			case PARTICLE::ICETRAIL:
				// negate the effect of gravity on icetrail particles
				particle.ySpeed -= ICETRAIL_GRAVITY;
				particle.yPos -= ICETRAIL_GRAVITY;
				break;
			case PARTICLE::SPARK:
				particle.ySpeed -= SPARK_GRAVITY;
				particle.yPos -= SPARK_GRAVITY;
				break;
			/*case PARTICLE::STAR:
			{
				int timer = particle.star.colorChangeCounter;
				if (timer <= 1) {
					particle.isActive = false;
					particle.type = PARTICLE::INACTIVE;
				} else {
					particle.ySpeed -= STAR_GRAVITY;
					particle.yPos -= STAR_GRAVITY;
				
					float normalizedAge = 1.0f-float(timer)/float(particle.star.colorChangeInterval);
					if (normalizedAge > 0.5f) normalizedAge = 1.0f-normalizedAge;
					particle.star.size = max(2,int(normalizedAge*24));
				}
			}
				break;*/
		}
	}
	//MayUtils::profilerLeave("particle maintenance");
	//MayUtils::profilerDraw();
	
	if (SCREENSHOT_MODE && jjIsServer) {
		int interval = 770;
		int screenshotNum = jjGameTicks/interval;
		int screenshotsPerRow = jjLayers[4].width*TILE_SIZE/jjResolutionWidth+1;
		int cameraX = jjResolutionWidth*(screenshotNum%screenshotsPerRow);
		int cameraY = jjResolutionHeight*(screenshotNum/screenshotsPerRow);
		if (cameraY < jjLayers[4].height*TILE_SIZE) {
			jjLayers[7].xAutoSpeed = 0.0f;
			jjLayers[7].yAutoSpeed = 0.0f;
			jjLayers[7].xSpeed = 1.0f;
			jjLayers[7].ySpeed = 1.0f;
			jjLocalPlayers[0].cameraUnfreeze(true);
			jjLocalPlayers[0].cameraFreeze(cameraX, cameraY, false, true);
			if (jjGameTicks%interval == 50) jjTakeScreenshot("astralWitchcraft-"+screenshotNum);
		}
	}
}

int localPlayersReachedStompThreshold = 0;

void onPlayer(jjPLAYER@ p) {
	MayLib::callOnPlayer(p);
	for (int i = 1; i <= 5; i++) {
		p.powerup[i] = true;
	}
	p.fastfire = 6;
	
	// don't let mutators for older levels mess up the physics in this level
	jjPlayersStickToSlopes = true;
	
	// clamp to invisible ceiling if needed. The ceiling is higher towards
	// the center of the level, with a "smooth" transition to lower than the
	// battlements once you get close enough to them.
	if (p.xPos >= CEILING_START_X) {
		float distanceFromCenter = abs(p.xPos-jjLayerWidth[4]*TILE_SIZE*0.5f);
		float ceiling = min(0.0f, distanceFromCenter-CEILING_DOME_WIDTH*0.5f)+CEILING_Y;
		ceiling = max(ceiling, TILE_SIZE);
		if (p.yPos <= ceiling) p.yPos = ceiling;
	}
	
	if (p.food >= BUTTSTOMP_MIN_FOOD) {
		if (p.food > MAX_FOOD) p.food = MAX_FOOD;
		
		if (p.buttstomp == 1) {
			jjSample(p.xPos, p.yPos, NOVA_CHARGE_SOUND);
		// yes, this -1 is needed. A value of 40 is not actually reached
		// in onPlayer() for buttstomps that are very close to the
		// ground. In particular, this includes buttstomps buffered
		// out of jumps, a common technique.
		} else if (p.buttstomp == BUTTSTOMP_STARTUP_FRAMES-1) {
			p.ammo[BUTTSTOMP_WEAPON_SLOT] = p.food;
			p.fireBullet(BUTTSTOMP_WEAPON_SLOT, false, false, DIRECTION::RIGHT);
			p.ammo[BUTTSTOMP_WEAPON_SLOT] = 0;
			// food value is reset in the bullet behavior
		}
	}
	
	//_updateAndDrawBackgroundParticles(p);
	_updateAndDrawFastBackgroundStars(p);
}

// returns true if something was actually drawn
bool drawUnselectedAmmo(jjPLAYER@ player, jjCANVAS@ canvas, uint weapon, int x, int y, int frame) {
	if (player.currWeapon != weapon && (player.ammo[weapon] > 0 || jjWeapons[weapon].infinite)) {
		SPRITE::Mode mode = SPRITE::NORMAL;
		int param = 0;
		if (weapon == 1) {
			mode = SPRITE::TRANSLUCENTPLAYER;
			param = player.playerID;
		} else {
			mode = SPRITE::BRIGHTNESS;
			param = 80;

[preview ends here]