Downloads containing ab20btl05.j2as

Downloads
Name Author Game Mode Rating
TSF with JJ2+ Only: Anniversary Bash 20 Levels Jazz2Online Multiple N/A Download file

File preview

#include "MLLE-Include-1.4.asc"
const bool MLLESetupSuccessful = MLLE::Setup();
#pragma require "ab20btl05.j2l"
#include "sevk.asc"
#include "SEweapon.asc"
#pragma require "Castle1.j2t"
#pragma require "diamondus frozen.j2t"
#pragma require "Hocus013D.j2t"
#pragma require "JJ1Deserto.j2t"
#pragma require "Medivo2.j2t"
#pragma require "sevk.asc"
#pragma require "SEweapon.asc"

/* Academy, written by CJ/Superjazz */

bool demoModeOn = false;
bool debugModeOn = false;

enum BOX {BOX_INFO, BOX_SKILLS, BOX_SPELL_BOOK, BOX_TREE, BOX_KEY_MENU, BOX_WIZARD };

enum SPELL {SPELL_MAGIC_ARROW, SPELL_STONE_SKIN, SPELL_BLESS, SPELL_BLOOD_LUST, SPELL_DISPEL, SPELL_SLOW, SPELL_CURE,
			SPELL_VISIONS, SPELL_DISRUPTING_RAY, SPELL_WEAKNESS, SPELL_ICE_BOLT, SPELL_DEATH_RIPPLE, SPELL_PRECISION,
			SPELL_WALL_OF_FIRE, SPELL_FORGETFULNESS, SPELL_FIREBALL, SPELL_FRENZY, SPELL_CHAIN_LIGHTNING,
			SPELL_ARMAGEDDON, SPELL_IMPLOSION, SPELL_UNDO, SPELL_NONE };

enum SKILL {SKILL_MAX_MANA, SKILL_MANA_REGEN, SKILL_SPELL_DAMAGE, SKILL_SPELL_DURATION, SKILL_SPELL_COST };

enum PACKET_TYPE {PACKET_EFFECT, PACKET_MASS_EFFECT, PACKET_CHANNELING_STARTED, PACKET_CHANNELING_STOPPED,
			PACKET_PLAYER_DEAD, PACKET_WALL_OF_FIRE, PACKET_CHAIN_LIGHTNING_SERVER, PACKET_CHAIN_LIGHTNING_CLIENT,
			PACKET_AOE, PACKET_CAPTURE_GEM_MINE, PACKET_SKILL_DAMAGE, PACKET_SKILL_DURATION, PACKET_REQ_SYNC_DATA,
			PACKET_SYNC_EFFECTS, PACKET_SYNC_SKILLS, PACKET_SYNC_WALLS_OF_FIRE, PACKET_SYNC_GEM_MINE,
			PACKET_SYNC_DEMO_MODE, PACKET_FREE_SPELL };

const array<uint8> FUR_BLESS = {80, 80, 80, 80};
const array<uint8> FUR_DISRUPTING_RAY = {10, 10, 10, 10};
const array<uint8> FUR_FORGETFULNESS = {105, 105, 105, 105};
const array<uint8> FUR_FRENZY = {24, 24, 24, 24};
const array<uint8> FUR_PRECISION = {162, 162, 162, 162};
const array<uint8> FUR_SLOW = {20, 20, 20, 20};
const array<uint8> FUR_STONE_SKIN = {65, 65, 65, 65};
const array<uint8> FUR_WEAKNESS = {90, 90, 90, 90};

const float TARGET_X_SCALE = 2;
const float TARGET_Y_SCALE = 3;

const float THRESHOLD_X_SPEED = 8.0;
const float SLOW_SPEED = 4.0;

const int BULLET_MAGIC_ARROW = 105;
const int BULLET_ICE_BOLT = 115;
const int BULLET_FIREBALL = 125;

const int CHANNELING_BAR_BODY_COLOR = 15;
const int CHANNELING_BAR_BORDER_COLOR = 1;
const int CHANNELING_BAR_ORIGIN_LEFT = 2;
const int CHANNELING_BAR_ORIGIN_TOP = 4;
const int CHANNELING_BAR_HEIGHT = 10;
const int CHANNELING_COLOR = 37;
const int RESOURCE_BAR_HEIGHT = 16;
const int RESOURCE_BAR_WIDTH = 80;

const int AREA_OF_EFFECT_COLOR = 24;
const int AREA_OF_EFFECT_TARGET_COLOR = 40;

const int EFFECT_INITIAL_HEIGHT = 32;
const int HEADER_MARGIN_TOP = 16;
const int KEY_MENU_TAB_LEFT = 4;
const int HINT_BOX_HEIGHT = 400;
const int INFO_BOX_HEIGHT = 176;
const int INFO_BOX_TEXT_MARGIN = 80;
const int INFO_SCROLL_SPEED = 7;
const int ROW_SPACING = 16;
const int SELECTED_SPELL_COLOR = 24;
const int SPELL_BOXES_INIT_Y = 176;
const int SPELL_CAST_HINT_DEFAULT_WIDTH = 200;
const int SPELLBOOK_BODY_COLOR = 65;
const int SPELLBOOK_BORDER_COLOR = 1;
const int SPELLBOOK_MARGIN_RIGHT = 12;
const int SPELLBOOK_ORIGIN_TOP = 4;
const int TEXT_MARGIN_LEFT = 5;
const int TEXT_MARGIN_TOP = 48;
const int TEXT_BOX_BODY_COLOR = 98;
const int TEXT_BOX_BORDER_COLOR = 100;

const int BULLET_CHECK_MAX_DISTANCE = 64;
const int SECOND = 70;
const int TILE = 32;

const int BULLET_POINTER_MAX_DISTANCE = 10;
const int CHAIN_LIGHTNING_DEFAULT_DURATION = 35;
const int CHAIN_LIGHTNING_TARGET_SCALE = 7;
const int COOLDOWN = 350;
const int DEFAULT_MAX_MANA = 100;
const int EARLY_GAME_LIMIT = 1050;
const int NUMPAD_BUFFER_TIME = 70;
const int MAX_ACTIVE_WALLS_OF_FIRE = 5;
const int SHORT_GAME_THRESHOLD = 10;
const int STARTING_MANA = 10;
const int TREE_COOLDOWN = 1;
const int TREE_BASE_COST = 10;
const int VERTICAL_FLIP_EVENT_ID = 149;
const int WALL_OF_FIRE_CASTING_RANGE = 128;
const int WALL_OF_FIRE_WIDTH = 2;
const int WIZARD_SPELL_COST_PER_TIER = 20;

const int ELEVATOR_TILE_X = 144;
const int ELEVATOR_TILE_Y = 48;
const int GEM_MINE_TOP = 82;
const int GEM_MINE_BOTTOM = 84;
const int GEM_MINE_LEFT = 47;
const int GEM_MINE_RIGHT = 49;
const int MAGIC_WELL_TOP = 72;
const int MAGIC_WELL_LEFT = 60;
const int MAGIC_WELL_RIGHT = 69;
const int TREE_OF_KNOWLEDGE_EYES_X = 2578;
const int TREE_OF_KNOWLEDGE_EYES_Y = 2752;
const int WIZARD_X = 4704;
const int WIZARD_Y = 320;

const int8 WALL_OF_FIRE_MAX_HEIGHT = 10;

const string ACADEMY_INFO_TEXT = "Welcome to Academy young magician! Academy is the home of the wizards, where magic reigns. In academy you collect spell scrolls that you may consume to cast spells on thee or thy enemies. Thy opponents drop scrolls upon dying. To cast spells, thou must also generate enough mana. Good luck!";
const string CANCEL = "C - Close";
const string EMPTY_SKILLS = "You don't have any skills learned yet! Visit the tree of knowledge to learn some (asks for " + TREE_BASE_COST + " purple gems).";
const string EMPTY_SPELLBOOK = "YOUR SPELLBOOK\nIS EMPTY!";
const string HOTKEY_IN_USE = "That key is already\nreserved by another hotkey!";
const string HOTKEY_FILENAME = "xlmacademy_hotkeys.asdat";
const string OPEN_SPELLBOOK = "C - Spellbook";
const string SKILLS_TIP = "Skills are passive abilities that are active on you at all times after you have learned them.";
const string SPELLBOOK_TIP = "Go get some scrolls\nby killing other players!";
const string TREE_OF_KNOWLEDGE_QUOTE = "Ahh, an adventurer!\nI will be happy to teach you\na little of what I have learned\nover the ages for\na mere 10 (purple) gems.\n(Just bury it around my roots.)";
const string TREE_OF_KNOWLEDGE_TIP = "Press fire to accept\nor down to exit.";
const string WIZARD_TEXT = "Psst. I have got a bunch of spell scrolls for sale from my mage guild. I can sell you some for a fair price. The more you pay, the more powerful the spell.";
const string WIZARD_TEXT_RETURN = "I'm glad to see you again. Would you like another scroll?";
const string WIZARD_TIP = "Use arrow keys and\nfire to choose.";

bool channelingStarted = false;
bool delayFreeScroll = false;
bool gameStarted = false;
bool infoOpen = false;
bool hotkeyInUse = false;
bool recovering = false;
bool showResources = true;
bool skillsOpen = false;
bool spellBookOpen = false;
bool wizardFirstEncounter = true;

int chainLightningCounter = 0;
int channelingElapsed = 0;
int earlyGameElapsed = EARLY_GAME_LIMIT;
int freeScrollDelay = 10;
int hintBoxY = 0;
int hotkeyCycleSpells = 67;
int hotkeyInfo = 73;
int hotkeyKeyMenu = 75;
int hotkeyResources = 82;
int hotkeySkills = 76;
int hotkeySpellbook = 83;
int infoScrollY = 0;
int keyMenuState = 1;
int numpadElapsed = 0;
int selectionHotkey = -1;
int selectionWizard = 1;
int recoverElapsed = 0;
int treeCost = TREE_BASE_COST;
int treeElapsed = 0;
int treeState = 0;
int wizardState = 0;

uint infoBoxHintPage = 0;

