Downloads containing MinimalMayLib.asc

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

File preview

// by may 
// 0.1
//
// reduced version of MayLib for release with Astral Witchcraft. The following
// features are removed from this version:
// - all packet-related code
// - all player/server configuration
// - all in-game UI
const double MAX_UINT_DOUBLE = 4294967295.0; // useful for jjRandom
const double MAX_INT_DOUBLE = 2147483648.0;
const uint64 MAX_UINT64 = 0xFFFFFFFFFFFFFFFF;
const double MAX_UINT64_DOUBLE = 18446744073709551615.0; // useful for jjRNG
const float PI = 3.141592f;

// JJ2-specific constants
const int MAX_PARTICLES = 1024;
const int MAX_PLAYERS = 32;
const int MAX_WEAPON = 9; // maximum value for weapon slot (0-9), not the number of weapons
const int BUTTSTOMP_STARTUP_FRAMES = 40;

const int TILE_SIZE = 32;

const int GENERATOR_PARAM_BIT_COUNT = 17;

const int MCE_EVENT_ID = 255;

// first palette index of sprite colors other than white
const int SPRITE_GRADIENTS_START = 16;
// length of the gradients in the sprite colors, except the yellow one which we
// simply pretend nothing is wrong with
const int SPRITE_GRADIENT_LENGTH = 8;

const int SPRITE_COLORS_END = 96;

// info about some particles
const float FIRE_GRAVITY = 0.015625f;
const float ICETRAIL_GRAVITY = FIRE_GRAVITY*2;
const float PIXEL_GRAVITY = FIRE_GRAVITY*4;
const float STAR_GRAVITY = FIRE_GRAVITY;
const float SPARK_GRAVITY = FIRE_GRAVITY;
const float TILE_GRAVITY = FIRE_GRAVITY*4;

// Random number from 0 to n-1 inclusive.
uint randomUpTo(uint n) {
	return jjRandom() % n;
}

int min(int a, int b) {
	return a > b ? b : a;
}

int max(int a, int b) {
	return a > b ? a : b;
}

float min(float a, float b) {
	return a > b ? b : a;
}

float max(float a, float b) {
	return a > b ? a : b;
}

int getMaxAmmo(int weapon) {
	int m = jjWeapons[weapon].maximum;
	return m == -1 ? ((jjGameMode == GAME::SP || jjGameMode == GAME::COOP) ? 99 : 50) : m;
}

TEAM::Color indexToTeam(int i) {
	if (i == 0) return TEAM::BLUE;
	if (i == 1) return TEAM::RED;
	if (i == 2) return TEAM::GREEN;
	return TEAM::YELLOW;
}

// XXX: This is not very future-proof but I think it's the best that can be done
// right now? All the team-based game modes use CTF as their base mode but maybe
// they won't always so this checks them explicitly. Still useless if new modes
// get added though.
bool gameModeHasTeams() {
	return jjGameMode == GAME::CTF || jjGameCustom == GAME::DCTF ||
		jjGameCustom == GAME::DOM || jjGameCustom == GAME::FR ||
		jjGameCustom == GAME::JB || jjGameCustom == GAME::TB || jjGameCustom == GAME::TLRS;
}

// functions to rotate a segment of an array by one step, in-place.
// Multiple steps can be done efficiently too, with a different algorithm (that
// is less efficient for single steps), but I never want to rotate something
// multiple steps anyway, so I didn't bother with that functionality.
void rotateArraySegmentLeft_uint8(array<uint8> &arr, int start, int segmentLength) {
	int end = start+segmentLength-1; // inclusive end
	int tmp = arr[start];
	for (int i = start; i < end; i++) {
		arr[i] = arr[i+1];
	}
	arr[end] = tmp;
}
void rotateArraySegmentRight_uint8(array<uint8> &arr, int start, int segmentLength) {
	int end = start+segmentLength-1; // inclusive end
	int tmp = arr[end];
	for (int i = end; i > start; i--) {
		arr[i] = arr[i-1];
	}
	arr[start] = tmp;
}

