Downloads containing guide.mut

Downloads
Name Author Game Mode Rating
JJ2+ Only: Level GuideFeatured Download Stijn Mutator 10 Download file

File preview

//level guide mutator for jazz jackrabbit 2 + (https://jj2.plus)
//by stijn, 2017-2018 (https://www.jazz2online.com)
#pragma name "Level Guide"

//events that may be shown on minimap
array<int> interesting_events = {
  OBJECT::BLASTERPOWERUP, OBJECT::BOUNCERPOWERUP, OBJECT::ICEPOWERUP, OBJECT::SEEKERPOWERUP, OBJECT::RFPOWERUP, OBJECT::TOASTERPOWERUP, OBJECT::GUN8POWERUP, OBJECT::GUN9POWERUP,
  OBJECT::FIRESHIELD,  OBJECT::WATERSHIELD, OBJECT::PLASMASHIELD, OBJECT::LASERSHIELD,
  OBJECT::CARROT, OBJECT::FULLENERGY,
  OBJECT::SILVERCOIN, OBJECT::GOLDCOIN,
  OBJECT::ICEAMMO3, OBJECT::ICEAMMO15, AREA::MPSTART,
  OBJECT::CTFBASE,
  OBJECT::EOLPOST, AREA::WARPEOL, AREA::EOL, AREA::WARP, AREA::PATH
};

//arrays for convenience
array<int> power_ups = {OBJECT::BLASTERPOWERUP, OBJECT::BOUNCERPOWERUP, OBJECT::ICEPOWERUP, OBJECT::SEEKERPOWERUP, OBJECT::RFPOWERUP, OBJECT::TOASTERPOWERUP, OBJECT::GUN8POWERUP, OBJECT::GUN9POWERUP};
array<int> shields = {OBJECT::FIRESHIELD,  OBJECT::WATERSHIELD, OBJECT::PLASMASHIELD, OBJECT::LASERSHIELD};

//objects that drop down to ground (i.e. monitors)
const array<uint> affected_by_gravity = {
	OBJECT::BLASTERPOWERUP, OBJECT::BOUNCERPOWERUP, OBJECT::ICEPOWERUP, OBJECT::SEEKERPOWERUP, OBJECT::RFPOWERUP, OBJECT::TOASTERPOWERUP, OBJECT::GUN8POWERUP, OBJECT::GUN9POWERUP,
  OBJECT::FIRESHIELD,  OBJECT::WATERSHIELD, OBJECT::PLASMASHIELD, OBJECT::LASERSHIELD
};

//an interesting thing; e.g. a carrot, ctf base or powerup
class interesting_thing {
  int xPos;
  int yPos;
  int width = 32;
  int height = 32;
  int eventID;
  int objectID = -1;
  int difficulty = 0;
  int team = -1;
  string typetext = "";
}

//a collection of interesting things
class cluster {
  array<interesting_thing> things;
  int originX;
  int originY;
  int levelX;
  int levelY;
  int width;
  int height;
  bool hover = false;
}

//feel free to tweak these
int panel_scale = 20;       //size of icons on minimap
int panel_colour = 64;      //base colour for map item panels
int panel_hover = 42;       //hover overlay colour for map items
int ui_colour = 72;         //base colour for most UI elements
int letterbox_huehue = 200; //transparency of letterbox in map view

//don't edit these
int pixels_per_tick = 750;  //minimap pixels to render per gametick in background during precalc - auto-adjusted later
int precalc_done = 0;       //0 = pre-init, 1 = init complete, rendering minimap, 2 = post-render setup, 3 = done
int precalc_x = 0;
int precalc_y = 0;
bool precalc_active = false;
float levelX;
float levelY;
int precalc_wait = 10;

bool show_guide = false;
bool show_hint = false;
bool advertised = false;
int page = 0;
int max_page = 0;
int highlight = 0;
int letterbox;
int letterbox_hue = 255;
bool camera_frozen;
string game_mode = "";

jjPIXELMAP@ minimap;
array<cluster> hotspots;
int customAnimID = 0;
string mode_mutator = "";

int minimap_width;
int minimap_height;
int minimap_x_offset = 0;
int minimap_y_offset = 0;
float minimap_x_step = 0;
float minimap_y_step = 0;
int layer4_start_x = 1024;
int layer4_end_x = 0;
int layer4_start_y = 1024;
int layer4_end_y = 0;

const int KEY_F1 = 0x70;
const int KEY_F2 = 0x71;
const int KEY_F5 = 0x74;
const int key_threshold = 20;
int key_decay = 0;

//some math functions for convenience
int max(int one, int two) { return (one > two) ? one : two; }
int min(int one, int two) { return (one < two) ? one : two; }
uint sum(array<int> arr) { uint result = 0; for(uint i = 0; i < arr.length(); i += 1) { result += arr[i]; }; return result; }

/**
 * Handle key presses
 */