dictionary spells = {
	{"M", Spell("M", "Magic Arrow", 	SPELL_MAGIC_ARROW,		SPELL_NONE, 			1, 1, 	1, 0, 	10, 0,		0,	false, "Fires an arrow that flies in a straight line, hitting all objects on its way and dealing ||{dmg} ||||damage on all enemies.")},
	{"K", Spell("K", "Stone Skin", 		SPELL_STONE_SKIN,		SPELL_DISRUPTING_RAY,	1, 2, 	0, 20, 	10, 0,		0,	false, "You take |1 |||||damage less from enemy bullets for ||||||{dur} ||seconds.")},
	{"B", Spell("B", "Bless", 			SPELL_BLESS,			SPELL_WEAKNESS,			1, 3, 	0, 20, 	10, 0,		0,	false, "Your bullets deal ||1 ||||damage more on all enemies for ||||||{dur} ||seconds.")},
	{"O", Spell("O", "Blood Lust", 		SPELL_BLOOD_LUST,		SPELL_NONE,				1, 4, 	0, 20, 	10, 0,		0,	false, "You gain a sugar rush and max fast fire for ||||{dur} ||seconds.")},
	{"D", Spell("D", "Dispel", 			SPELL_DISPEL,			SPELL_NONE,				1, 5, 	0, 0, 	10, 1,		4,	true, "Removes all effects from all players in range (including you).")},
	{"S", Spell("S", "Slow", 			SPELL_SLOW,				SPELL_NONE, 			1, 6, 	0, 10, 	12, 1,		4,	false, "All enemies in range lose half of their movement speed for ||||{dur} ||seconds.")},
	{"U", Spell("U", "Cure", 			SPELL_CURE,				SPELL_NONE,				1, 7, 	0, 0, 	12, 0,		0,	false, "You regain full health (for a little pinch) and all negative effects are removed from you.")},
	{"V", Spell("V", "Visions",			SPELL_VISIONS,			SPELL_NONE,				2, 8, 	0, 20, 	16, 0,		0,	false, "See each player's current health above them (as long as antiradar is disabled).")}, //See other players' healths if applicable? (sure, an adventure map spell)
	{"R", Spell("R", "Disrupting Ray", 	SPELL_DISRUPTING_RAY,	SPELL_STONE_SKIN, 		2, 9, 	0, 10, 	18, 1.25,	5,	false, "All enemies in range become vulnerable and take ||2 ||||damage more from bullets for ||||||{dur} ||seconds.")},
	{"W", Spell("W", "Weakness", 		SPELL_WEAKNESS,			SPELL_BLESS,			2, 10, 	0, 10, 	18, 1.25,	5,	false, "All enemies in range become weak and their bullets deal |1 |||||damage less for ||||||{dur} ||seconds.")},
	{"E", Spell("E", "Ice Bolt", 		SPELL_ICE_BOLT,			SPELL_NONE,				2, 11, 	2, 0, 	20, 0,		0,	false, "Fires a bolt that flies in a straight line, hitting all objects on its way, freezing and dealing ||{dmg} ||||damage on all enemies.")},
	{"H", Spell("H", "Death Ripple", 	SPELL_DEATH_RIPPLE,		SPELL_NONE,				2, 12, 	1, 0,	24, 1.25,	6,	true, "Deals ||{dmg} ||||damage to all living players in range (undead are unaffected).")},
	{"P", Spell("P", "Precision", 		SPELL_PRECISION,		SPELL_FORGETFULNESS,	2, 13, 	0, 20, 	28, 0,		0,	false, "Your bullets deal ||2 ||||damage more on all enemies for ||||||{dur} ||seconds.")},
	{"I", Spell("I", "Wall of Fire",	SPELL_WALL_OF_FIRE,		SPELL_NONE,				2, 14, 	2, 10, 	32, 1.25,	0,	false, "Places a passable wall of fire that lasts ||||{dur} ||seconds. All players passing through it are dealt ||||{dmg} ||||damage.")}, //Should be Fire wall, but would be harder to distinguish from Fireball
	{"G", Spell("G", "Forgetfulness", 	SPELL_FORGETFULNESS,	SPELL_PRECISION,		3, 15, 	0, 10, 	36, 1.5,	7,	false, "All enemies in range lose the ability to shoot bullets for ||||{dur} ||seconds.")},
	{"F", Spell("F", "Fireball", 		SPELL_FIREBALL,			SPELL_NONE,				3, 16, 	3, 0, 	40, 0,		7,	false, "Fires a flaming bolt that flies in a straight line, exploding upon the first impact with a player or a solid wall, dealing ||{dmg} ||||damage in a large radius.")},
	{"Y", Spell("Y", "Frenzy", 			SPELL_FRENZY,			SPELL_NONE,				4, 17, 	0, 20, 	50, 0,		0,	false, "Your bullets deal ||4 ||||damage more on all enemies, but you also take ||||4 ||||damage more from enemy bullets for ||||||{dur} ||seconds.")},
	{"L", Spell("L", "Chain Lightning",	SPELL_CHAIN_LIGHTNING,	SPELL_NONE,				4, 18, 	4, 0, 	60, 1.75,	7,	false, "All enemies in a large range are dealt ||{dmg} ||||damage. Players whom are in range of the primary targets take ||||half ||||the damage (including you).")},
	{"A", Spell("A", "Armageddon",		SPELL_ARMAGEDDON,		SPELL_NONE,				4, 19, 	5, 0, 	70, 1.75,	8,	true, "All players in a large range are dealt ||{dmg} ||||damage within a short delay (including you).")},
	{"Q", Spell("Q", "Implosion", 		SPELL_IMPLOSION,		SPELL_NONE,				5, 20, 	7, 0, 	80, 2,		3,	false, "All enemies in a short range are dealt ||{dmg} ||||forced damage within a short delay (ignores blinking).")}
};

array<Skill@> skills = {
	Skill(SKILL_MAX_MANA, "Advanced Intelligence", "||||+100 ||maximum mana"),
	Skill(SKILL_MANA_REGEN, "Advanced Mysticism", "||||100% ||faster mana regeneration"),
	Skill(SKILL_SPELL_DAMAGE, "Advanced Sorcery", "||||+1 ||spell damage"),
	Skill(SKILL_SPELL_DURATION, "Advanced Magic Endurance", "||||+10 ||seconds spell duration"),
	Skill(SKILL_SPELL_COST, "Advanced Wizardry", "Spells cost ||||50% ||less")
};

array<string> infoBoxHints = {
	"||||Hint: ||Stronger players are more likely to drop stronger spell scrolls upon dying (higher tier, as shown in the top left corner on each spell), depending on their roasts count. If the game mode does not support roast count, the spell scroll tiers are completely random.",
	"||||Hint: ||You cast spells based on spell scrolls you own. When you successfully cast a spell, the scroll is spent. If your spell cast affects no one, the spell scroll will not be spent and you can cast it again later. (In demo mode spell scrolls are never spent.)",
	"||||Hint: ||If you don't get lucky with picking up scrolls from other players, you can alternatively purchase them with coins. Visit the wizard at the top of the eastern tower. The more you pay, the stronger the spell!",
	"||||Hint: ||You can visit the tree of knowledge to learn additional skills that benefit you permanently. The tree wants " + TREE_BASE_COST + " purple gems as a reward for the first lesson. Every subsequent lesson will cost " + TREE_BASE_COST + " purple gems more, up to a total of 5 lessons."
};

array<GAME::Custom> nonRoastScoreCustomModes = {GAME::RT, GAME::LRS, GAME::PEST, GAME::TLRS};

array<VisualGem@> visualGems = {
	VisualGem(44.5, 83.2),
	VisualGem(45, 83.3),
	VisualGem(45.6, 83.8),
	VisualGem(46.3, 84.4),
	VisualGem(47, 84),
	VisualGem(48, 84.2),
	VisualGem(48.6, 84.3),
	VisualGem(50, 84.1),
	VisualGem(51, 83.3),
	VisualGem(51.5, 83.1)
};

array<string> keyBindings;
array<string> ownSpells;
array<uint> numpadBuffer;
array<uint> numpadKeys = {96, 97, 98, 99, 100, 101, 102, 103, 104, 105};

array<array<Chunk>> chunks;

array<Key> keys(256);

array<Player> players(32);

array<AreaOfEffect@> activeAreasOfEffect;

array<array<ChainLightningTarget@>> chainLightningTargetGroups;

array<WallOfFire@> activeWallsOfFire;

GemMine@ gemMine;

class Chunk {
	array<jjOBJ@> objects;
	void addObject(jjOBJ@ obj) {
		objects.insertLast(obj);
	}
	void clearObjects() {
		objects.removeRange(0, objects.length);
	}
}

class Player {
	int mana;
	int maxMana;
	int manaRegenCounter;
	int manaRegenCounterEnd = 140;
	int manaRegenRate;
	int cooldown;
	int8 playerID;
	int8 spellCostBonus;
	int8 spellDamageBonus;
	uint32 originalFur;
	uint8 currentHealth;
	uint spellDurationBonus;
	bool isDead;
	bool isChanneling;
	string selectedSpellKey;
	array<SKILL> activeSkills;
	array<Effect@> activeEffects;
	Player() {
		isDead = false;
		isChanneling = false;
		selectedSpellKey = "";
		mana = STARTING_MANA;
		maxMana = DEFAULT_MAX_MANA;
		manaRegenCounter = 0;
		manaRegenRate = 1;
		playerID = 0;
		cooldown = 0;
		spellCostBonus = 1;
		spellDamageBonus = 0;
		spellDurationBonus = 0;
	}
	~Player() {
		jjPLAYER@ play = jjPlayers[playerID];
		if (originalFur != 0 && !play.isZombie) {
			play.fur = originalFur;
		}
	}
	void addNewRandomSkill() {
		if (activeSkills.length < skills.length) {
			int index = 0;
			SKILL randomSkill = SKILL_MAX_MANA;
			while (index >= 0) {
				uint random = jjRandom() % skills.length;
				randomSkill = SKILL(random);
				index = activeSkills.find(randomSkill);
			}
			Skill@ skill = skills[skills.find(Skill(randomSkill))];
			jjAlert("You learned " + skill.name + "! ||(" + skill.description + ")");
			setSkillBonus(this, randomSkill, playerID);
			activeSkills.insertLast(randomSkill);
		}
	}
	void setNewActiveEffect(Effect@ effect) {
		int index = -1;
		for (uint i = 0; i < activeEffects.length; i++) {
			if (activeEffects[i].enumValue == effect.enumValue ||
					activeEffects[i].enumValue == effect.counterEffect) {
				activeEffects.removeAt(i);
			}
		}
		activeEffects.insertLast(effect);
	}
	void setSelectedSpellKey(string newSelectedSpellKey) {
		selectedSpellKey = newSelectedSpellKey;
	}
	void setChanneledSpellKey(string newSelectedSpellKey) {
		selectedSpellKey = newSelectedSpellKey;
		isChanneling = true;
	}
	void unSetChanneledSpellKey() {
		selectedSpellKey = "";
		isChanneling = false;
	}
	void removeActiveEffect(uint index) {
		activeEffects.removeAt(index);
	}
	void removeAllActiveEffects() {
		activeEffects.removeRange(0, activeEffects.length);
	}
	void removeNegativeEffects() {
		for (uint i = 0; i < activeEffects.length; i++) {
			if (activeEffects[i].isCurse) {
				activeEffects.removeAt(i);
			}
		}
	}
	void regenerateMana(jjPLAYER@ play) {
		if (manaRegenCounter < manaRegenCounterEnd) {
			manaRegenCounter += isInMagicWell(play) ? manaRegenRate * 2 : manaRegenRate;
		}
		else {
			manaRegenCounter = 0;
			if (mana < maxMana) {
				mana++;
			}
		}
	}
	bool hasForgetfulness() {
		for (uint i = 0; i < activeEffects.length; i++) {
			if (activeEffects[i].enumValue == SPELL_FORGETFULNESS) {
				return true;
			}
		}
		return false;
	}
	bool isInMagicWell(jjPLAYER@ play) {
		return play.xPos > MAGIC_WELL_LEFT*TILE && play.xPos < MAGIC_WELL_RIGHT*TILE
				&& play.yPos > MAGIC_WELL_TOP*TILE;
	}
}

class Spell {
	string key;
	string name;
	SPELL enumValue;
	SPELL counterSpell;
	uint tier;
	uint numpad;
	int localCount;
	int baseDamage;
	uint baseDuration;
	int baseManaCost;
	float channelingTime;
	float radius;
	bool damagesAll;
	string description;
	Spell() {}
	Spell(string setKey, string setName, SPELL setEnumValue, SPELL setCounterSpell, uint setTier,
			uint setNumpad, int setBaseDamage, uint setBaseDuration, int setBaseManaCost,
			float setChannelingTime, float setRadius, bool setDamagesSelf, string setDescription) {
		key = setKey;
		name = setName;
		enumValue = setEnumValue;
		counterSpell = setCounterSpell;
		tier = setTier;
		numpad = setNumpad;
		baseDamage = setBaseDamage;
		baseDuration = setBaseDuration;
		baseManaCost = setBaseManaCost;
		channelingTime = setChannelingTime;
		radius = setRadius;
		damagesAll = setDamagesSelf;
		description = setDescription;
		
		localCount = 0;
	}
	int opCmp(const Spell@ otherSpell) {
		return numpad - otherSpell.numpad;
	}
}