void pointDistanceFromRectangle(int px, int py, int rx, int ry, int rw, int rh, int &out distX, int &out distY) {
	if (px < rx) {
		distX = px-rx;
	} else if (px >= rx+rw) {
		distX = px-(rx+rw)+1;
	} else {
		distX = 0;
	}
	if (px < ry) {
		distY = py-ry;
	} else if (py >= ry+rh) {
		distY = py-(ry+rh)+1;
	} else {
		distY = 0;
	}
}

void distanceFromCameraRect(jjPLAYER@ player, int x, int y, int &out distX, int &out distY) {
	// we don't care about subpixels here
	int cameraX = int(player.cameraX);
	int cameraY = int(player.cameraY);
	
	pointDistanceFromRectangle(x, y, cameraX, cameraY, jjSubscreenWidth-jjBorderWidth*2, jjSubscreenHeight-jjBorderHeight*2, distX, distY);
}

// Returns true if the passed point is within maxDistance pixels of any local
// player's camera view. With a maxDistance of 0, this tells you whether a pixel
// at that point can be seen by any local player.
//
// Using this to decide whether to call drawing functions is generally not
// productive, unless the function is drawResizedSprite/drawRotatedSprite or a
// friend of those.
bool pointIsCloseToCamera(int pointX, int pointY, int maxDistance = 0) {
	int minDist = maxDistance+1;
	for (int i = 0; i < jjLocalPlayerCount; i++) {
		int distX;
		int distY;
		distanceFromCameraRect(jjLocalPlayers[i], pointX, pointY, distX, distY);
		if (distX < minDist) minDist = distX;
		if (distY < minDist) minDist = distY;
	}
	return minDist <= maxDistance;
}

namespace MayLib {
	// Right now the public interface doesn't do anything, it just exists so
	// you can detect the presence of MayLib
	shared interface MayLibInterface : jjPUBLICINTERFACE {}
	class MayLibPublicInterface : MayLibInterface {
		string getVersion() const {
			return "0.0.1";
		}
	}
	MayLibPublicInterface interf;
	MayLibPublicInterface@ onGetPublicInterface() {
		return interf;
	}

	// The first animation in our custom anim set is a bundle of non-animated
	// images.
	const uint FRAME_OFFSET_GENERATOR_CLOCK_HAND = 0;
	const uint FRAME_OFFSET_MINIMAP = 1;
	const uint FRAME_OFFSET_MINIMAP_LARGE = 2;
	
	uint customAnimSet;
	uint firstCustomAnim;
	uint firstCustomAnimFrame;

	MayLibLevelConfig@ config;
	MayMinimap@ minimap;
	
	// for nicePowerups option.
	// 9 bits per player so doesn't quite fit into an int. could fit it in 8
	// if at least one weapon was always infinite, but scripts can give all
	// 9 weapons finite ammo.
	uint64 localPlayerNicePowerupsStateLastFrame = 0;

	// Angelscript bug: if a function is named the same thing as a funcdef
	// type, script loading will sometimes AV/segfault, apparently looking
	// for some member of a null struct pointer.
	// To prevent the user from accidentally triggering this bug, the
	// funcdef type has a ridiculous name.
	funcdef void oNdRaWmInImAp(MayMinimap@ minimap, jjCANVAS@ canvas, int x, int y, float scale);
	
	class MayMinimapConfig {
		// Whether the minimap should be generated and displayed at all.
		bool enable = true;
		
		// Number of tiles to exclude from the minimap on each edge.
		// Use this for levels that have large non-playable areas at the
		// edges, such as the bottom of maysolar.j2l.
		uint cropLeft = 0;
		uint cropRight = 0;
		uint cropTop = 0;
		uint cropBottom = 0;
		
		oNdRaWmInImAp@ drawHook;
	}
	