void onPlayerInput(jjPLAYER@ player) {
  if(jjLocalPlayerCount > 1) {
    if(jjKey[KEY_F1] && key_decay == 0) {
      jjAlert('The level guide is not available in splitscreen mode.');
      key_decay = 15;
    }
    return;
  }
  
  if(key_decay == 0) {
    if(jjKey[KEY_F1]) { //toggle guide
      if(!show_guide) {
        if(page > max_page) {
          page = max_page;
        }
      }
      show_guide = !show_guide;
      advertised = true;
      key_decay = key_threshold;
    } else if(jjKey[KEY_F2] && show_guide && page > 0) { //toggle arrows
      show_hint = !show_hint;
      key_decay = key_threshold;
    } else if(jjKey[KEY_F5] && show_guide) { //go to minimap
      page = 0;
    } else if(show_guide && player.keyLeft) { //next page
      if(page > 0) {
        page -= 1;
      } else {
        highlight = 35;
        page = max_page;
      }
      key_decay = key_threshold;
    } else if(show_guide && player.keyRight) { //previous page
      if(page < max_page) {
        page += 1;
      } else {
        highlight = 35;
        page = 0;
      }
      key_decay = key_threshold;
    }
  }
  
  //mouse clicks: on minimap, send to clicked hotspot
  if(show_guide && page == 0) {
    for(uint i = 0; i < hotspots.length(); i += 1) {
      cluster h = hotspots[i];
      hotspots[i].hover = false;
      if(jjMouseX >= h.originX && jjMouseX <= h.originX + h.width && jjMouseY >= h.originY && jjMouseY <= h.originY + h.height) {
        if(jjKey[1] && key_decay == 0) {
          page = i + 1;
          key_decay = key_threshold;
        } else {
          hotspots[i].hover = true;
        }
      }
    }
  //on other pages, mouse = return to minimap
  } else if(show_guide && jjKey[1] && page > 0 && key_decay == 0) {
    page = 0;
    key_decay = key_threshold;
  }
  
  //don't move player characters as long as guide is visible
  if(show_guide) {
    player.keyUp = player.keyDown = player.keyRight = player.keyLeft = player.keyFire = player.keyJump = false;
  }
}

/**
 * This is where the magic happens
 *
 * We're using onDrawAmmo because that way we can hide some potentially in-the-way HUD elements when showing the guide
 */