class SpellScroll : se::AmmoPickup {
	uint tierChance;
	SpellScroll(jjOBJ@ obj) {
		super(jjAnimations[jjAnimSets[ANIM::PICKUPS].firstAnim + 5], jjAnimations[jjAnimSets[ANIM::PICKUPS].firstAnim + 5]);
		determineTierByCreator(obj);
	}
	void onBehave(jjOBJ@ obj) override {
		obj.behave(BEHAVIOR::PICKUP, false);
		
		if (obj.age >= 700) {
			obj.delete();
		}
		else {
			if (obj.age >= 500 && obj.age % 10 == 0) {
				//draw nothing
			}
			else {
				drawScroll(obj);
			}
			obj.age++;
		}
	}
	bool onObjectHit(jjOBJ@ obj, jjOBJ@ bullet, jjPLAYER@ player, int force) override {
		if (player.isLocal) {
			string gainedSpellName = pickUpScroll(tierChance);
			jjAlert("You picked up a scroll of " + gainedSpellName);
		}
		
		obj.frameID = 0;
		obj.behavior = BEHAVIOR::EXPLOSION2;
		
		return true;
	}
	void determineTierByCreator(jjOBJ@ obj) {
		if (nonRoastScoreCustomModes.find(jjGameCustom) < 0 && obj.creatorType == CREATOR::PLAYER) {
			jjPLAYER@ creator = jjPlayers[obj.creatorID];
			tierChance = getTierChanceByRoasts(creator.roasts);
		}
		else {
			tierChance = 5;
		}
		if (debugModeOn) jjAlert("tierChance: " + tierChance);
	}
	void drawScroll(jjOBJ@ obj) {
		float yPos = getY(obj);
		
		jjDrawSprite(obj.xPos, yPos, ANIM::PICKUPS, 5, 0, 0, SPRITE::SINGLECOLOR, 15);
		for (float y = yPos-5; y <= yPos+7; y += 6) {
			for (float x = obj.xPos-6; x <= obj.xPos+10; x += 5) {
				jjDrawResizedSprite(x, y, ANIM::BRIDGE, 0, 0, 0.4, 0.4, SPRITE::SINGLECOLOR, 1);
			}
		}
		for (float x = obj.xPos-12; x <= obj.xPos+15; x++) {
			jjDrawPixel(x, getY(obj)+8, 1);
		}
		uint xDim = 136;
		for (float y = yPos+8; y < yPos+15; y++) {
			float xPos = obj.xPos-16;
			
			float xDiff = jjSin(xDim) * 4; 
			jjDrawPixel(xPos + xDiff, y, 1);
			
			if (xDim < 540) xDim += 57;
			else xDim = 136;
		}
		xDim = 128;
		for (float y = yPos-12; y < yPos-5; y++) {
			float xPos = obj.xPos-16;
			
			float xDiff = jjSin(xDim) * 4; 
			jjDrawPixel(xPos + xDiff, y, 1);
			
			if (xDim < 540) xDim += 58;
			else xDim = 128;
		}
		jjDrawPixel(obj.xPos-15, yPos-9, 1);
		jjDrawPixel(obj.xPos-15, yPos-10, 1);
	}
}

class GemMine {
	int8 ownerID;
	int captureElapsed, lockElapsed, yieldElapsed;
	int captureBegin = 350;
	int lockBegin = 2100;
	int yieldBegin = 350;
	array<int8> playersInRange;
	GemMine() {
		ownerID = -1;
		captureElapsed = captureBegin;
		yieldElapsed = yieldBegin;
		lockElapsed = lockBegin;
	}
	void capture(int8 setOwnerID) {
		captureElapsed = captureBegin;
		lockElapsed = lockBegin;
		ownerID = setOwnerID;
	}
	void control(jjPLAYER@ localPlayer, Player@ asPlayer) {
		if (gameIsRunning()) {
			if (lockElapsed > 0) {
				lockElapsed--;
			}
			else {
				for (int8 i = 0; i < 32; i++) {
					jjPLAYER@ play = jjPlayers[i];
					int index = playersInRange.find(play.playerID);
					if (index < 0 && playerInRange(play)) {
						playersInRange.insertLast(play.playerID);
					}
					else if (index >= 0 && !playerInRange(play)) {
						playersInRange.removeAt(index);
					}
				}
				if (playerInRange(localPlayer) && !asPlayer.isDead
						&& localPlayer.playerID != ownerID && playersInRange.length == 1) {
					if (captureElapsed > 0) {
						if (captureElapsed == captureBegin) {
							jjAlert("Capturing gem mine in..." + captureElapsed / SECOND, false, STRING::MEDIUM);
						}
						else if (captureElapsed % SECOND == 0) {
							jjAlert("" + captureElapsed / SECOND, false, STRING::MEDIUM);
						}
						captureElapsed--;
					}
					else {
						capture(localPlayer.playerID);
						sendCaptureGemMinePacket(localPlayer.playerID);
					}
				}
				else {
					captureElapsed = captureBegin;
				}
			}
			if (ownerID >= 0 && jjPlayers[ownerID].isLocal) {
				yieldGem(jjPlayers[ownerID]);
			} 
		}
	}
	void yieldGem(jjPLAYER@ play) {
		if (yieldElapsed > 0) {
			yieldElapsed--;
		}
		else {
			yieldElapsed = yieldBegin;
			jjAddObject(OBJECT::PURPLEGEM, play.xPos, play.yPos);
		}
	}
	void draw(jjCANVAS@ canvas) {
		uint8 color, _ = 0;
		string ownerName = "";
		if (ownerID >= 0) {
			jjPLAYER@ owner = jjPlayers[ownerID];
			owner.furGet(_, color, _, _);
			ownerName = owner.name;
		}
		jjTEXTAPPEARANCE centeredText();
		centeredText.align = STRING::CENTER;
		canvas.drawSprite(49*TILE, 84*TILE, ANIM::FLAG, 3, 0, 0, SPRITE::SINGLECOLOR, color);
		canvas.drawString(49*TILE, 85*TILE, "Owned by\n" + ownerName, STRING::SMALL, centeredText);
		if (gameIsRunning() && lockElapsed > 0) {
			canvas.drawString(49*TILE, 85*TILE, "\n\nUnlocking in " + int(lockElapsed / SECOND), STRING::SMALL, centeredText);
		}
	}
	bool playerInRange(jjPLAYER@ play) {
		return play.xPos >= GEM_MINE_LEFT*TILE && play.xPos <= GEM_MINE_RIGHT*TILE
				&& play.yPos >= GEM_MINE_TOP*TILE && play.yPos <= GEM_MINE_BOTTOM*TILE;
	}
}

class VisualGem {
	float x, y, scale;
	int angle, color, frame;
	VisualGem(float setX, float setY) {
		x = setX;
		y = setY;
		angle = jjRandom() % 1024;
		color = jjRandom() % 12;
		//scale = jjRandom() % 40 * 0.01 + 0.1;
		scale = 0.3;
		frame = jjRandom() % 8;
	}
	void draw(jjCANVAS@ canvas) {
		canvas.drawRotatedSprite(int(x*TILE), int(y*TILE), ANIM::PICKUPS, 34, frame, angle, scale, scale, SPRITE::GEM, color);
	}
}

class Skill {
	SKILL enumValue;
	string name;
	string description;
	Skill(SKILL setEnumValue) {
		enumValue = setEnumValue;
	}
	Skill(SKILL setEnumValue, string setName, string setDescription) {
		enumValue = setEnumValue;
		name = setName;
		description = setDescription;
	}
	bool opEquals(Skill@ other) {
		return enumValue == other.enumValue;
	}
}

abstract class Effect {
	bool isLocal;
	bool isCurse;
	jjPLAYER@ play;
	string name;
	SPELL enumValue;
	SPELL counterEffect;
	uint elapsed;
	uint duration;
	Effect() {}
	Effect(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect, uint setDuration,
			bool setIsLocal = false, bool setIsCurse = false) {
		@play = setPlay;
		name = setName;
		enumValue = setEnumValue;
		counterEffect = setCounterEffect;
		duration = setDuration;
		isLocal = setIsLocal;
		isCurse = setIsCurse;
		elapsed = setDuration;
	}
	void affect() {}
}

abstract class AreaOfEffect {
	float xPos;
	float yPos;
	float radius;
	int damage;
	uint elapsed;
	uint duration;
	jjPLAYER@ caster;
	AreaOfEffect(float setXPos, float setYPos, float setRadius, int setDamage, uint setDuration, jjPLAYER@ setCaster) {
		xPos = setXPos;
		yPos = setYPos;
		radius = setRadius;
		damage = setDamage;
		elapsed = duration = setDuration;
		@caster = setCaster;
	}
	void animate() {}
	void control(jjPLAYER@ play) {
		float scaledRadius = getScaledRadius(radius, ANIM::FLARE, 5);
		float xDistance = play.xPos - xPos;
		float yDistance = play.yPos - yPos;
		
		if (xDistance*xDistance + yDistance*yDistance < scaledRadius*scaledRadius && gameIsRunning()) {
			play.hurt(damage, false, caster);
		}
	}
}

class Key {
	int keyCode;
	string key;
	bool keyPressed;
	Key() {}
	Key(int setKeyCode, string setKey) {
		keyCode = setKeyCode;
		key = setKey;
		keyPressed = false;
	}
}

class AbstractBullet : jjBEHAVIORINTERFACE {
	private jjBEHAVIOR nativeBehavior;
	private HANDLING::Player nativeHandling;
    AbstractBullet(const jjBEHAVIOR &in setNativeBehavior, const HANDLING::Player &in setNativeHandling) {
		nativeBehavior = setNativeBehavior;
		nativeHandling = setNativeHandling;
	}
	void onBehave(jjOBJ@ obj) {
		initBullet(obj);
	}
	bool onIsRFBullet(jjOBJ@ obj) {
        return nativeBehavior == BEHAVIOR::RFBULLET;
    }
	void initBullet(jjOBJ@ obj) {
		int ammoCount = jjPlayers[obj.creatorID].ammo[WEAPON::BLASTER];
		obj.bulletHandling = HANDLING::IGNOREBULLET;
		obj.playerHandling = HANDLING::SPECIAL;
		if (debugModeOn) jjAlert("ammoCount: " + ammoCount);
		if (ammoCount >= BULLET_MAGIC_ARROW-1 && ammoCount <= BULLET_MAGIC_ARROW) {
			obj.scriptedCollisions = true;
			obj.behavior = MagicArrow();
		}
		else if (ammoCount >= BULLET_ICE_BOLT-1 && ammoCount <= BULLET_ICE_BOLT) {
			obj.scriptedCollisions = true;
			obj.behavior = IceBolt();
		}
		else if (ammoCount >= BULLET_FIREBALL-1 && ammoCount <= BULLET_FIREBALL) {
			obj.behavior = Fireball();
		}
		else {
			obj.scriptedCollisions = true;
			obj.behavior = BulletWrapper(obj, nativeBehavior, nativeHandling);
		}
		jjPlayers[obj.creatorID].ammo[WEAPON::BLASTER] = 50;
	}
}

class BulletWrapper : jjBEHAVIORINTERFACE {
    private jjBEHAVIOR nativeBehavior;
	private HANDLING::Player nativeHandling;
    BulletWrapper(jjOBJ@ obj, const jjBEHAVIOR &in setNativeBehavior, const HANDLING::Player &in setNativeHandling) {
		nativeBehavior = setNativeBehavior;
		nativeHandling = setNativeHandling;
	}
    void onBehave(jjOBJ@ obj) {
		if (obj.state == STATE::KILL) {
			obj.delete();
		}
		else {
			controlObjectCollision(obj);
			obj.behave(nativeBehavior);
		}
    }
	bool onObjectHit(jjOBJ@ obj, jjOBJ@ bullet, jjPLAYER@ play, int force) {
		if (@play != null && gameIsRunning()) {
			jjPLAYER@ creator = jjPlayers[obj.creatorID];
			
			if (obj.creatorType == CREATOR::PLAYER && creator.isEnemy(play) &&
					play.blink == 0 && creator.blink == 0) {
				
				int8 damage = obj.var[6] & 8 != 0 ? 2 : 1;
				if (jjPlayers[creator.playerID].isZombie) damage++;
				
				if (debugModeOn) jjAlert("damage now: " + damage);
				
				damage += getDamageModifier(players[creator.playerID], players[play.playerID]);
				
				if (damage < 0) damage = 0;
				
				if (debugModeOn) jjAlert("damage then: " + damage);
				
				play.hurt(damage, false, creator);
				obj.state = STATE::KILL;
			}
			return true;
		}
		return false;
	}
}