	class MayLibLevelConfig {
		MayMinimapConfig minimapConfig;

		// Any generator object with an "MCE Event" directly under it will be
		// moved [EventID]-16 pixels to the right and [Delay Secs]-16 pixels down.
		// Additionally, place an "MCE Event" either directly to the left of that
		// generator, or an unbroken chain of generators leading to one, to move the
		// generators to tile [EventID],[Delay Secs] of that other MCE Event.
		//
		// Eh, forget trying to describe it, here's an example:
		// BGGGGGGG
		//  AAAA AA
		//
		// G = generators
		// A = MCE Events with EventID 24 and Delay Secs 24
		// B = MCE Event with EventID 20 and Delay Secs 50
		//
		// All the generators except the fifth one from the left will be
		// repositioned to pixel position 648 (20*32+24-16), 1608 (50*32+24-16).
		// That one generator will stay where it is, because it doesn't have an
		// MCE Event directly under it.
		//
		// For levels with greater width or height than 256, you can put yet
		// another MCE Event to the left of the left MCE Event and that MCE Event
		// will add [EventID]*256 and [Delay Secs]*256 to the tile index used.
		//
		//
		//
		// Note that if the generator is moved to a new tile, that tile's
		// parameters will need to be changed to the generator's. Thus you can
		// only put multiple generators on one tile if they all have the same
		// parameters, and you will probably screw things up if you move a
		// generator to a tile that already has a different event on it. (If you
		// break either of these rules, the script will jjPrint a warning but
		// still move the generator anyway, regardless of the consequences.)
		//
		//
		//
		// As for the *purpose* of all this? Well, it was coded (but not released)
		// a bit prior to MLLE getting its own support for arbitrary event positions,
		// so it made it relatively easy to put regenerating pickups in funny places
		// in multiplayer, while being religiously paranoid about keeping things in
		// sync between players (no objects are created or destroyed).
		//
		// I quickly discovered an unexpected secondary use of it, which is perhaps
		// the primary use now: manipulating UIDs! Because the generator still gets
		// created in its original position, you can deal with any generator UID
		// collision by moving the events but offsetting them to their desired
		// position.
		bool generatorOffsets = false;
		
		// display remaining time on generators, analog clock style. Also removes the explosion
		// created by generators right before their spawned object is, as it becomes redundant.
		// this is quite lazy and doesn't do things like draw the correct fastfire sprite for Spaz
		//
		// there is at least one mutator already floating around that does a similar thing...
		bool showGeneratorTimers = false;
		
		// Sets players' fastfire to 6 while using weapons with infinite
		// ammo (usually just blaster) and 35 (the default) otherwise,
		// and sets non-POPCORN infinite weapons to CAPPED.
		// Do not put fastfire pickups in your level if you use this,
		// since they won't do anything!
		bool fastInfiniteWeapons = false;
		
		// Powerups are not lost when running out of ammo.
		// Specifically, if a player had both a powerup and ammo for a
		// weapon on one frame, and has no ammo or powerup for that
		// weapon on the next frame, and didn't die, the script will
		// give them the powerup back, assuming that they lost it by
		// using the last of their ammo.
		// A script that takes away someone's powerup will generally
		// still work with this option, unless you take away their ammo
		// at the same time, in which case this option will have a false
		// positive and give them the powerup back.
		bool nicePowerups = false;
	}
	
	enum MinimapStrictness {
		SHOW_ONLY_SELF,
		SHOW_ONLY_ALLIES,
		SHOW_ALL_PLAYERS
	}
	
	class MayMinimap {
		MinimapStrictness mapStrictness = SHOW_ONLY_ALLIES;
	
		uint width = 0;
		uint height = 0;
		
		uint8 emptyColor = 39; // very dark blue
		uint8 wallColor = 36; // less dark blue
		uint8 pitColor = 24; // red
		