bool onDrawAmmo(jjPLAYER@ player, jjCANVAS@ screen) {
  letterbox = int(jjResolutionWidth / 12.5); //pretty arbitrary, needs to be recalculated because resolution may change (though the minimap won't...)
  bool panCamera = jjLocalPlayers[0].isSpectating;
  
  if(!show_guide) {
    if(camera_frozen) {
      camera_frozen = false;
      player.cameraUnfreeze(true);  
    }
    
    return false;
  }
  
  if(precalc_done < 3) {
    screen.drawRectangle(0, 0, jjResolutionWidth, jjResolutionHeight, ui_colour + 7);
    drawStringCentered(screen, jjResolutionHeight / 2, "Rendering minimap...", STRING::SMALL, STRING::NORMAL);
    if(precalc_wait > 0) {
      precalc_wait -= 1;
    } else {
      precalc_pixels();
    }
    return true;
  }

  camera_frozen = true;
  player.idle = 0;
  
  //level map
  if(page == 0) {
    if(jjColorDepth == 16 && letterbox_hue < letterbox_huehue) {
      letterbox_hue += 1;
    }
    player.cameraFreeze(true, true, true, false); 
    screen.drawRectangle(0, 0, jjResolutionWidth, jjResolutionHeight, ui_colour + 7, SPRITE::BLEND_NORMAL, letterbox_hue);
    drawStringCentered(screen, 18, jjLevelName, STRING::LARGE, STRING::BOUNCE);
    drawStringCentered(screen, letterbox + 5, "Level map", STRING::MEDIUM, STRING::BOUNCE);
    screen.drawSprite(letterbox + minimap_x_offset, letterbox + 25 + minimap_y_offset, ANIM::CUSTOM[customAnimID], 0, 0);
    
    //draw player character on map
    if(!jjLocalPlayers[0].isSpectating) {
      int playerX = int(((player.xPos - layer4_start_x * 32) / ((layer4_end_x - layer4_start_x) * 32)) * (minimap_width - minimap_x_offset - minimap_x_offset)) + letterbox + minimap_x_offset - 13;
      int playerY = int(((player.yPos - layer4_start_y * 32) / ((layer4_end_y - layer4_start_y) * 32)) * (minimap_height - minimap_y_offset - minimap_y_offset)) + letterbox + minimap_y_offset + 25 - 13;
      int anim;
      switch(player.charCurr) {
        case CHAR::JAZZ: anim = 3; break;
        case CHAR::SPAZ: anim = (jjIsTSF) ? 5 : 4; break;
        case CHAR::LORI: anim = 4; break;
        case CHAR::BIRD: anim = 0; break;
        case CHAR::FROG: anim = 2; break;
      }
      screen.drawRectangle(playerX - 4, playerY - 25, 1, 27, ui_colour + 4); //left
      screen.drawRectangle(playerX + 21, playerY - 25, 1, 27, ui_colour + 4); //right
      screen.drawRectangle(playerX - 3, playerY - 26, 24, 1, ui_colour + 4); //top
      screen.drawRectangle(playerX - 3, playerY + 2, 24, 1, ui_colour + 4); //bottom
      screen.drawRectangle(playerX- 3, playerY - 25, 24, 27, ui_colour + 3); //base
      screen.drawRectangle(playerX- 2, playerY - 24, 22, 25, ui_colour + 2); //base
      screen.drawRectangle(playerX- 1, playerY - 23, 20, 23, ui_colour + 1); //base
      screen.drawResizedSprite(playerX, playerY, ANIM::FACES, anim, 0, 0.5, 0.55);
      
      for(uint i = 0; i < hotspots.length(); i += 1) {
        if(hotspots[i].hover) {
          screen.drawRectangle(hotspots[i].originX, hotspots[i].originY, hotspots[i].width, hotspots[i].height, panel_hover, SPRITE::BLEND_OVERLAY, 255);
        }
      }
    }
    
  //level hotspots
  } else {
    if(letterbox_hue > 128 && jjColorDepth == 16) {
      letterbox_hue -= 1;
    }
    
    //draw letterbox
    screen.drawRectangle(0, letterbox + 25, letterbox, jjResolutionHeight - (letterbox * 2) - 25, ui_colour + 7, SPRITE::BLEND_NORMAL, letterbox_hue); //left
    screen.drawRectangle(jjResolutionWidth - letterbox, letterbox + 25, letterbox, jjResolutionHeight - (letterbox * 2) - 25, ui_colour + 7, SPRITE::BLEND_NORMAL, letterbox_hue); //right
    screen.drawRectangle(0, 0, jjResolutionWidth, letterbox + 25, ui_colour + 7, SPRITE::BLEND_NORMAL, letterbox_hue); //top
    screen.drawRectangle(0, jjResolutionHeight - letterbox, jjResolutionWidth, letterbox, ui_colour + 7, SPRITE::BLEND_NORMAL, letterbox_hue); //bottom
    
    drawStringCentered(screen, 18, jjLevelName, STRING::LARGE, STRING::BOUNCE);
    drawStringCentered(screen, letterbox + 5, "Locations of interest", STRING::MEDIUM, STRING::BOUNCE);
      
    array<interesting_thing> collection = hotspots[page - 1].things;
    int hotspotX = 0;
    int hotspotY = 0;
    int hotspot_minX = 1024 * 32;
    int hotspot_minY = 1024 * 32;
    int hotspot_maxX = 0;
    int hotspot_maxY = 0;
    
    //determine what the hotspot contains and what its centre of gravity is
    array<string> types = {};
    for(uint i = 0; i < collection.length(); i += 1) {
      hotspotX += int(collection[i].xPos);
      hotspotY += int(collection[i].yPos);      
      hotspot_minX = min(hotspot_minX, int(collection[i].xPos) - collection[i].width / 2);
      hotspot_minY = min(hotspot_minY, int(collection[i].yPos) - collection[i].height / 2);
      hotspot_maxX = max(hotspot_maxX, int(collection[i].xPos) + collection[i].width / 2);
      hotspot_maxY = max(hotspot_maxY, int(collection[i].yPos) + collection[i].height / 2);
      if(types.find(collection[i].typetext) < 0) { types.insertLast(collection[i].typetext); }
    }
    
    //arrows pointing at hotspot
    int margin = 32;
    if(show_hint) {
      screen.drawRotatedSprite(int(hotspot_minX - player.cameraX - margin), int(hotspot_minY - player.cameraY - margin) - 16, ANIM::FLAG, 0, 0, 850, 1.5, 1.5, SPRITE::SINGLEHUE, ui_colour);
      screen.drawRotatedSprite(int(hotspot_maxX - player.cameraX + margin), int(hotspot_minY - player.cameraY - margin) - 16, ANIM::FLAG, 0, 0, 570, 1.5, 1.5, SPRITE::SINGLEHUE, ui_colour);
      screen.drawRotatedSprite(int(hotspot_minX - player.cameraX - margin), int(hotspot_maxY - player.cameraY + margin) - 16, ANIM::FLAG, 0, 0, 50, 1.5, 1.5, SPRITE::SINGLEHUE, ui_colour);
      screen.drawRotatedSprite(int(hotspot_maxX - player.cameraX + margin), int(hotspot_maxY - player.cameraY + margin) - 16, ANIM::FLAG, 0, 0, 320, 1.5, 1.5, SPRITE::SINGLEHUE, ui_colour);
      //screen.drawRectangle(hotspot_minX - jjPlayers[0].cameraX, hotspot_minY - jjPlayers[0].cameraY, hotspot_maxX - hotspot_minX, hotspot_maxY - hotspot_minY, 16);
    }
    
    //pan camera to hotspot - panning is best because it gives a better sense of where in the level the hotspot exactly is
    hotspotX /= collection.length();
    hotspotY /= collection.length();
    player.cameraFreeze(hotspotX, hotspotY, true, panCamera);
    
    //draw box with text describing what can be found at the hotspot
    string type_string = join(types, " / ");
    int type_width = jjGetStringWidth(type_string, STRING::MEDIUM, STRING::NORMAL);
    int rectWidth = type_width + 40;
    int rectHeight = 32;
    int rectX = (jjResolutionWidth / 2) - (type_width / 2) - 20;
    int rectY = jjResolutionHeight - letterbox - rectHeight - 20;
      
    screen.drawRectangle(rectX, rectY, rectWidth, rectHeight, ui_colour + 7, SPRITE::TRANSLUCENT);
    int colour = ui_colour + 6;
    for(int i = 0; i < 5; i += 1) {
      if(i < 3) {
        colour -= 1;
      } else {
        colour += 1;
      }
      screen.drawRectangle(rectX - i, rectY - i, rectWidth + (2 * i), 1, colour); //top
      screen.drawRectangle(rectX - i, rectY + rectHeight + i, rectWidth + (2 * i), 1, colour); //bottom
      screen.drawRectangle(rectX - i, rectY - i, 1, rectHeight + (2 * i), colour); //left
      screen.drawRectangle(rectX + rectWidth + i, rectY - i, 1, rectHeight + 1 + (2 * i), colour); //right
        
    }
    drawStringCentered(screen, rectY + 15, type_string, STRING::MEDIUM, STRING::NORMAL);
  }
  
  //other hud texts
  screen.drawString(jjResolutionWidth - letterbox - 90, jjResolutionHeight - letterbox + 16, "Page " + (page + 1) + "/" + (max_page + 1), STRING::SMALL, STRING::NORMAL);
  if(highlight > 0) {
    drawStringCentered(screen, letterbox + 25 + 16, "|||||||Press |F1||||||| to close the level guide", STRING::SMALL, STRING::NORMAL);
    highlight -= 1;
  } else {
    drawStringCentered(screen, letterbox + 25 + 16, "Press |F1||||| to close the level guide", STRING::SMALL, STRING::NORMAL);
  }
  if(page > 0) {
    drawStringCentered(screen, letterbox + 25 + 32, "Press |F2||||| for location hints, or |||click||||| to return to the map", STRING::SMALL, STRING::NORMAL);
  } else {
    drawStringCentered(screen, letterbox + 25 + 32, "|Click||||| any icon to inspect location", STRING::SMALL, STRING::NORMAL);
  }

  //little mouse cursor
  screen.drawRectangle(jjMouseX, jjMouseY, 2, 2, 64);
  return false;
}