class MagicArrow : jjBEHAVIORINTERFACE {
	void onBehave(jjOBJ@ obj) {
		switch(obj.state) {
			case STATE::START:
			{
				jjSample(obj.xPos, obj.yPos, SOUND::AMMO_BULFL1);
				obj.direction = jjPlayers[obj.creatorID].direction;
				obj.xPos += obj.direction >= 0 ? 16 : -16;
				obj.state = STATE::FLY;
				obj.var[6] = 16;
				obj.light = 100;
			}
			break;
			case STATE::FLY:
			{
				for (int i = 0; i < 32; i++) {
					jjPLAYER@ play = jjPlayers[i];
					jjPLAYER@ caster = jjPlayers[obj.creatorID];
					Player@ asCaster = players[caster.playerID];
					if (abs(obj.xPos - play.xPos) <= 32 && abs(obj.yPos - play.yPos) <= 32) {
						if (obj.doesCollide(play, true) && caster.isEnemy(play)
								&& gameIsRunning()) {
							Spell@ spell = cast<Spell@>(spells["M"]);
							play.hurt(spell.baseDamage + asCaster.spellDamageBonus, false, caster);
						}
					}
				}
				obj.xPos += obj.direction >= 0 ? 10 : -10;
				if (obj.xPos < 0 || obj.xPos > jjLayerWidth[4] * TILE) {
					obj.delete();
				}
				controlObjectCollision(obj);
			}
			break;
			case STATE::KILL:
			{
				obj.state = STATE::FLY;
			}
			break;
			case STATE::EXPLODE:
			default:
			{
				obj.state = STATE::FLY;
			}
			break;
		}
	}
	void onDraw(jjOBJ@ obj) {
		int angle = obj.direction >= 0 ? 950 : 425;
		jjDrawRotatedSprite(obj.xPos, obj.yPos, ANIM::FLAG, 0, 0, angle, 1.5, 1.5, SPRITE::MENUPLAYER);
		obj.particlePixelExplosion(1);
	}
	bool onObjectHit(jjOBJ@ obj, jjOBJ@ bullet, jjPLAYER@ player, int force) {
		return true;
	}
}

class IceBolt : jjBEHAVIORINTERFACE {
	int frame;
	IceBolt() {
		frame = 0;
	}
	void onBehave(jjOBJ@ obj) {
		switch(obj.state) {
			case STATE::START:
			{
				jjSample(obj.xPos, obj.yPos, SOUND::AMMO_ICEPU4);
				obj.direction = jjPlayers[obj.creatorID].direction;
				obj.xPos += obj.direction >= 0 ? 16 : -16;
				obj.state = STATE::FLY;
				obj.var[6] = 16;
				obj.light = 100;
			}
			break;
			case STATE::FLY:
			{
				for (int i = 0; i < 32; i++) {
					jjPLAYER@ play = jjPlayers[i];
					jjPLAYER@ caster = jjPlayers[obj.creatorID];
					Player@ asCaster = players[caster.playerID];
					if (abs(obj.xPos - play.xPos) <= 32 && abs(obj.yPos - play.yPos) <= 32) {
						if (obj.doesCollide(play, true) && caster.isEnemy(play)
								&& gameIsRunning()) {
							Spell@ spell = cast<Spell@>(spells["E"]);
							play.hurt(spell.baseDamage + asCaster.spellDamageBonus, false, caster);
							play.freeze();
						}
					}
				}
				obj.xPos += obj.direction >= 0 ? 10 : -10;
				if (obj.xPos < 0 || obj.xPos > jjLayerWidth[4] * TILE) {
					obj.delete();
				}
				controlObjectCollision(obj);
			}
			break;
			case STATE::KILL:
			{
				obj.state = STATE::FLY;
			}
			break;
			case STATE::EXPLODE:
			default:
			{
				obj.state = STATE::FLY;
			}
			break;
		}
	}
	void onDraw(jjOBJ@ obj) {
		int angle = obj.direction >= 0 ? 0 : 540;
		jjDrawRotatedSprite(obj.xPos, obj.yPos, ANIM::AMMO, 11, frame, angle, 2, 2, SPRITE::TINTED, 35);
		
		if (frame < 7) frame++;
		else frame = 0;
	}
	bool onObjectHit(jjOBJ@ obj, jjOBJ@ bullet, jjPLAYER@ player, int force) {
		return false;
	}
}

class Fireball : jjBEHAVIORINTERFACE {
	uint8 frame;
	Fireball() {
		frame = 0;
	}
	void onBehave(jjOBJ@ obj) {
		switch(obj.state) {
			case STATE::START:
			{
				jjSample(obj.xPos, obj.yPos, SOUND::AMMO_FIREGUN1A, 0, 7500);
				obj.direction = jjPlayers[obj.creatorID].direction;
				obj.xPos += obj.direction >= 0 ? TILE : -TILE;
				obj.state = STATE::FLY;
				obj.light = 125;
			}
			break;
			case STATE::FLY:
			{
				jjPLAYER@ caster = jjPlayers[obj.creatorID];
				
				for (int i = 0; i < 32; i++) {
					jjPLAYER@ play = jjPlayers[i];
					
					if (obj.doesCollide(play, true) && play !is caster) {
						explode(obj, caster);
					}
				}
				obj.xPos += obj.direction >= 0 ? 10 : -10;
				if (obj.xPos < 0 || obj.xPos > jjLayerWidth[4] * TILE) {
					obj.delete();
				}
				else if ((jjLayers[4].maskedVLine(int(obj.xPos+TILE), int(obj.yPos), 1))
						|| (jjLayers[4].maskedVLine(int(obj.xPos-TILE), int(obj.yPos), 1))) {
					explode(obj, caster);
				}
			}
			break;
		}
	}
	void onDraw(jjOBJ@ obj) {
		int angle = obj.direction >= 0 ? 0 : 540;
		jjDrawRotatedSprite(obj.xPos, obj.yPos, ANIM::AMMO, 14, frame, angle, 2, 2);
		if (frame < 7) frame++;
		else frame = 0;
	}
	void explode(jjOBJ@ obj, jjPLAYER@ caster) {
		doAOE(caster, SPELL_FIREBALL, obj.xPos, obj.yPos);
		obj.delete();
	}
	bool onObjectHit(jjOBJ@ obj, jjOBJ@ bullet, jjPLAYER@ player, int force) {
		return false;
	}
}

class StoneSkin : Effect {
	StoneSkin(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration);
	}
	void affect() override {}
}

class Slow : Effect {
	Slow(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration, bool setIsLocal, bool setIsCurse) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration, setIsLocal, setIsCurse);
	}
	void affect() override {
		if (play.xSpeed > SLOW_SPEED) {
			play.xSpeed = SLOW_SPEED;
		}
		else if (play.xSpeed < -SLOW_SPEED) {
			play.xSpeed = -SLOW_SPEED;
		}
	}
}

class Bless : Effect {
	Bless(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration);
	}
	void affect() override {}
}

class BloodLust : Effect {
	int originalFastFire;
	BloodLust(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration, bool setIsLocal) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration, setIsLocal);
		
		originalFastFire = setPlay.fastfire;
	}
	~BloodLust() {
		play.fastfire = originalFastFire;
	}
	void affect() override {
		play.fastfire = 6;
	}
}

class DisruptingRay : Effect {
	DisruptingRay(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration, bool setIsLocal, bool setIsCurse) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration, setIsLocal, setIsCurse);
	}
	void affect() override {}
}

class Weakness : Effect {
	Weakness(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration, bool setIsLocal, bool setIsCurse) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration, setIsLocal, setIsCurse);
	}
	void affect() override {}
}

class Precision : Effect {
	Precision(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration);
	}
	void affect() override {}
}

class Forgetfulness : Effect {
	Forgetfulness(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration, bool setIsLocal, bool setIsCurse) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration, setIsLocal, setIsCurse);
	}
	
	void affect() override {
		play.noFire = true;
	}
}

class Frenzy : Effect {
	Frenzy(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration);
	}
	void affect() override {}
}

class Visions : Effect {
	Visions(jjPLAYER@ setPlay, string setName, SPELL setEnumValue, SPELL setCounterEffect,
			uint setDuration, bool setIsLocal) {
		super(setPlay, setName, setEnumValue, setCounterEffect, setDuration, setIsLocal);
	}
	void affect() override {
		for (int i = 0; i < 32; i++) {
			jjPLAYER@ target = jjPlayers[i];
			jjDrawString(target.xPos - 12, target.yPos - 48, "" + target.health, STRING::SMALL, STRING::PALSHIFT, 24);
			jjDrawSprite(target.xPos + 12, target.yPos - 48, ANIM::PICKUPS, 41, 0);
		}
	}
}

class WallOfFire {
	int damage;
	int frame;
	int channel;
	int8 height;
	float xOrigin;
	float yOrigin;
	uint elapsed;
	uint duration;
	jjPLAYER@ caster;
	WallOfFire(float setXOrigin, float setYOrigin, int8 setHeight, int setDamage, uint setDuration, jjPLAYER@ setCaster) {
		xOrigin = setXOrigin;
		yOrigin = setYOrigin;
		height = setHeight;
		damage = setDamage;
		elapsed = duration = setDuration;
		@caster = setCaster;
		frame = 1;
		channel = 0;
		
		if (height > WALL_OF_FIRE_MAX_HEIGHT) {
			height = WALL_OF_FIRE_MAX_HEIGHT;
		}
	}
}

class DeathRipple : AreaOfEffect {
	int frame;
	float range;
	DeathRipple(float setXPos, float setYPos, float setRadius, int setDamage, uint setDuration, jjPLAYER@ setCaster) {
		super(setXPos, setYPos, setRadius, setDamage, setDuration, setCaster);
		frame = 0;
	}
	void animate() override {
		for (int i = 0; i < 32; i++) {
			jjPLAYER@ play = jjPlayers[i];
			if (playerInRange(play, false)) {
				jjDrawRotatedSprite(play.xPos, play.yPos, ANIM::AMMO, 66, frame, 0, 2, 2, SPRITE::SINGLECOLOR, 24);
			}
		}
		if (jjGameTicks % 5 == 0) {
			if (frame < 8) {
				frame++;
			}
			else {
				frame = 0;
			}
		}
	}
	void control(jjPLAYER@ play) override {
		if (playerInRange(play, true)) {
			play.hurt(damage, false, caster);
		}
	}
	bool playerInRange(jjPLAYER@ play, bool vulnerable) {
		float scaledRadius = getScaledRadius(radius, ANIM::FLARE, 5);
		float xDistance = play.xPos - xPos;
		float yDistance = play.yPos - yPos;
		
		if (vulnerable) {
			return xDistance*xDistance + yDistance*yDistance < scaledRadius*scaledRadius && gameIsRunning()
				&& checkIfPlayerIsVulnerable(caster, play, cast<Spell@>(spells["H"]));
		}
		return xDistance*xDistance + yDistance*yDistance < scaledRadius*scaledRadius;
	}
}

class FireballExplosion : AreaOfEffect {
	int frame;
	FireballExplosion(float setXPos, float setYPos, float setRadius, int setDamage, uint setDuration, jjPLAYER@ setCaster) {
		super(setXPos, setYPos, setRadius, setDamage, setDuration, setCaster);
		frame = 0;
	}
	void animate() override {
		jjDrawResizedSprite(xPos, yPos, ANIM::AMMO, 81, frame, radius, radius, SPRITE::NORMAL, 0, 1);
		
		if (jjGameTicks % 7 == 0) {
			if (frame < 11) {
				frame++;
			}
		}
	}
	void control(jjPLAYER@ play) override {
		float scaledRadius = getScaledRadius(radius, ANIM::AMMO, frame, 81);
		float xDistance = play.xPos - xPos;
		float yDistance = play.yPos - yPos;
		
		if (xDistance*xDistance + yDistance*yDistance < scaledRadius*scaledRadius && gameIsRunning()) {
			play.hurt(damage, false, caster);
		}
	}
}

class Armageddon : AreaOfEffect {
	int frame;
	float meteorY;
	bool exploding;
	Armageddon(float setXPos, float setYPos, float setRadius, int setDamage, uint setDuration, jjPLAYER@ setCaster) {
		super(setXPos, setYPos, setRadius, setDamage, setDuration, setCaster);
		meteorY = setYPos - 640;
		exploding = false;
		frame = 4;
	}
	void animate() override {
		if (exploding) {
			jjDrawResizedSprite(xPos, yPos, ANIM::AMMO, 81, frame, 14, 14, SPRITE::NORMAL, 0, 1);
			
			if (jjGameTicks % 7 == 0) {
				if (frame < 11) {
					frame++;
				}
			}
		}
		else {
			jjDrawRotatedSprite(xPos, meteorY, ANIM::ROCK, 0, 0, 0, 5, 5, SPRITE::TINTED, 24, 1);
		}
		
		if (meteorY < yPos) {
			meteorY += 32;
		}
		else if (!exploding) {
			exploding = true;
			jjSamplePriority(SOUND::INTRO_BOEM2);
		}
	}
	void control(jjPLAYER@ play) override {
		if (playerInRange(play, true) && exploding) {
			play.hurt(damage, false, caster);
		}
	}
	bool playerInRange(jjPLAYER@ play, bool vulnerable) {
		float scaledRadius = getScaledRadius(radius, ANIM::FLARE, 5);
		float xDistance = play.xPos - xPos;
		float yDistance = play.yPos - yPos;
		
		if (vulnerable) {
			return xDistance*xDistance + yDistance*yDistance < scaledRadius*scaledRadius && gameIsRunning()
				&& checkIfPlayerIsVulnerable(caster, play, cast<Spell@>(spells["A"]));
		}
		return xDistance*xDistance + yDistance*yDistance < scaledRadius*scaledRadius;
	}
}