		// colors for rabbit locations, these cycle through 8 palette
		// indices, with these variables being the start indices
		uint8 selfColor = 16; // green
		uint8 allyColor = 64; // beige
		uint8 enemyColor = 24; // red
		
		MayMinimapConfig @minimapConfig;
		uint animFrame;
		
		uint yOffset = 28;
		
		uint scale = 1;
		
		MayMinimap(MayMinimapConfig @newConfig, uint startAnimFrameToReplace) {
			@minimapConfig = newConfig;
			animFrame = startAnimFrameToReplace;
			jjLAYER@ layer = jjLayers[4];
			int newWidth = layer.width - minimapConfig.cropLeft - minimapConfig.cropRight;
			int newHeight = layer.height - minimapConfig.cropTop - minimapConfig.cropBottom;
			if (newWidth <= 0 || newHeight <= 0) {
				width = 0;
				height = 0;
				return;
			}
			width = newWidth;
			height = newHeight;
			
			jjPIXELMAP image(width, height);
			jjPIXELMAP bigImage(width*2, height*2);
			
			bool hasPits = levelHasPits();
			for (int y = minimapConfig.cropTop; y < layer.height-minimapConfig.cropBottom; y++) {
				for (int x = minimapConfig.cropLeft; x < layer.width-minimapConfig.cropRight; x++) {
					bool quadrant1 = layer.maskedPixel(x*32+8,y*32+8);
					bool quadrant2 = layer.maskedPixel(x*32+24,y*32+8);
					bool quadrant3 = layer.maskedPixel(x*32+8,y*32+24);
					bool quadrant4 = layer.maskedPixel(x*32+24,y*32+24);
					
					uint px = x-minimapConfig.cropLeft;
					uint py = y-minimapConfig.cropTop;
					uint8 curEmptyColor = emptyColor;
					if (hasPits && py == height-1) {
						emptyColor = pitColor;
					}
					bigImage[px*2,     py*2] = quadrant1 ? wallColor : emptyColor;
					bigImage[px*2+1,   py*2] = quadrant2 ? wallColor : emptyColor;
					bigImage[px*2,   py*2+1] = quadrant3 ? wallColor : emptyColor;
					bigImage[px*2+1, py*2+1] = quadrant4 ? wallColor : emptyColor;
					
					// XXX: this is overly pessimistic, e.g. a 32-pixel wide tunnel
					// centered on the edge between tiles will get rendered as a solid wall.
					image[x-minimapConfig.cropLeft, y-minimapConfig.cropTop] = (quadrant1 || quadrant2 || quadrant3 || quadrant4) ? wallColor : emptyColor;
				}
			}
			image.save(jjAnimFrames[startAnimFrameToReplace]);
			bigImage.save(jjAnimFrames[startAnimFrameToReplace+1]);
			jjAnimFrames[startAnimFrameToReplace].hotSpotX = 0;
			jjAnimFrames[startAnimFrameToReplace].hotSpotY = 0;
			jjAnimFrames[startAnimFrameToReplace+1].hotSpotX = 0;
			jjAnimFrames[startAnimFrameToReplace+1].hotSpotY = 0;
		}
		
		int drawX(float xPos) {
			return jjResolutionWidth-width*scale+(int(xPos*scale) >> 5);
		}
		
		int drawY(float yPos) {
			return (int(yPos*scale) >> 5) + yOffset;
		}
		