/**
 * Hide score while guide is visible
 *
 * This only really works in SP and Coop, but anyway, better than nothing
 */
bool onDrawScore(jjPLAYER@ player, jjCANVAS@ canvas) { 
  return show_guide;
}

/**
 * Hide health while guide is visible
 */
bool onDrawHealth(jjPLAYER@ player, jjCANVAS@ canvas) { 
  return show_guide;
}

/**
 * Detect mutators
 *
 * Right now only looks for compatible Bank Robbery mutators, but could be extended to look
 * for other custom modes as well.
 */
void onLevelLoad() {
  jjPUBLICINTERFACE@ bankmut = jjGetPublicInterface("bank.mut");
  jjPUBLICINTERFACE@ boxlessmut = jjGetPublicInterface("boxlessbr.mut");
  jjPUBLICINTERFACE@ onslaughtmut = jjGetPublicInterface("onslaught.mut");
  if(bankmut !is null || boxlessmut !is null) { //bank robbery mutator is present
    mode_mutator = "br";
  }
  if(onslaughtmut !is null) { //bank robbery mutator is present
    mode_mutator = "ons";
  }
}

/**
 * Main hook
 *
 * Does very little; most stuff happens in onDrawAmmo. This adjsuts some decaying variables,
 * manages precalc and advertises the level guide when everything's loaded.
 */
void onMain() {
  if(key_decay > 0) {
    key_decay -= 1;
  }
  
  //this tries to balance the load of rendering the minimap in the background in a very rudimentary way
  if(jjGameTicks % 35 == 1 && jjFPS > 0) {
    pixels_per_tick = jjFPS * 10;
  }
  
  if(precalc_done == 1) {
    precalc_pixels(pixels_per_tick);
  }
  
  if(jjColorDepth == 8) {
    letterbox_hue = 255; //huehue
  }
  
  //re-render minimap if game mode changes - since different objects may now be of interest
  string current_mode = jjGameCustom + "" + jjGameMode;
  if(current_mode != game_mode) {
    game_mode = current_mode;
    find_hotspots();
    precalc_done = 0;
  }
  
  if(precalc_done == 0) {
    precalc_init();
  }
	
	//announce availability of guide when level begins
  if(precalc_done == 3 && !advertised && jjLocalPlayerCount == 1) {
    advertised = true;
    jjAlert("A level guide is available. Press ||F1|||||| to view.");
  }
}

/**
 * Draw string centered on canvas
 */
void drawStringCentered(jjCANVAS@ screen, int yPos, string text, STRING::Size size, STRING::Mode style) {
  int width = jjGetStringWidth(join(text.split("|"), ""), size, style); //ghetto string replace
  screen.drawString((jjResolutionWidth / 2) - (width / 2), yPos, text, size, style);
}