class Implosion : AreaOfEffect {
	float scale;
	int8[] targets;
	bool finished;
	Implosion(float setXPos, float setYPos, float setRadius, int setDamage, uint setDuration, jjPLAYER@ setCaster) {
		super(setXPos, setYPos, setRadius, setDamage, setDuration, setCaster);
		scale = 5;
		finished = false;
		
		for (int i = 0; i < 32; i++) {
			jjPLAYER@ play = jjPlayers[i];
			float scaledRadius = getScaledRadius(radius, ANIM::FLARE, 5);
			Spell@ spell = cast<Spell@>(spells["Q"]);
			
			if (checkIfPlayerIsInRange(setCaster, play, scaledRadius) && checkIfPlayerIsVulnerable(caster, play, spell)
					&& gameIsRunning() && targets.find(play.playerID) < 0) {
				if (debugModeOn) jjAlert("" + play.playerID);
				targets.insertLast(play.playerID);
			}
		}
	}
	void animate() override {
		for (uint i = 0; i < targets.length; i++) {
			jjPLAYER@ play = jjPlayers[targets[i]];
			jjDrawRotatedSprite(play.xPos, play.yPos, ANIM::BOLLPLAT, 0, 0, 0, scale, scale, SPRITE::SINGLECOLOR, 1);
		}
		if (scale > 0) {
			scale -= 0.1;
		}
		else if (!finished) {
			finished = true;
			for (uint i = 0; i < targets.length; i++) {
				jjPLAYER@ play = jjPlayers[targets[i]];
				
				if (play.isLocal) {
					play.hurt(damage, true, caster);
					jjSamplePriority(SOUND::SMALTREE_GROUND);
				}
			}
		}
	}
	void control(jjPLAYER@ play) override {}
	bool playerInRange(jjPLAYER@ play, bool vulnerable) {
		float scaledRadius = getScaledRadius(radius, ANIM::FLARE, 5);
		float xDistance = play.xPos - xPos;
		float yDistance = play.yPos - yPos;
		
		if (vulnerable) {
			return xDistance*xDistance + yDistance*yDistance < scaledRadius*scaledRadius && gameIsRunning()
				&& checkIfPlayerIsVulnerable(caster, play, cast<Spell@>(spells["Q"]));
		}
		return xDistance*xDistance + yDistance*yDistance < scaledRadius*scaledRadius;
	}
}

class ChainLightningTarget {
	int8 id;
	int8 parentID;
	int8 playerID;
	int8 damage;
	uint elapsed;
	uint duration;
	ChainLightningTarget(int8 setID, int8 setPlayerID, int8 setDamage, uint setDuration, int8 setParentID = -1) {
		id = setID;
		playerID = setPlayerID;
		damage = setDamage;
		duration = setDuration;
		elapsed = setDuration;
		parentID = setParentID;
	}
}

bool gameIsRunning() {
	return jjGameState == GAME::STARTED || jjGameState == GAME::OVERTIME;
}

int getDamageModifier(Player@ attacker, Player@ victim) {
	int damageModifier = 0;
	
	for (uint i = 0; i < attacker.activeEffects.length; i++) {
		SPELL effect = attacker.activeEffects[i].enumValue;
		damageModifier += getDamageModifierByAttacker(effect);
	}
	
	for (uint i = 0; i < victim.activeEffects.length; i++) {
		SPELL effect = victim.activeEffects[i].enumValue;
		damageModifier += getDamageModifierByVictim(effect);
	}
	
	return damageModifier;
}

int getDamageModifierByAttacker(SPELL effect) {
	switch (effect) {
		case SPELL_BLESS: return 1;
		case SPELL_WEAKNESS: return -1;
		case SPELL_PRECISION: return 2;
		case SPELL_FRENZY: return 4;
	}
	return 0;
}

int getDamageModifierByVictim(SPELL effect) {
	switch (effect) {
		case SPELL_STONE_SKIN: return -1;
		case SPELL_DISRUPTING_RAY: return 2;
		case SPELL_FRENZY: return 4;
	}
	return 0;
}

int getWallOfFireHeightInTiles(float xOrigin, float yOrigin) {
	int height = 0;
	for (float x = xOrigin; x < xOrigin + 64; x++) {
		if (!jjMaskedHLine(int(x), 1, int(yOrigin+8))) {
			return 0;
		}
	}
	for (float y = yOrigin; y > yOrigin - WALL_OF_FIRE_MAX_HEIGHT*TILE; y--) {
		for (float x = xOrigin; x < xOrigin + 64; x++) {
			if (jjMaskedHLine(int(x), 1, int(y-TILE))) {
				return int(height/TILE);
			}
		}
		height++;
	}
	return int(height/TILE);
}

int getBulletPointerDistance(float xOrigin, float yOrigin, int direction) {
	int distance = 0;
	if (direction >= 0) {
		for (float x = xOrigin; x < xOrigin+BULLET_POINTER_MAX_DISTANCE*TILE; x++) {
			if (jjMaskedVLine(int(x+TILE), int(yOrigin), 1)) {
				return distance;
			}
			distance++;
		}
	}
	else {
		for (float x = xOrigin; x > xOrigin-BULLET_POINTER_MAX_DISTANCE*TILE; x--) {
			if (jjMaskedVLine(int(x-TILE), int(yOrigin), 1)) {
				return distance;
			}
			distance++;
		}
	}
	return distance;
}

int getSpellIndexByKey(string selectedSpellKey) {
	if (selectedSpellKey == "" && ownSpells.length > 0) {
		return -1;
	}
	for (uint i = 0; i < ownSpells.length; i++) {
		Spell@ spell = cast<Spell@>(spells[ownSpells[i]]);
		if (spell.key == selectedSpellKey) {
			return int(i);
		}
	}
	return -2;
}

uint getTierChanceByRoasts(int roasts) {
	uint chance = 5;
	if (jjMaxScore <= SHORT_GAME_THRESHOLD) {
		chance = roasts;
	}
	else {
		chance = uint(roasts / 2);
	}
	
	if (debugModeOn) {
		jjAlert("roasts: " + roasts);
		jjAlert("chance: " + chance);
	}
	
	if (chance < 1) {
		chance = 1;
	}
	else if (chance > 5) {
		chance = 5;
	}
	
	return chance;
}

int getRoastsRequiredByTier(uint tierChance) {
	if (jjMaxScore <= SHORT_GAME_THRESHOLD) {
		return int(tierChance);
	}
	return int(tierChance * 2);
}

void setSkillBonus(Player@ asPlayer, SKILL skill, int8 playerID) {
	switch (skill) {
		case SKILL_MAX_MANA:
		{
			asPlayer.maxMana += 100;
		}
		break;
		case SKILL_MANA_REGEN:
		{
			asPlayer.manaRegenRate++;
		}
		break;
		case SKILL_SPELL_DAMAGE:
		{
			sendSkillPacket(playerID, PACKET_SKILL_DAMAGE);
			if (jjIsServer) asPlayer.spellDamageBonus++;
		}
		break;
		case SKILL_SPELL_DURATION:
		{
			sendSkillPacket(playerID, PACKET_SKILL_DURATION);
			if (jjIsServer) asPlayer.spellDurationBonus += 10;
		}
		break;
		case SKILL_SPELL_COST:
		{
			asPlayer.spellCostBonus++;
		}
		break;
	}
}

void setHotkey(int selection, uint keyIndex, bool overwrite = false) {
	if (!overwrite) {
		array<int> hotkeys = {hotkeyInfo, hotkeySkills, hotkeyCycleSpells, hotkeySpellbook, hotkeyKeyMenu, hotkeyResources};
		if (hotkeys.find(keyIndex) >= 0) {
			hotkeyInUse = true;
			return;
		}
	}
	
	switch (selection) {
		case 0:
		{
			hotkeyInfo = keyIndex;
		}
		break;
		case 1:
		{
			hotkeySkills = keyIndex;
		}
		break;
		case 2:
		{
			hotkeyCycleSpells = keyIndex;
		}
		break;
		case 3:
		{
			hotkeySpellbook = keyIndex;
		}
		break;
		case 4:
		{
			hotkeyKeyMenu = keyIndex;
		}
		break;
		case 5:
		{
			hotkeyResources = keyIndex;
		}
		break;
	}
	if (!overwrite) {
		saveHotkeyToFile(selection, keyIndex);
	}
	hotkeyInUse = false;
	loadKeyBindings();
}

void saveHotkeyToFile(int selection, uint keyIndex) {
	jjSTREAM file(HOTKEY_FILENAME);
	file.push(selection);
	file.push(keyIndex);
	file.save(HOTKEY_FILENAME);
}

void loadHotKeysFromFile() {
	jjSTREAM file(HOTKEY_FILENAME);
	while (!file.isEmpty()) {
		int selection;
		uint keyIndex;
		file.pop(selection);
		file.pop(keyIndex);
		setHotkey(selection, keyIndex, true);
	}
}

void loadChunks() {
	chunks = array<array<Chunk>>(int(ceil(float(jjLayerHeight[4]) / 4)), array<Chunk>(int(ceil(float(jjLayerWidth[4]) / 4))));
	if (debugModeOn) jjAlert("height: " + chunks.length);
	if (debugModeOn) jjAlert("width: " + chunks[0].length);
}

void loadPlayerIDs() {
	for (int8 i = 0; i < 32; i++) {
		players[i].playerID = i;
	}
}

void loadKeys() {
	keys[65] = Key(65, "A");
	keys[66] = Key(66, "B");
	keys[67] = Key(67, "C");
	keys[68] = Key(68, "D");
	keys[69] = Key(69, "E");
	keys[70] = Key(70, "F");
	keys[71] = Key(71, "G");
	keys[72] = Key(72, "H");
	keys[73] = Key(73, "I");
	keys[74] = Key(74, "J");
	keys[75] = Key(75, "K");
	keys[76] = Key(76, "L");
	keys[77] = Key(77, "M");
	keys[78] = Key(78, "N");
	keys[79] = Key(79, "O");
	keys[80] = Key(80, "P");
	keys[81] = Key(81, "Q");
	keys[82] = Key(82, "R");
	keys[83] = Key(83, "S");
	keys[84] = Key(84, "T");
	keys[85] = Key(85, "U");
	keys[86] = Key(86, "V");
	keys[87] = Key(87, "W");
	keys[88] = Key(88, "X");
	keys[89] = Key(89, "Y");
	keys[90] = Key(90, "Z");
	
	loadKeyBindings();
	loadHotKeysFromFile();
	
	jjAlert("The current hotkeys are:");
	for (uint i = 0; i < keyBindings.length; i++) {
		jjAlert(keyBindings[i]);
	}
	jjAlert("Open Key menu to change them.");
}

void loadKeyBindings() {
	keyBindings.removeRange(0, keyBindings.length);
	keyBindings.insertLast("Information: " + getNameByKeyCode(hotkeyInfo));
	keyBindings.insertLast("Skills: " + getNameByKeyCode(hotkeySkills));
	keyBindings.insertLast("Cycle spell: " + getNameByKeyCode(hotkeyCycleSpells));
	keyBindings.insertLast("Spellbook: " + getNameByKeyCode(hotkeySpellbook));
	keyBindings.insertLast("Key menu: " + getNameByKeyCode(hotkeyKeyMenu));
	keyBindings.insertLast("Hide resources: " + getNameByKeyCode(hotkeyResources));
}