		void draw(jjCANVAS@ canvas) {
			if (scale > 0) {
				canvas.drawSpriteFromCurFrame(jjResolutionWidth-width*scale, yOffset, animFrame+scale-1, 0, SPRITE::TRANSLUCENT);
				
				// If splitscreeners are on different teams, and we're meant to show allies but not
				// enemies, allies won't be drawn, since it would mean one or more splitscreeners
				// could see the position of their enemies!
				bool drawAllies = gameModeHasTeams() && mapStrictness >= SHOW_ONLY_ALLIES;
				if (drawAllies && mapStrictness < SHOW_ALL_PLAYERS) {
					int splitscreenerTeam = jjLocalPlayers[0].team;
					for (int i = 1; i < jjLocalPlayerCount; i++) {
						if (jjLocalPlayers[i].team != splitscreenerTeam) drawAllies = false;
					}
				}
				
				for (uint i = 0; i < MAX_PLAYERS; i++) {
					if (jjPlayers[i].isInGame) {
						uint8 color;
						if (jjPlayers[i].isLocal) {
							color = selfColor;
						} else if (drawAllies && jjPlayers[i].team == jjLocalPlayers[0].team) {
							color = allyColor;
						} else if (mapStrictness >= SHOW_ALL_PLAYERS) {
							color = enemyColor;
						} else { // don't draw this player
							continue;
						}
						
						int x = drawX(jjPlayers[i].xPos);
						int y = drawY(jjPlayers[i].yPos);
						canvas.drawPixel(x, y, color+((jjGameTicks >> 2) & 3));
						
						// Flag holders and Eva's Ring holders twinkle and show health
						if (jjPlayers[i].flag != 0 || jjPlayers[i] is jjTokenOwner) {
							if ((jjGameTicks >> 3) & 1 == 1) {
								canvas.drawPixel(x-1, y, color);
								canvas.drawPixel(x+1, y, color);
								canvas.drawPixel(x, y-1, color);
								canvas.drawPixel(x, y+1, color);
							}
							
							int health = jjPlayers[i].health;
							for (int h = 0; h < health; h++) {
								canvas.drawPixel(x-health+h*2+1, y-3, 49);
							}
						}
					}
				}
				
				/**** begin crap added at the last minute for Astral Witchcraft ****/
				for (int i = 1; i < jjObjectCount; i++) {
					jjOBJ @obj = jjObjects[i];
					if (obj.isActive) {
						// draw CTF bases as Xes of the team's color
						if (obj.eventID == OBJECT::CTFBASE) {
							int x = drawX(obj.xOrg);
							int y = drawY(obj.yOrg);
							uint8 color;
							switch (obj.var[1]) {
								case 0:
									color = 34; // blue base
									break;
								case 1:
									color = 25; // red base
									break;
								case 2:
									color = 18; // green base
									break;
								case 3:
								default:
									color = 40; // yellow base
									break;
								}
								canvas.drawPixel(x-1, y-1, color);
								canvas.drawPixel(x+1, y-1, color);
								canvas.drawPixel(x, y, color);
								canvas.drawPixel(x-1, y+1, color);
								canvas.drawPixel(x+1, y+1, color);
						} else if (obj.eventID == OBJECT::GENERATOR) {
							int eventIDToGenerate = obj.var[3];
							int x = drawX(obj.xPos);
							int y = drawY(obj.yPos);
							switch (eventIDToGenerate) {
									// draw carrot generators as diagonal orange lines
									case OBJECT::CARROT:
									case OBJECT::FULLENERGY:
										canvas.drawPixel(x, y, 42);
										canvas.drawPixel(x-1, y+1, 42);
										break;
									default:
										break;
							}
						}
					}
				}
				if (minimapConfig.drawHook !is null) minimapConfig.drawHook(this, canvas, jjResolutionWidth-width*scale, yOffset, scale);
				
				/**** end of crap added at the last minute for Astral Witchcraft ****/
			}
		}
		
		void processToggleCommand() {
			scale = (scale+1)%3;
		}
	}
	
	// My "new" levels use a consistent configuration.
	MayLibLevelConfig@ defaultMayLevelConfig() {
		MayLibLevelConfig rval();
		rval.generatorOffsets = true;
		rval.showGeneratorTimers = true;
		rval.fastInfiniteWeapons = true;
		rval.nicePowerups = true;
		return @rval;
	}