/**
 * Pre-render minimap
 *
 * Rendering the minimap each frame is far too expensive, so sacrifice about a second of gameplay
 * to render it on the first run of the level guide. Also does some other misc setup such as
 * allocating space for sprites and determining letterbox size.
 */
void precalc_init() {
  if(precalc_done > 0) {
    return;
  }
  
  precalc_x = 0;
  precalc_y = 0;
  advertised = false;
  letterbox = int(jjResolutionWidth / 12.5); //pretty arbitrary
  letterbox_hue = (jjColorDepth == 8) ? 255 : letterbox_huehue;

  //allocate space to save the minimap etc as sprites
  while (jjAnimSets[ANIM::CUSTOM[customAnimID]].firstAnim != 0) ++customAnimID;
  array<uint> customFrames = {3};
  jjAnimSets[ANIM::CUSTOM[customAnimID]].allocate(customFrames);
    
  //calculate drawable area for minimap - ignore empty space surrounding the level
  minimap_width = jjResolutionWidth - (letterbox * 2);
  minimap_height = jjResolutionHeight - (letterbox * 2) - 25;
  for(int y = 0; y < jjLayers[4].height; y += 1) {
    for(int x = 0; x < jjLayers[4].width; x += 1) {
      if(jjLayers[4].tileGet(x, y) != 0) {
        layer4_start_x = min(layer4_start_x, x);
        layer4_start_y = min(layer4_start_y, y);
        layer4_end_x = max(layer4_end_x, x);
        layer4_end_y = max(layer4_end_y, y);
      }
    }
  }
  
  //scale full level to minimap size
  int width = layer4_end_x - layer4_start_x + 1;
  int height = layer4_end_y - layer4_start_y + 1;
  float aspect = float(width) / float(height);
  float minimap_aspect = float(minimap_width) / float(minimap_height);
  int half_width = int(minimap_width / 2);
  if(aspect < minimap_aspect) {
    minimap_y_offset = 0;
    minimap_x_offset = int((minimap_width - (minimap_height * aspect)) / 2);
  } else {
    minimap_x_offset = 0;
    minimap_y_offset = int((minimap_height - (minimap_height * (minimap_aspect / aspect))) / 2);
  }
  
  minimap_x_step = float(width * 32) / float(minimap_width - (minimap_x_offset * 2.0));
  minimap_y_step = float(height * 32) / float(minimap_height - (minimap_y_offset * 2.0));
    
  //render minimap
  @minimap = jjPIXELMAP(uint(minimap_width - (minimap_x_offset * 2.0)), uint(minimap_height - (minimap_y_offset * 2.0)));
  levelX = layer4_start_x * 32;
  levelY = layer4_start_y * 32;
  
  precalc_done = 1;
}

/**
 * Render minimap
 *
 * Takes a number of pixels as an argument; only so many pixels will be rendered. This
 * allows JJ2 to render the minimap in the background while gameplay continues, instead
 * of the game hanging while rendering. Omitting the argument renders the full minimap
 * (or what is left to be rendered)
 */
void precalc_pixels(int pixels = 0) {
  if(precalc_active || precalc_done != 1) {
    return;
  }
  
  precalc_done = 1;
  precalc_active = true;
  int pixels_done = 0;
  if(pixels == 0) {
    pixels = minimap.height * minimap.width + 1;
  }
  
  for(; precalc_y < int(minimap.height); precalc_y += 1) {
    for(; precalc_x < int(minimap.width); precalc_x += 1) {
      pixels_done += 1;
      int tileX = int(levelX / 32);
      int tileY = int(levelY / 32);
      for(int l = 7; l > 0; l -= 1) {
        if(jjLayers[l].xSpeed == 1 && jjLayers[l].ySpeed == 1) {
          if((tileX > jjLayers[l].width && !jjLayers[l].tileWidth) || tileY > jjLayers[l].height && !jjLayers[l].tileHeight) {
            continue;
          }
          int tileID = jjLayers[l].tileGet(tileX % jjLayers[l].width, tileY % jjLayers[l].height);
          if(tileID > 0) {
            jjPIXELMAP tile(tileID);
            int color = tile[int(levelX % 32), int(levelY % 32)];
            if(color > 0) {
              minimap[precalc_x, precalc_y] = color;
            }
          }
        }
      }
      levelX += minimap_x_step;
      if(pixels_done >= pixels) {
        precalc_x += 1;
        precalc_active = false;
        return;
      }
    }
    levelX = layer4_start_x * 32;
    levelY += minimap_y_step;
    precalc_x = 0;
  }
  
  precalc_done = 2;
  finish_precalc();
  precalc_active = false;  
}

/**
 * Finish precalc
 *
 * Renders item icons to the minimap and sets up some misc stuff
 */