void loadSprites() {
	jjAnimSets[ANIM::BIGROCK].load();
	jjAnimSets[ANIM::BOLLPLAT].load();
	jjAnimSets[ANIM::BRIDGE].load();
	jjAnimSets[ANIM::FLARE].load();
	jjAnimSets[ANIM::ROCK].load();
	jjAnimSets[ANIM::SKELETON].load();
	jjAnimSets[ANIM::SONCSHIP].load();
	jjAnimSets[ANIM::TURTLE].load();
	jjAnimSets[ANIM::VINE].load();
	jjAnimSets[ANIM::WITCH].load();
}

void loadBullets() {
	for (uint i = OBJECT::BLASTERBULLET; i < OBJECT::BULLET; i++) {
		jjObjectPresets[i].behavior = AbstractBullet(jjObjectPresets[i].behavior, jjObjectPresets[i].playerHandling);
	}
}

array<string> loadSpells() {
	array<string> spellKeys = spells.getKeys();
	return sortSpellKeys(false);
}

void updateSpellDescriptions(array<string> spellKeys, Player@ asPlayer) {
	for (uint i = 0; i < spellKeys.length; i++) {
		string key = spellKeys[i];
		Spell@ spell = cast<Spell@>(spells[key]);
		string descriptionWithDmg = jjRegexReplace(spell.description, "\\{dmg\\}",
				"" + (spell.baseDamage + asPlayer.spellDamageBonus));
		spell.description = jjRegexReplace(descriptionWithDmg, "\\{dur\\}",
				"" + (spell.baseDuration + asPlayer.spellDurationBonus));
	}
}

void loadOthers() {
	jjObjectPresets[OBJECT::ONEUP].behavior = function(obj){obj.behavior = SpellScroll(obj);};
	jjObjectPresets[OBJECT::ONEUP].scriptedCollisions = true;
	jjUseLayer8Speeds = true;
}

void flipVerticalTiles() {
	for (int x = 0; x < jjLayerWidth[4]; x++) {
		for (int y = 0; y < jjLayerHeight[4]; y++) {
		  uint16 tile = jjTileGet(4, x, y);
			   
		  if (jjEventGet(x, y) == VERTICAL_FLIP_EVENT_ID) jjTileSet(4, x, y, tile ^ TILE::VFLIPPED);
		}
	}
}

void disableDemoMode() {
	ownSpells.removeRange(0, ownSpells.length);
	for (uint i = 0; i < 32; i++) {
		players[i].unSetChanneledSpellKey();
	}
}

array<string> sortSpellKeys(bool withSpellCount = true) {
	array<string> sortedKeys;
	
	array<Spell@> spellArray;
	dictionary spellCounts;
	array<string> spellKeys = spells.getKeys();
	
	if (withSpellCount) {
		spellCounts = getSpellCounts();
		spellKeys = spellCounts.getKeys();
	}
	for (uint i = 0; i < spellKeys.length; i++) {
		string key = spellKeys[i];
		if (withSpellCount) {
			uint spellCount = uint(spellCounts[key]);
			for (uint j = 0; j < spellCount; j++) {
				spellArray.insertLast(cast<Spell@>(spells[spellKeys[i]]));
			}
		}
		else {
			spellArray.insertLast(cast<Spell@>(spells[spellKeys[i]]));
		}
	}
	spellArray.sortAsc();
	
	for (uint i = 0; i < spellArray.length; i++) {
		sortedKeys.insertLast(spellArray[i].key);
	}
	
	return sortedKeys;
}

void removeSpells() {
	ownSpells.removeRange(0, ownSpells.length);
}

dictionary getSpellCounts() {
	dictionary spellCounts;
	array<string> spellKeys = spells.getKeys();
	
	for (uint i = 0; i < spellKeys.length; i++) {
		string key = spellKeys[i];
		Spell@ spell = cast<Spell@>(spells[key]);
		int count = 0;
		
		for (uint j = 0; j < ownSpells.length; j++) {
			if (spell.key == ownSpells[j]) {
				count++;
			}
		}
		
		if (count > 0) {
			spellCounts.set(spell.key, count);
		}
	}
	
	return spellCounts;
}

uint getTotalSpellCountByTier(uint tier) {
	uint spellCount = 0;
	array<string> spellKeys = spells.getKeys();
	
	for (uint i = 0; i < spellKeys.length; i++) {
		string key = spellKeys[i];
		Spell@ spell = cast<Spell@>(spells[key]);
		if (spell.tier == tier) spellCount++;
	}
	
	return spellCount;
}

string getLongestSpellName() {
	string longestName = "";
	dictionary spellCounts = getSpellCounts();
	for (uint i = 0; i < ownSpells.length; i++) {
		string spellName = cast<Spell@>(spells[ownSpells[i]]).name;
		uint spellCount = uint(spellCounts[ownSpells[i]]);
		string fullName = ownSpells[i] + "   " + spellName + " (" + spellCount + ")      ";
		if (fullName.length > longestName.length) longestName = fullName;
	}
	if (longestName.length == 0) longestName = EMPTY_SPELLBOOK;
	return longestName;
}

string getSpellKeyByEnumValue(uint8 spellEnumValue) {
	string spellKey = "";
	array<string> spellKeys = spells.getKeys();
	for (uint i = 0; i < spellKeys.length; i++) {
		string key = spellKeys[i];
		Spell@ spell = cast<Spell@>(spells[key]);
		if (SPELL(spellEnumValue) == spell.enumValue) {
			spellKey = key;
			break;
		}
	}
	return spellKey;
}

float getScaledRadius(float radius, ANIM::Set animSet, int frameCount, int animCount = 0) {
	jjANIMFRAME@ sprite = jjAnimFrames[jjAnimations[jjAnimSets[animSet].firstAnim + animCount].firstFrame + frameCount];
	return float(sprite.width * radius / 2);
}

int8[] getTargets(jjPLAYER@ source, Spell@ spell, float scaledRadius) {
	int8[] targets;
	for (int8 i = 0; i < 32; i++) {
		if (jjPlayers[i].isInGame) {
			jjPLAYER@ potentialTarget = jjPlayers[i];
			bool inRange = checkIfPlayerIsInRange(source, potentialTarget, scaledRadius);
			if (inRange) {
				bool isVulnerable = checkIfPlayerIsVulnerable(source, potentialTarget, spell);
				if (isVulnerable) {
					targets.insertLast(i);
				}
			}
		}
	}
	
	return targets;
}

void gainFreeSpell() {
	string gainedSpellName = pickUpScroll(1);
	jjAlert("You have been granted a free spell scroll of " + gainedSpellName);
}

Spell@ giveRandomSpellByTier(uint tierChance, uint minimumTier = 1) {
	array<Spell@> spellsInRange;
	array<string> spellKeys = spells.getKeys();
	for (uint i = 0; i < spellKeys.length; i++) {
		string key = spellKeys[i];
		Spell@ spell = cast<Spell@>(spells[key]);
		
		if (spell.tier <= tierChance && spell.tier >= minimumTier) {
			spellsInRange.insertLast(spell);
		}
	}
	return spellsInRange[jjRandom() % spellsInRange.length]; 
}

string pickUpScroll(uint tierChance) {
	Spell@ gainedSpell = giveRandomSpellByTier(tierChance);
	ownSpells.insertLast(gainedSpell.key);
	ownSpells = sortSpellKeys();
	return gainedSpell.name;
}

bool canCastSpell(Player@ asPlayer, Spell@ spell) {
	if (demoModeOn || (asPlayer.mana >= spell.baseManaCost / asPlayer.spellCostBonus && asPlayer.cooldown <= 0)) {
		return true;
	}
	return false;
}

void channel(jjPLAYER@ play) {
	string selectedSpellKey = players[play.playerID].selectedSpellKey;
	if (channelingElapsed < cast<Spell@>(spells[selectedSpellKey]).channelingTime * SECOND) {
		channelingElapsed++;
	}
	else {
		channelingElapsed = 0;
		castSpell(play, selectedSpellKey);
	}
}

void castSpell(jjPLAYER@ play, string key) {
	Spell@ spell = cast<Spell@>(spells[key]);
	Player@ asPlayer = players[play.playerID];
	asPlayer.unSetChanneledSpellKey();
	sendChannelingStoppedPacket(play.playerID);
	channelingStarted = false;
	recovering = true;
	
	if (!demoModeOn) {
		asPlayer.cooldown = spell.tier * SECOND;
	}
	
	if (doSpell(key)) {
		int index = ownSpells.find(key);
		if (index >= 0 && !demoModeOn) {
			asPlayer.mana -= spell.baseManaCost / asPlayer.spellCostBonus;
			ownSpells.removeAt(uint(index));
		}
	}
	else {
		if (key == "I") {
			jjAlert("Can't cast wall of fire here. Try on a flat land on a more open area.");
		}
		else {
			jjAlert("That spell affected no one!");
		}
	}
}

bool doSpell(string key) {
	jjPLAYER@ caster = jjLocalPlayers[0];
	Player@ asPlayer = players[caster.playerID];
	int8 playerID = caster.playerID;
	Spell@ spell = cast<Spell@>(spells[key]);
	uint spellDuration = spell.baseDuration + asPlayer.spellDurationBonus;
	
	switch (spell.enumValue) {
		case SPELL_MAGIC_ARROW:
		{
			doBullet(BULLET_MAGIC_ARROW);
		}
		break;
		case SPELL_STONE_SKIN:
		{
			sendEffectPacket(playerID, SPELL_STONE_SKIN, playerID, spellDuration);
			if (jjIsServer) {
				doEffect(SPELL_STONE_SKIN, playerID, spellDuration);
			}
		}
		break;
		case SPELL_BLESS:
		{
			sendEffectPacket(playerID, SPELL_BLESS, playerID, spellDuration);
			if (jjIsServer) {
				doEffect(SPELL_BLESS, playerID, spellDuration);
			}
		}
		break;
		case SPELL_BLOOD_LUST:
		{
			sendEffectPacket(playerID, SPELL_BLOOD_LUST, playerID, spellDuration);
			if (jjIsServer) {
				doEffect(SPELL_BLOOD_LUST, playerID, spellDuration);
			}
			jjPlayers[playerID].startSugarRush(spellDuration * 70);
		}
		break;
		case SPELL_DISPEL:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			sendMassEffectPacket(playerID, SPELL_DISPEL, targets, spellDuration);
			if (jjIsServer) {
				doMassEffect(SPELL_DISPEL, targets, spellDuration);
			}
		}
		break;
		case SPELL_SLOW:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			sendMassEffectPacket(playerID, SPELL_SLOW, targets, spellDuration);
			if (jjIsServer) {
				doMassEffect(SPELL_SLOW, targets, spellDuration);
			}
		}
		break;
		case SPELL_CURE:
		{
			sendEffectPacket(playerID, SPELL_CURE, playerID, spellDuration);
			if (jjIsServer) {
				doEffect(SPELL_CURE, playerID, spellDuration);
			}
			if (caster.health < jjMaxHealth) {
				int distance = caster.health - jjMaxHealth;
				caster.hurt(distance, true);
			}
		}
		break;
		case SPELL_VISIONS:
		{
			sendEffectPacket(playerID, SPELL_VISIONS, playerID, spellDuration);
			if (jjIsServer) {
				doEffect(SPELL_VISIONS, playerID, spellDuration);
			}
		}
		break;
		case SPELL_DISRUPTING_RAY:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			sendMassEffectPacket(playerID, SPELL_DISRUPTING_RAY, targets, spellDuration);
			if (jjIsServer) {
				doMassEffect(SPELL_DISRUPTING_RAY, targets, spellDuration);
			}
		}
		break;
		case SPELL_WEAKNESS:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			sendMassEffectPacket(playerID, SPELL_WEAKNESS, targets, spellDuration);
			if (jjIsServer) {
				doMassEffect(SPELL_WEAKNESS, targets, spellDuration);
			}
		}
		break;
		case SPELL_ICE_BOLT:
		{
			doBullet(BULLET_ICE_BOLT);
		}
		break;
		case SPELL_DEATH_RIPPLE:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			sendAOEPacket(playerID, spell.enumValue);
			
			if (jjIsServer) {
				doAOE(caster, spell.enumValue, caster.xPos, caster.yPos);
			}
		}
		break;
		case SPELL_PRECISION:
		{
			sendEffectPacket(playerID, SPELL_PRECISION, playerID, spellDuration);
			if (jjIsServer) {
				doEffect(SPELL_PRECISION, playerID, spellDuration);
			}
		}
		break;
		case SPELL_WALL_OF_FIRE:
		{
			int direction = caster.direction;
			float xOrigin = caster.xPos + direction * WALL_OF_FIRE_CASTING_RANGE;
			float yOrigin = caster.yPos + 16;
			int height = getWallOfFireHeightInTiles(xOrigin, yOrigin);
			if (height < 1) {
				return false;
			}
			
			sendWallOfFirePacket(playerID, xOrigin, yOrigin, height);
			if (jjIsServer) {
				uint duration = (spell.baseDuration + asPlayer.spellDurationBonus) * SECOND;
				doWallOfFire(caster, spell, xOrigin, yOrigin, height, duration);
			}
		}
		break;
		case SPELL_FORGETFULNESS:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			sendMassEffectPacket(playerID, SPELL_FORGETFULNESS, targets, spellDuration);
			if (jjIsServer) {
				doMassEffect(SPELL_FORGETFULNESS, targets, spellDuration);
			}
		}
		break;
		case SPELL_FIREBALL:
		{
			doBullet(BULLET_FIREBALL);
		}
		break;
		case SPELL_FRENZY:
		{
			sendEffectPacket(playerID, SPELL_FRENZY, playerID, spellDuration);
			if (jjIsServer) {
				doEffect(SPELL_FRENZY, playerID, spellDuration);
			}
		}
		break;
		case SPELL_CHAIN_LIGHTNING:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			playLightningSound(caster.xPos, caster.yPos);
			
			if (jjIsServer) {
				array<ChainLightningTarget@> chainLightningTargets = doChainLightning(playerID, targets);
				sendChainLightningPacketToClients(playerID, chainLightningTargets);
				chainLightningTargetGroups.insertLast(chainLightningTargets);
			} else {
				sendChainLightningPacketToServer(playerID, targets);
			}
		}
		break;
		case SPELL_ARMAGEDDON:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			sendAOEPacket(playerID, spell.enumValue);
			
			if (jjIsServer) {
				doAOE(caster, spell.enumValue, caster.xPos, caster.yPos);
			}
		}
		break;
		case SPELL_IMPLOSION:
		{
			float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
			int8[] targets = getTargets(caster, spell, scaledRadius);
			
			if (targets.length == 0) return false;
			
			sendAOEPacket(playerID, spell.enumValue);
			
			if (jjIsServer) {
				doAOE(caster, spell.enumValue, caster.xPos, caster.yPos);
			}
		}
		break;
	}
	return true;
}