	// Kill the explosion objects created by generators
	class MayLibExplosion : jjBEHAVIORINTERFACE {
		void onBehave(jjOBJ@ obj) {
			if (obj.lightType == LIGHT::POINT && obj.curAnim == int(jjAnimSets[ANIM::PICKUPS].firstAnim + 86)) {
				obj.delete();
			} else {
				obj.behave(BEHAVIOR::EXPLOSION);
			}
		}
	}
	
	void warnMultiplayerOnly() {
		if (jjObjectMax == 2048) {
			jjAlert("|This level can only be played in an online server.", false, STRING::MEDIUM);
			jjLayers[4].hasTiles = false;
			jjLayerOrderSet({jjLayers[4]});
		}
	}
	
	bool _configCheck() {
		if (config is null) {
			jjPrint("WARNING: MayLib function called without first calling callOnLevelLoad(), ignoring");
			return false;
		}
		return true;
	}
	
	bool levelHasPits() {
		// HACK: jjEventGet() does not work for the bottom row of events (always returns 0).
		// Use an undocumented feature of jjParameterGet() instead: if called with an offset of
		// -12 and length of 32 or -32, it returns the full event bitfield for that tile, the
		// lower 8 bits of which are the event ID.
		// (Calling with an offset of -12 and length of 8 won't work, that gives you 0 for the
		// bottom row like jjEventGet() does.)
		return (jjParameterGet(jjLayers[4].width-1,jjLayers[4].height-1,-12,32) & 255) == 255;
	}

	void _repositionGenerator(jjOBJ@ generator, float x, float y, bool ignoreWarning) {
		int xTile = int(x)/32;
		int yTile = int(y)/32;
		int startXTile = int(generator.xPos)/32;
		int startYTile = int(generator.yPos)/32;
		// if the generator is moving to a different tile, the params
		// need to be copied to that tile
		if (xTile != startXTile || yTile != startYTile) {
			// if it looks like copying the params might screw
			// something up, warn about it!
			int eventAtDestination = jjEventGet(xTile, yTile);
			int params = jjParameterGet(startXTile, startYTile, 0, GENERATOR_PARAM_BIT_COUNT);
			int paramsAtDestination = jjParameterGet(xTile, yTile, 0, GENERATOR_PARAM_BIT_COUNT);
			if (!ignoreWarning && ((eventAtDestination != 0 || paramsAtDestination != 0) && paramsAtDestination != params)) {
				jjPrint("WARNING: MayLib: Destination tile ("+xTile+","+yTile+") for generator has conflicting parameters. Either there is an event on this tile other than the generator, a non-identical generator was already moved to this tile, or the j2l is dirty.");
			}
			jjParameterSet(xTile, yTile, 0, GENERATOR_PARAM_BIT_COUNT, params);
		}
		generator.xPos = x;
		generator.yPos = y;
	}
	
	void _applyGeneratorOffsets() {
		for (int i = 1; i < jjObjectCount; i++) {
			jjOBJ@ obj = jjObjects[i];
			if (obj.isActive && obj.eventID == OBJECT::GENERATOR) {
				int xTile = int(obj.xPos)/32;
				int yTile = int(obj.yPos)/32;
				if (yTile < jjLayers[4].height-1) {
					int eventBelow = jjEventGet(xTile, yTile+1);
					if (eventBelow == MCE_EVENT_ID) {
						int xOffset = jjParameterGet(xTile, yTile+1, 0, 8); // "EventID" parameter of standard MCE Event
						int yOffset = jjParameterGet(xTile, yTile+1, 8, 8); // "Delay Secs" parameter of standard MCE Event
						
						int leftTile = xTile-1;
						while (leftTile >= 0) {
							int eventLeft = jjEventGet(leftTile, yTile);
							if (eventLeft == MCE_EVENT_ID) {
								int destTileX = jjParameterGet(leftTile, yTile, 0, 8); // "EventID" parameter of standard MCE Event
								int destTileY = jjParameterGet(leftTile, yTile, 8, 8); // "Delay Secs" parameter of standard MCE Event
								if (leftTile > 0 && jjEventGet(leftTile-1,yTile) == MCE_EVENT_ID) {
									destTileX += 256*jjParameterGet(leftTile-1, yTile, 0, 8); // "EventID" parameter of standard MCE Event
									destTileY += 256*jjParameterGet(leftTile-1, yTile, 8, 8); // "Delay Secs" parameter of standard MCE Event
								}
								xOffset += destTileX*32 - int(obj.xPos);
								yOffset += destTileY*32 - int(obj.yPos);
								break;
							} else if (eventLeft != OBJECT::GENERATOR) {
								// No generator chain, no MCE Event specifying tile.
								break;
							}
							leftTile--;
						}
						
						int destTileX = int(obj.xPos+xOffset)/32;
						int destTileY = int(obj.yPos+yOffset)/32;
						// don't warn if the generator is being moved on top of its own MCE Event
						_repositionGenerator(obj, obj.xPos+xOffset, obj.yPos+yOffset, destTileX == xTile && destTileY == yTile+1);
					}
				}
			}
		}
	}
	