void finish_precalc() {  
  //show location of important items on the minimap
  find_hotspots();
  
  //icons for each category and corresponding sprites
  array<int> sprites = {ANIM::FLAG, ANIM::FLAG, ANIM::PICKUPS, ANIM::PLUS_COMMON, ANIM::PICKUPS, ANIM::AMMO, ANIM::PICKUPS, ANIM::PICKUPS, //base/control point, health, unused, coin, ice ammo, level exit, jail
                        ANIM::PICKUPS, ANIM::PICKUPS, ANIM::PICKUPS, ANIM::PICKUPS, ANIM::PICKUPS, ANIM::PICKUPS, ANIM::PICKUPS, ANIM::PICKUPS, //powerups
                        ANIM::PICKUPS, ANIM::PICKUPS, ANIM::PICKUPS, ANIM::PLUS_COMMON}; //shields
  array<int> anims =   {4, 8, 21, 2, 37, 28, 28, 52,
                        60, 61, 62, 63, 64, 65, 66, 67,
                        31, 10, 51, 2};
  if(jjLocalPlayers[0].charCurr == CHAR::SPAZ) { anims[8] = 83; } //spaz blaster monitor
  if(jjLocalPlayers[0].charCurr == CHAR::LORI) { sprites[8] = ANIM::PLUS_COMMON; anims[8] = 5; } //lori blaster monitor
  
  for(uint i = 0; i < hotspots.length(); i += 1) {
    array<int> has_category = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
    
    //first determine where on the minimap the hotspot is and what kind of items it contains
    int hotspot_x = 0;
    int hotspot_y = 0;
    for(uint j = 0; j < hotspots[i].things.length(); j += 1) {
      interesting_thing t = hotspots[i].things[j];
      hotspot_x += t.xPos;
      hotspot_y += t.yPos;
      if(has_category[0] == 0) has_category[0] = ((t.eventID == OBJECT::CTFBASE && t.team != 1) || (t.eventID == AREA::PATH && t.team == 0) || (jjGameCustom == GAME::DOM && (t.difficulty == 1 || t.difficulty == 2)) || t.eventID == AREA::WARP) ? 1 : 0;
      if(has_category[1] == 0) has_category[1] = ((t.eventID == OBJECT::CTFBASE || t.eventID == AREA::PATH) && t.team == 1) ? 1 : 0;
      if(has_category[2] == 0) has_category[2] = (t.eventID == OBJECT::CARROT || t.eventID == OBJECT::FULLENERGY) ? 1 : 0;
      if(has_category[4] == 0) has_category[4] = (t.eventID == OBJECT::SILVERCOIN || t.eventID == OBJECT::GOLDCOIN) ? 1 : 0;
      if(has_category[5] == 0) has_category[5] = (mode_mutator == "ons" || jjGameCustom == GAME::JB) && (t.eventID == OBJECT::ICEAMMO3 || t.eventID == OBJECT::ICEAMMO15 || t.eventID == OBJECT::ICEPOWERUP) ? 1 : 0;
      if(has_category[6] == 0) has_category[6] = (t.eventID == OBJECT::EOLPOST || t.eventID == AREA::EOL || t.eventID == AREA::WARPEOL) ? 1 : 0;
      if(has_category[7] == 0) has_category[7] = (t.eventID == AREA::MPSTART || (t.eventID == AREA::PATH && t.team < 0)) ? 1 : 0;
      for(int k = 8; k < 16; k += 1) { 
        if(has_category[k] == 0) has_category[k] = (t.eventID == power_ups[k - 8]) ? 1 : 0;
      }
      for(int k = 16; k < 20; k += 1) { 
        if(has_category[k] == 0) has_category[k] = (t.eventID == shields[k - 16]) ? 1 : 0;
      }
    }
    
    hotspot_x /= hotspots[i].things.length();
    hotspot_y /= hotspots[i].things.length();
    hotspot_x = int((hotspot_x - (layer4_start_x * 32)) / minimap_x_step);
    hotspot_y = int((hotspot_y - (layer4_start_y * 32)) / minimap_y_step);
    hotspots[i].levelX = hotspot_x;
    hotspots[i].levelY = hotspot_y;
    
    
    //then draw a small panel with icons for each type present
    int panel_width = int(panel_scale * 0.75);
    int panel_height = int(panel_scale * 1.5);
    
    panel_width += sum(has_category) * int(panel_scale * 1.25);
    hotspots[i].width = panel_width;
    hotspots[i].height = panel_height;
    
    //offsets - make sure the panel is within bounds
    int xo = max(0, hotspot_x - (panel_width / 2));
    int yo = max(0, hotspot_y - (panel_height / 2));
    xo = min(xo, minimap_width - (minimap_x_offset * 2) - panel_width); 
    yo = min(yo, minimap_height - (minimap_y_offset * 2) - panel_height);
    
    //draw it
    for(int x = 0; x < panel_width; x += 1) {
      for(int y = 0; y < panel_height; y += 1) {
        if((x == 0 && y == 0) || (x == 0 && y == panel_height - 1) || (x == panel_width - 1 && y == 0) || (x == panel_width - 1 && y == panel_height - 1)) {
          continue; //rounded corners
        }
        int colour = panel_colour;
        if(y <= 2 || y >= (panel_height - 3) || x <= 2 || x >= (panel_width - 3)) {
          colour += 1;
        }
        if(y <= 1 || y >= (panel_height - 2) || x <= 1 || x >= (panel_width - 2)) {
          colour += 1;
        }
        if(y <= 0 || y >= (panel_height - 1) || x <= 0 || x >= (panel_width - 1)) {
          colour += 1;
        }
        minimap[x + xo, y + yo] = colour;
      }
    }
    
    //then draw the icons on the panel
    for(uint j = 0; j < has_category.length(); j += 1) {
      if(has_category[j] == 1) {
        int sprite_offset = (panel_scale / 2);
        for(uint k = 0; k < j; k += 1) {
          if(has_category[k] == 1) {
            sprite_offset += int(panel_scale * 1.25);
          }
        }
        
        jjPIXELMAP sprite(jjAnimFrames[jjAnimations[jjAnimSets[sprites[j]].firstAnim + anims[j]].firstFrame]);
        
        for(int x = 0; x < panel_scale; x += 1) {
          for(int y = 0; y < panel_scale; y += 1) {
            int ref_x = int(x * float(sprite.width) / float(panel_scale));
            int ref_y = int(y * float(sprite.height) / float(panel_scale));
            if(sprite[ref_x, ref_y] != 0) {
              minimap[x + sprite_offset + xo, y + yo + int(panel_scale * 0.25)] = sprite[ref_x, ref_y];
            }
          }
        }
      }
    }
    
    hotspots[i].originX = xo + letterbox + minimap_x_offset;
    hotspots[i].originY = yo + letterbox + 25 + minimap_y_offset;
  }
  
  jjANIMFRAME@ minimapframe = jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[customAnimID]].firstAnim].firstFrame];
  minimap.save(minimapframe);
  
  precalc_wait = 10;
  precalc_done = 3;
}