void doBullet(int bulletID) {
	jjPLAYER@ play = jjLocalPlayers[0];
	int originalAmmo = play.ammo[WEAPON::BLASTER];
	play.ammo[WEAPON::BLASTER] = bulletID;
	int id = play.fireBullet(WEAPON::BLASTER);
	jjObjects[id].behave(BEHAVIOR::DEFAULT, false); //behave once to read bullet count
	play.ammo[WEAPON::BLASTER] = originalAmmo;
}

void changePlayerFur(uint8 effectID, int8 playerID) {
	array<uint8> furArray;
	jjPLAYER@ play = jjPlayers[playerID];
	Player@ asPlayer = players[playerID];
	bool change = true;
	switch (effectID) {
		case SPELL_STONE_SKIN:
		{
			furArray = FUR_STONE_SKIN;
		}
		break;
		case SPELL_BLESS:
		{
			furArray = FUR_BLESS;
		}
		break;
		case SPELL_SLOW:
		{
			furArray = FUR_SLOW;
		}
		break;
		case SPELL_DISRUPTING_RAY:
		{
			furArray = FUR_DISRUPTING_RAY;
		}
		break;
		case SPELL_WEAKNESS:
		{
			furArray = FUR_WEAKNESS;
		}
		break;
		case SPELL_PRECISION:
		{
			furArray = FUR_PRECISION;
		}
		break;
		case SPELL_FORGETFULNESS:
		{
			furArray = FUR_FORGETFULNESS;
		}
		break;
		case SPELL_FRENZY:
		{
			furArray = FUR_FRENZY;
		}
		break;
		case SPELL_UNDO:
		{
			if (asPlayer.activeEffects.length >= 1) { //If there are other active effects, change to the latest
				Effect@ effect = asPlayer.activeEffects[asPlayer.activeEffects.length - 1];
				changePlayerFur(effect.enumValue, playerID);
			}
			else {
				play.fur = asPlayer.originalFur;
			}
			change = false;
		}
		break;
		case SPELL_NONE:
		default:
		{
			if (asPlayer.activeEffects.length >= 1) { //If there are any active effects, change to the original
				play.fur = asPlayer.originalFur;
			}
			change = false;
		}
		break;
	}
	if (change) {
		play.furSet(furArray[0], furArray[1], furArray[2], furArray[3]);
	}
}

void removeWallOfFireByCaster(jjPLAYER@ caster) {
	for (uint i = 0; i < activeWallsOfFire.length; i++) {
		WallOfFire@ wall = activeWallsOfFire[i];
		if (@caster is wall.caster) {
			activeWallsOfFire.removeAt(i);
		}
	}
}

void doWallOfFire(jjPLAYER@ caster, Spell@ spell, float xOrigin, float yOrigin, int8 height, uint duration) {
	Player@ asPlayer = players[caster.playerID];
	removeWallOfFireByCaster(caster);
	WallOfFire wallOfFire(xOrigin, yOrigin, height, spell.baseDamage + asPlayer.spellDamageBonus, duration, caster); //TODO: CHANGE TO VARIABLE
	jjSample(caster.xPos, caster.yPos, SOUND::DEVILDEVAN_DRAGONFIRE);
	activeWallsOfFire.insertLast(wallOfFire);
}

void doAOE(jjPLAYER@ caster, uint8 spellID, float xOrigin, float yOrigin) {
	string spellKey = getSpellKeyByEnumValue(spellID);
	Player@ asPlayer = players[caster.playerID];
	Spell@ spell = cast<Spell@>(spells[spellKey]);
	int damage = spell.baseDamage + asPlayer.spellDamageBonus;
	
	switch (spellID) {
		case SPELL_DEATH_RIPPLE:
		{
			jjSample(xOrigin, yOrigin, SOUND::COMMON_HEAD);
			activeAreasOfEffect.insertLast(DeathRipple(
					xOrigin, yOrigin, spell.radius, damage, 35, caster));
		}
		break;
		case SPELL_FIREBALL:
		{
			jjSample(xOrigin, yOrigin, SOUND::COMMON_BENZIN1);
			jjSample(xOrigin, yOrigin, SOUND::DEVILDEVAN_DRAGONFIRE);
			activeAreasOfEffect.insertLast(FireballExplosion(
					xOrigin, yOrigin, spell.radius, damage, 70, caster));
		}
		break;
		case SPELL_ARMAGEDDON:
		{
			jjSample(xOrigin, yOrigin, SOUND::INTRO_BOEM1);
			activeAreasOfEffect.insertLast(Armageddon(
					xOrigin, yOrigin, spell.radius, damage, 70, caster));
		}
		break;
		case SPELL_IMPLOSION:
		{
			activeAreasOfEffect.insertLast(Implosion(
					xOrigin, yOrigin, spell.radius, damage, 70, caster));
		}
		break;
	}
}

array<ChainLightningTarget@> doChainLightning(int8 casterID, int8[] targets) {
	Player@ asPlayer = players[casterID];
	Spell@ spell = cast<Spell@>(spells["L"]);
	array<ChainLightningTarget@> chainLightningTargets;
	int damage = spell.baseDamage + asPlayer.spellDamageBonus;
	
	for (uint i = 0; i < targets.length; i++) {
		int8 targetPlayerID = targets[i];
		chainLightningTargets.insertLast(ChainLightningTarget(
				chainLightningCounter, targetPlayerID, damage, CHAIN_LIGHTNING_DEFAULT_DURATION));
		chainLightningCounter++;
	}
	
	for (uint i = 0; i < chainLightningTargets.length; i++) {
		int8 targetPlayerID = chainLightningTargets[i].playerID;
		
		float scaledRadius = getScaledRadius(spell.radius, ANIM::FLARE, 5);
		int8[] newTargets = getTargets(jjPlayers[targetPlayerID], spell, scaledRadius);
		
		for (uint j = 0; j < newTargets.length; j++) {
			bool found = findFromChainLightningTargets(chainLightningTargets, newTargets[j]);
			
			if (!found) {
				chainLightningTargets.insertLast(ChainLightningTarget(chainLightningCounter, newTargets[j], int8(damage / 2),
						CHAIN_LIGHTNING_DEFAULT_DURATION, chainLightningTargets[i].id));
				chainLightningCounter++;
			}
		}
	}
	
	for (uint i = 0; i < chainLightningTargets.length; i++) {
		if (gameIsRunning()) {
			jjPlayers[chainLightningTargets[i].playerID].hurt(chainLightningTargets[i].damage, false, jjPlayers[casterID]);
		}
	}
	
	return chainLightningTargets;
}

void doEffect(uint8 effectID, int8 targetPlayerID, uint duration, bool multiplyDuration = true) {
	jjPLAYER@ play = jjPlayers[targetPlayerID];
	Player@ asPlayer = players[targetPlayerID];
	if (multiplyDuration) duration *= SECOND;
	
	Effect @effect;
	bool affects = true;
	
	switch (effectID) {
		case SPELL_STONE_SKIN:
		{
			Spell@ spell = cast<Spell@>(spells["K"]);
			@effect = StoneSkin(play, "Stone Skin", spell.enumValue, spell.counterSpell, duration);
			jjSample(play.xPos, play.yPos, SOUND::ORANGE_BOEML);
			jjSample(play.xPos, play.yPos, SOUND::ORANGE_BOEMR);
		}
		break;
		case SPELL_BLESS:
		{
			Spell@ spell = cast<Spell@>(spells["B"]);
			@effect = Bless(play, "Bless", spell.enumValue, spell.counterSpell, duration);
			jjSample(play.xPos, play.yPos, SOUND::ORANGE_SWEEP0R);
		}
		break;
		case SPELL_BLOOD_LUST:
		{
			Spell@ spell = cast<Spell@>(spells["O"]);
			@effect = BloodLust(play, "Blood Lust", spell.enumValue, spell.counterSpell, duration, true);
		}
		break;
		case SPELL_SLOW:
		{
			Spell@ spell = cast<Spell@>(spells["S"]);
			@effect = Slow(play, "Slow", spell.enumValue, spell.counterSpell, duration,
							true, true);
			jjSample(play.xPos, play.yPos, SOUND::DEVILDEVAN_VANISH1);
		}
		break;
		case SPELL_CURE:
		{
			if (asPlayer.activeEffects.length == 0) {
				asPlayer.originalFur = play.fur; //This one works for storing the absolute original fur
			}
			asPlayer.removeNegativeEffects();
			changePlayerFur(SPELL_UNDO, targetPlayerID);
			
			affects = false;
			jjSample(play.xPos, play.yPos, SOUND::ORANGE_SWEEP2L);
			jjSample(play.xPos, play.yPos, SOUND::ORANGE_SWEEP2R);
		}
		break;
		case SPELL_VISIONS:
		{
			Spell@ spell = cast<Spell@>(spells["V"]);
			@effect = Visions(play, "Visions", spell.enumValue, spell.counterSpell, duration, true);
		}
		break;
		case SPELL_DISRUPTING_RAY:
		{
			Spell@ spell = cast<Spell@>(spells["R"]);
			@effect = DisruptingRay(play, "Disrupting Ray", spell.enumValue, spell.counterSpell, duration,
							false, true);
			jjSample(play.xPos, play.yPos, SOUND::COMMON_ITEMTRE);
		}
		break;
		case SPELL_WEAKNESS:
		{
			Spell@ spell = cast<Spell@>(spells["W"]);
			@effect = Weakness(play, "Weakness", spell.enumValue, spell.counterSpell, duration,
							false, true);
			jjSample(play.xPos, play.yPos, SOUND::COMMON_HOLYFLUT);
		}
		break;
		case SPELL_PRECISION:
		{
			Spell@ spell = cast<Spell@>(spells["P"]);
			@effect = Precision(play, "Precision", spell.enumValue, spell.counterSpell, duration);
			jjSample(play.xPos, play.yPos, SOUND::INTRO_RUN);
		}
		break;
		case SPELL_FORGETFULNESS:
		{
			Spell@ spell = cast<Spell@>(spells["G"]);
			@effect = Forgetfulness(play, "Forgetfulness", spell.enumValue, spell.counterSpell, duration,
							true, true);
			if (debugModeOn) jjAlert("Got it");
			jjSample(play.xPos, play.yPos, SOUND::INTRO_GUNM0);
		}
		break;
		case SPELL_FRENZY:
		{
			Spell@ spell = cast<Spell@>(spells["Y"]);
			@effect = Frenzy(play, "Frenzy", spell.enumValue, spell.counterSpell, duration);
			jjSample(play.xPos, play.yPos, SOUND::INTRO_MONSTER);
		}
		break;
	}
	
	if (affects) {
		if (jjIsServer) {
			if (asPlayer.activeEffects.length == 0) {
				asPlayer.originalFur = play.fur; //This one works for storing the absolute original fur
			}
			changePlayerFur(effectID, targetPlayerID);
		}
		
		players[targetPlayerID].setNewActiveEffect(effect);
	}
}