	int nextCustomAnimSet() {
		for (int i = 0; i < 256; i++) {
			if (jjAnimSets[ANIM::CUSTOM[i]].firstAnim == 0) {
				return ANIM::CUSTOM[i];
			}
		}
		return -1;
	}
	
	void callOnLevelLoad(MayLibLevelConfig@ configuration) {
		if (!(config is null)) {
			jjPrint("WARNING: MayLib already initialized, ignoring callOnLevelLoad call.");
			return;
		}
		
		@config = configuration;
	
		// Anims are always allocated even for disabled features
		customAnimSet = nextCustomAnimSet();
		array<uint> animAllocs = {3};
		jjAnimSets[customAnimSet].allocate(animAllocs);
		firstCustomAnim = jjAnimSets[customAnimSet].firstAnim;
		firstCustomAnimFrame = jjAnimations[firstCustomAnim].firstFrame;
		
		if (config.showGeneratorTimers) {
			jjObjectPresets[OBJECT::EXPLOSION].behavior = MayLibExplosion();
		}
		
		jjPIXELMAP generatorClockHand(2,16);
		for (int y = 0; y < 16; y++) {
			generatorClockHand[0,y] = 40+y/2;
			generatorClockHand[1,y] = 40+y/2;
		}
		generatorClockHand.save(jjAnimFrames[firstCustomAnimFrame+FRAME_OFFSET_GENERATOR_CLOCK_HAND]);
		jjAnimFrames[firstCustomAnimFrame+FRAME_OFFSET_GENERATOR_CLOCK_HAND].hotSpotX = -1;
		
		if (config.minimapConfig.enable) {
			@minimap = @MayMinimap(config.minimapConfig, firstCustomAnimFrame+FRAME_OFFSET_MINIMAP);
		}
		
		if (config.fastInfiniteWeapons) {
			for (int i = 0; i < 9; i++) {
				if (jjWeapons[i].infinite && jjWeapons[i].style != WEAPON::POPCORN) {
					jjWeapons[i].style == WEAPON::CAPPED;
				}
			}
		}
	}
	
	bool onLevelBeginCalled;
	void callOnLevelBegin() {
		if (!_configCheck()) return;
		if (onLevelBeginCalled) {
			jjPrint("WARNING: MayLib callOnLevelBegin already called, ignoring.");
			return;
		}
		onLevelBeginCalled = true;
		
		if (config.generatorOffsets) {
			_applyGeneratorOffsets();
		}
	}
	