/**
 * Find hotspots
 *
 * Looks for objects that are of interest (powerups, health, shields, bases) and 
 * saves a list of locations; objects that are near to each other are clustered
 */
void find_hotspots() {
  //see which objects are worth showing in the guide
  array<interesting_thing> objects_found = {};
  hotspots.removeRange(0, hotspots.length());
  
  for(int i = 1; i < jjObjectCount; i += 1) {
    jjOBJ@ obj = jjObjects[i];
    int e = obj.eventID;
    if(e == OBJECT::GENERATOR) {
      e = obj.var[3];
    }
    int team = -1;
    if(e == OBJECT::CTFBASE) {
      team = obj.var[1];
    }
    if(obj.isActive && is_interesting(e)) {
      interesting_thing thing();
      thing.xPos = int(obj.xOrg);
      thing.yPos = int(obj.yOrg);
      thing.eventID = e;
      thing.objectID = i;
      thing.team = team;
      objects_found.insertLast(thing);
    }
  }
  
  //we may get duplicates of what we found in jjObjects here, but that doesn't matter
  //since the minimap looks at types of objects, not the amount
  for(int y = 0; y < jjLayers[4].height; y += 1) {
    for(int x = 0; x < jjLayers[4].width; x += 1) {
      int eventID = jjEventGet(x, y);
      int difficulty = jjParameterGet(x, y, -4, 2);
      if(eventID == OBJECT::GENERATOR) {
        eventID = jjParameterGet(x, y, 0, 8);
      }
      int team = -1;
      if(eventID == OBJECT::CTFBASE) {
        team = jjParameterGet(x, y, 0, 1);
      }
      if(eventID == AREA::PATH) {
        int speed = jjParameterGet(x, y, 0, 6);
        if(speed == 0) {
          team = 0;
        } else if(speed == 21) {
          team = 1;
        }
      }
      if(eventID > 0 && is_interesting(eventID, difficulty)) {
        interesting_thing thing();
        thing.xPos = x * 32 + 16;
        thing.yPos = y * 32 + 16;
        thing.eventID = eventID;
        thing.difficulty = difficulty;
        thing.team = team;
        objects_found.insertLast(thing);
      }
    }
  }
  
  //check which of those are actually relevant to the current game mode
  for(int i = objects_found.length() - 1; i >= 0; i -= 1) {
    if(!is_relevant(objects_found[i])) {
      objects_found.removeAt(i);
    } else {
      if(affected_by_gravity.find(objects_found[i].eventID) >= 0) {
        objects_found[i].yPos += jjMaskedTopVLine(objects_found[i].xPos, objects_found[i].yPos, jjLayers[4].height * 32);
      }
      objects_found[i].typetext = get_typetext(objects_found[i]);
      if(objects_found[i].eventID == OBJECT::CTFBASE) {
        if(jjGameCustom == GAME::DOM) {
          objects_found[i].width = objects_found[i].height = 64;
        } else {
          objects_found[i].width = 128;
          objects_found[i].height = 96;
        }
      }
    }
  }
  
  //cluster nearby objects into hotspots
  while(objects_found.length() > 0) {
    cluster new_collection;
    new_collection.things.insertLast(objects_found[0]);
    objects_found.removeAt(0);
    for(int i = objects_found.length() - 1; i >= 0; i -= 1) {
      for(uint j = 0; j < new_collection.things.length(); j += 1) {
        if(objects_are_near(new_collection.things[j], objects_found[i])) {
          new_collection.things.insertLast(objects_found[i]);
          objects_found.removeAt(i);
          break;
        }
      }
    }
    hotspots.insertLast(new_collection);
  }
  
  max_page = hotspots.length();
}