void doMassEffect(uint8 effectID, int8[] targets, uint duration) {
	for (uint i = 0; i < targets.length; i++) {
		int8 targetPlayerID = targets[i];
		jjPLAYER@ play = jjPlayers[targetPlayerID];
		Player@ asPlayer = players[targetPlayerID];
		
		Effect @effect;
		bool affects = true;
		
		switch (effectID) {
			case SPELL_SLOW:
			{
				Spell@ spell = cast<Spell@>(spells["S"]);
				@effect = Slow(play, "Slow", spell.enumValue, spell.counterSpell, duration * SECOND,
								true, true);
				jjSample(play.xPos, play.yPos, SOUND::DEVILDEVAN_VANISH1);
			}
			break;
			case SPELL_DISPEL:
			{
				unDoAllEffects(targetPlayerID);
				affects = false;
				jjSample(play.xPos, play.yPos, SOUND::WITCH_MAGIC);
			}
			break;
			case SPELL_DISRUPTING_RAY:
			{
				Spell@ spell = cast<Spell@>(spells["R"]);
				@effect = DisruptingRay(play, "Disrupting Ray", spell.enumValue, spell.counterSpell, duration * SECOND,
								false, true);
				jjSample(play.xPos, play.yPos, SOUND::COMMON_ITEMTRE);
			}
			break;
			case SPELL_WEAKNESS:
			{
				Spell@ spell = cast<Spell@>(spells["W"]);
				@effect = Weakness(play, "Weakness", spell.enumValue, spell.counterSpell, duration * SECOND,
								false, true);
				jjSample(play.xPos, play.yPos, SOUND::COMMON_HOLYFLUT);
			}
			break;
			case SPELL_FORGETFULNESS:
			{
				Spell@ spell = cast<Spell@>(spells["G"]);
				@effect = Forgetfulness(play, "Forgetfulness", spell.enumValue, spell.counterSpell, duration * SECOND,
								true, true);
				if (debugModeOn) jjAlert("Got it");
				jjSample(play.xPos, play.yPos, SOUND::INTRO_GUNM0);
			}
			break;
		}
		
		if (affects) {
			if (jjIsServer) {
				if (asPlayer.activeEffects.length == 0) {
					asPlayer.originalFur = play.fur; //This one works for storing the absolute original fur
				}
				changePlayerFur(effectID, targetPlayerID);
			}
			
			players[targetPlayerID].setNewActiveEffect(effect);
		}
	}
}

void unDoEffect(int8 targetPlayerID) {
	if (jjIsServer) {
		changePlayerFur(SPELL_UNDO, targetPlayerID);
	}
}

void unDoAllEffects(int8 targetPlayerID) {
	if (jjIsServer) changePlayerFur(SPELL_NONE, targetPlayerID);
	players[targetPlayerID].removeAllActiveEffects();
}

void doDeadPlayer(int8 playerID) {
	Player@ asPlayer = players[playerID];
	asPlayer.isChanneling = false;
	if (asPlayer.activeEffects.length == 0) {
		asPlayer.originalFur = jjPlayers[playerID].fur; //Make sure the original fur is saved
	}
	else {
		asPlayer.removeAllActiveEffects();
	}
	if (jjIsServer) changePlayerFur(SPELL_UNDO, playerID);
}

void closeOtherBoxes(BOX activeBox, Player@ asPlayer) {
	if (activeBox != BOX_SPELL_BOOK && @asPlayer != null) {
		asPlayer.unSetChanneledSpellKey();
		spellBookOpen = false;
	}
	if (activeBox != BOX_INFO) infoOpen = false;
	if (activeBox != BOX_SKILLS) skillsOpen = false;
	if (activeBox != BOX_TREE && treeState == 2) treeState = 1;
	if (activeBox != BOX_KEY_MENU) keyMenuState = keyMenuState >= 1 ? 1 : 0;
	if (activeBox != BOX_WIZARD && wizardState == 2) wizardState = 1;
}

bool infoViewIsAboveBottom() {
	return infoScrollY > -(spells.getKeys().length / 2 * INFO_BOX_HEIGHT -
			(jjSubscreenHeight-(SPELL_BOXES_INIT_Y+HINT_BOX_HEIGHT)+4));
}

bool mouseIsInSelection(int x, int y, int width, int height) {
	return jjMouseX >= x && jjMouseY >= y && jjMouseX <= x+width && jjMouseY <= y+height;
}

bool mouseIsOutsideKeyMenu() {
	return (jjMouseX < jjSubscreenWidth / 4 || jjMouseX > jjSubscreenWidth / 4 * 3) ||
			jjMouseY < jjSubscreenHeight / 4 || jjMouseY > jjSubscreenHeight / 4 * 3;
}

bool mouseIsWithinKeyMenuChoices() {
	return (jjMouseX > jjSubscreenWidth / 4 && jjMouseX < jjSubscreenWidth / 4 * 3)
			&& (jjMouseY > jjSubscreenHeight / 4 + 102 && jjMouseY < jjSubscreenHeight / 4 + 102 + keyBindings.length*16 );
}

string splitText(string textToSplit, int rowWidth) {
	string newText = "";
	int lines = 1;
	for (uint i = 0; i < textToSplit.length(); i++) {
		newText += textToSplit.substr(i, 1);
		if (jjGetStringWidth(newText, STRING::SMALL, STRING::NORMAL) >= lines * rowWidth && textToSplit.substr(i+1, 1) == " ") {
			newText += "\n";
			lines++;
			i++;
		}
	}
	return newText;
}

void drawInfo(jjCANVAS@ canvas, Player@ asPlayer, jjTEXTAPPEARANCE centeredText) {
	array<string> spellKeys = sortSpellKeys(false);
	updateSpellDescriptions(spellKeys, asPlayer);
	centeredText.pipe = STRING::SPECIALSIGN;
	
	for (uint i = 0; i < spellKeys.length; i++) {
		string key = spellKeys[i];
		Spell@ spell = cast<Spell@>(spells[key]);
		int originX = i % 2 == 0 ? 0 : jjSubscreenWidth / 2;
		int originY = SPELL_BOXES_INIT_Y + INFO_BOX_HEIGHT * int(floor(i / 2)) + infoScrollY;
		drawBox(canvas, originX, originY, jjSubscreenWidth / 2, INFO_BOX_HEIGHT,
				TEXT_BOX_BODY_COLOR, SPRITE::NORMAL, true, TEXT_BOX_BORDER_COLOR);
		drawSpellIcon(canvas, originX + 32, originY + 16, spell.enumValue);
		canvas.drawString(originX + 4, originY + 16, "" + spell.tier);
		canvas.drawString(originX + 70, originY + 38, "||||" + spell.name);
		canvas.drawString(originX + 270, originY + 16, "|Numpad", STRING::SMALL, centeredText);
		canvas.drawString(originX + 270, originY + 40, "|" + spell.numpad, STRING::SMALL, centeredText);
		canvas.drawString(originX + 344, originY + 16, "|||Mana", STRING::SMALL, centeredText);
		drawManaIcon(canvas, originX + 386, originY + 16);
		canvas.drawString(originX + 344, originY + 40, "|||" + spell.baseManaCost / asPlayer.spellCostBonus,
				STRING::SMALL, centeredText);
		canvas.drawString(originX + jjSubscreenWidth / 4, originY + 80,
				splitText(spell.description, jjSubscreenWidth / 2 - INFO_BOX_TEXT_MARGIN), STRING::SMALL, centeredText);
	}
	
	if (infoScrollY < SPELL_BOXES_INIT_Y) {
		drawScaledArrow(canvas, jjSubscreenWidth / 2, 32, 196, 1);
	}
	if (infoViewIsAboveBottom()) {
		drawScaledArrow(canvas, jjSubscreenWidth / 2, jjSubscreenHeight - 32, 708, 1);
	}
	
	drawBox(canvas, 0, 0 + infoScrollY, jjSubscreenWidth, SPELL_BOXES_INIT_Y,
			TEXT_BOX_BODY_COLOR, SPRITE::NORMAL, true, TEXT_BOX_BORDER_COLOR);
	canvas.drawString(jjSubscreenWidth / 2, 16 + infoScrollY, "||||ACADEMY", STRING::SMALL, centeredText);
	canvas.drawString(jjSubscreenWidth / 2, 48 + infoScrollY, splitText(ACADEMY_INFO_TEXT, jjSubscreenWidth - 32), STRING::SMALL, centeredText);
	canvas.drawString(jjSubscreenWidth / 2, SPELL_BOXES_INIT_Y - 12 + infoScrollY, "||||List of all available spells", STRING::SMALL, centeredText);
	hintBoxY = SPELL_BOXES_INIT_Y + INFO_BOX_HEIGHT / 2 * spellKeys.length + infoScrollY;
	drawBox(canvas, 0, hintBoxY, jjSubscreenWidth, HINT_BOX_HEIGHT,
			TEXT_BOX_BODY_COLOR, SPRITE::NORMAL, true, TEXT_BOX_BORDER_COLOR);
	canvas.drawString(jjSubscreenWidth / 2, hintBoxY + 64, splitText(infoBoxHints[infoBoxHintPage],
			jjSubscreenWidth / 2 - INFO_BOX_TEXT_MARGIN), STRING::SMALL, centeredText);
	
	if (infoBoxHintPage == 0) {
		canvas.drawString(jjSubscreenWidth / 2, hintBoxY + 256, "||||Current max tiers by roasts:", STRING::SMALL, centeredText);
		if (nonRoastScoreCustomModes.find(jjGameCustom) < 0) {
			uint yIterator = 0;
			for (uint i = 5; i > 1; i--) {
				string tierString = "Tier " + i + ": " + getRoastsRequiredByTier(i) + " or more roasts";
				canvas.drawString(jjSubscreenWidth / 2, hintBoxY + 272 + yIterator*16, tierString, STRING::SMALL, centeredText);
				yIterator++;
			}
		}
		else {
			string tierString = "The spell scroll tiers are random in this game mode!";
			canvas.drawString(jjSubscreenWidth / 2, hintBoxY + 272,
					splitText(tierString, jjSubscreenWidth / 2 - INFO_BOX_TEXT_MARGIN), STRING::SMALL, centeredText);
		}
	}
	
	canvas.drawString(jjSubscreenWidth - 256, hintBoxY + HINT_BOX_HEIGHT - 8, "Click here for next hint..");
}

void drawKeyMenu(jjCANVAS@ canvas, jjTEXTAPPEARANCE centeredText) {
	centeredText.pipe = STRING::SPECIALSIGN;
	drawBox(canvas, jjSubscreenWidth / 4, jjSubscreenHeight / 4, jjSubscreenWidth / 2, jjSubscreenHeight / 2,
			TEXT_BOX_BODY_COLOR, SPRITE::NORMAL, true, TEXT_BOX_BORDER_COLOR);
	canvas.drawString(jjSubscreenWidth / 2, jjSubscreenHeight / 4 + 16,
			"||||Key menu", STRING::SMALL, centeredText);
	canvas.drawString(jjSubscreenWidth / 2, jjSubscreenHeight / 4 + 32,
			"Left mouse click on a\nkey binding to change it.", STRING::SMALL, centeredText);

[preview ends here]