	void callOnMain() {
		if (!_configCheck()) return;	
		
		if (config.showGeneratorTimers) {
			for (int i = 1; i < jjObjectCount; i++) {
				jjOBJ@ obj = jjObjects[i];
				// generator var[2] is delay, var[1] counts down to 0 from that delay
				// var[0] is the object ID the generator last generated, var[3] is its event ID
				// however, clients do not count down var[1], instead the server tells them when to
				// spawn the object. So for clients the indicator is just an estimate, it can't be
				// perfectly accurate unless latency is perfectly constant (although the estimate
				// could certainly be improved by adding some sendPacket()s).
				
				// no display for generators with delay 1
				// the bank robbery levels have their own vfx for coin regeneration
				if (obj.isActive && obj.eventID == OBJECT::GENERATOR && obj.var[2] > 80 && obj.var[3] >= 0 && obj.var[3] < 256) {
					bool generating = false;
					if (jjIsServer) {
						generating = obj.var[1] != obj.var[2];
					} else {
						// var[0] could get set to a bad value by a script, don't crash in that case
						generating = obj.var[0] > 0 && obj.var[0] < jjObjectMax &&
						// objects created by a generator get a creator value of CREATOR::LEVEL+[generator object id]
						(!jjObjects[obj.var[0]].isActive || jjObjects[obj.var[0]].creator != CREATOR::LEVEL + i);
					}
					
					if (generating) {
						if (!jjIsServer) {
							obj.var[10] = min(obj.var[10]-1, obj.var[2]);
						}
						float time = 1.0f - float(jjIsServer ? obj.var[1] : obj.var[10]) / float(obj.var[2]);
						
						float x = obj.xPos;
						float y = obj.yPos;
						
						jjOBJ@ preset = jjObjectPresets[obj.var[3]];
						const jjANIMATION@ animation = jjAnimations[preset.curAnim];
						uint curFrame = (animation.frameCount == 0) ? 0 :
							(animation.firstFrame + ((obj.objectID*3 + jjRenderFrame >> 2) % animation.frameCount));
						
						if (time < 0.0f) {
							// We're a client and the object *should* be back by now, but it isn't. Could
							// just be lag, or could be a broken generator, we don't really know.
							jjDrawSpriteFromCurFrame(x, y, curFrame, 0, SPRITE::TRANSLUCENTSINGLEHUE, 24);
						} else {
							jjDrawRotatedSprite(x+1.0, y, customAnimSet, 0, 0, (int(time*1023.0)+512)%1024);
							jjDrawSpriteFromCurFrame(x, y, curFrame, 0, SPRITE::TRANSLUCENT);
						}
					} else {
						obj.var[10] = obj.var[2];
					}
				}
			}
		}
	}
	
	void callOnPlayer(jjPLAYER@ p) {
		if (!_configCheck()) return;
		
		if (config.fastInfiniteWeapons) {
			p.fastfire = jjWeapons[p.currWeapon].infinite ? 6 : 35;
		}
		
		if (config.nicePowerups) {
			uint64 playerOffset = uint64(1) << (p.localPlayerID*9);
			for (int gun = 1; gun < 10; gun++) {
				uint64 gunBit = playerOffset << (gun-1);
				bool hasAmmo = jjWeapons[gun].infinite || p.ammo[gun] > 0;
				bool hasPowerup = p.powerup[gun];
				bool hadPowerupAndAmmoLastFrame = (localPlayerNicePowerupsStateLastFrame & gunBit != 0);
				if (p.health <= 0 || (hasAmmo && !hasPowerup)) {
					// death or script took powerup away, probably. leave it gone.
					localPlayerNicePowerupsStateLastFrame &= ~gunBit;
				} else {
					if (hadPowerupAndAmmoLastFrame && !hasAmmo && !hasPowerup) {
						// running out of ammo took powerup away, probably. bring it back.
						p.powerup[gun] = true;
					}
					if (hasAmmo && hasPowerup) {
						localPlayerNicePowerupsStateLastFrame |= gunBit;
					}
				}
			}
		}
	}
	
	bool callOnDrawGameModeHUD(jjPLAYER@ player, jjCANVAS@ canvas) {
		// player can be null if spectating
		if (player is null || player.localPlayerID == 0) {
			if (minimap !is null) {
				minimap.draw(canvas);
			}
		}
		return false;
	}
}