/**
 * Check if event is interesting
 *
 * "Interesting" here means that it may potentially be shown; whether it will actually
 * be shown to players depends on other factors
 */
bool is_interesting(int e, int difficulty = 0) {
  if((difficulty == 1 || difficulty == 2) && jjGameCustom == GAME::DOM) {
    return true;
  }
  return interesting_events.find(e) >= 0;
}

/**
 * Check if thing is *relevant*
 *
 * Relevant means it's useful to point out in the current game mode; thus relevant
 * things are a subset of interesting things
 */
bool is_relevant(interesting_thing t) {
  int e = t.eventID;
  if(e == AREA::WARP) {
    return (jjGameCustom == GAME::FR && t.difficulty == 1);
  }
  if(e == OBJECT::EOLPOST || e == AREA::EOL || e == AREA::WARPEOL) {
    return (jjGameCustom == GAME::NOCUSTOM && (jjGameMode == GAME::COOP || jjGameMode == GAME::TREASURE));
  }
  if(e == OBJECT::SILVERCOIN || e == OBJECT::GOLDCOIN) {
    return (mode_mutator == "br"); //detect bank robbery here, somehow
  }
  if(e == AREA::PATH) {
    return (mode_mutator == "ons");
  }
  if(e == OBJECT::ICEAMMO3 || e == OBJECT::ICEAMMO15) {
    return (jjGameCustom == GAME::JB || mode_mutator == "ons");
  }
  if(e == AREA::MPSTART) {
    return (jjGameCustom == GAME::JB);
  }
  if(e == AREA::TEXT) {
    return (jjGameCustom == GAME::DOM && (t.difficulty == 1 || t.difficulty == 2));
  }
  if(e == OBJECT::CTFBASE) {
    return (jjGameCustom == GAME::NOCUSTOM && jjGameMode == GAME::CTF) || (jjGameCustom == GAME::DOM || jjGameCustom == GAME::FR || jjGameCustom == GAME::DCTF || jjGameCustom == GAME::HEAD);
  }
  if(jjGameCustom == GAME::DOM && (t.difficulty == 1 || t.difficulty == 2)) {
    return true;
  }
  return interesting_events.find(e) >= 0;
}

/**
 * Get text describing type of thing
 */
string get_typetext(interesting_thing t) {
  int e = t.eventID;
  if(e == AREA::WARP) {
    return "Flag";
  }
  if(e == OBJECT::EOLPOST || e == AREA::EOL || e == AREA::WARPEOL) {
    return "Exit";
  }
  if(e == OBJECT::SILVERCOIN || e == OBJECT::GOLDCOIN) {
    return "Coins";
  }
  if(e == OBJECT::ICEAMMO3 || e == OBJECT::ICEAMMO15) {
    return (mode_mutator == "ons") ? "Node builder ammo" : "Freezer ammo";
  }
  if(e == AREA::MPSTART) {
    return "Jail";
  }
  if(e == OBJECT::CTFBASE) {
    return (jjGameCustom == GAME::DOM) ? "Control point" : "Base";
  }
  if(e == AREA::PATH) {
    return "Control point";
  }
  if(power_ups.find(e) >= 0) {
    return "PowerUp";
  }
  if(e == OBJECT::CARROT || e == OBJECT::FULLENERGY) {
    return "Health";
  }
  if(jjGameCustom == GAME::DOM && (t.difficulty == 1 || t.difficulty == 2)) {
    return "Control point";
  }
  if(e == OBJECT::FIRESHIELD || e == OBJECT::WATERSHIELD || e == OBJECT::PLASMASHIELD || e == OBJECT::LASERSHIELD) {
    return "Shield";
  }
  return "MYSTERY OBJECT";
}

/**
 * Check if two objects are near each other
 *
 * Radius within which object must be is half the screen; vary if objects don't cluster easily enough
 */
bool objects_are_near(interesting_thing one, interesting_thing two) {
  float xRadius = jjResolutionWidth / 4;
  float yRadius = jjResolutionHeight / 4;
  float xMin = one.xPos - xRadius;
  float xMax = one.xPos + xRadius;
  float yMin = one.yPos - yRadius;
  float yMax = one.yPos + yRadius;
  return (two.xPos > xMin && two.xPos < xMax && two.yPos > yMin && two.yPos < yMax);
}

/**
 * Minimal public interface support just so other mutators can detect this one's presence
 */
shared interface PublicInterface : jjPUBLICINTERFACE { }
class PublicClass : PublicInterface { string getVersion() const { return "1.1"; } }
PublicClass publicInstance;
PublicClass@ onGetPublicInterface() { return publicInstance; }