Downloads containing Fio_cutscene.asc

Downloads
Name Author Game Mode Rating
JJ2+ Only: Find It Out (Single Player)Featured Download Superjazz Single player 8.7 Download file

File preview

#include "Fio_drawing.asc"
#include "Fio_utils.asc"

// NOTE TO SELF: Also lighting events require player to be nearby when they need to be visible in ambient lighting

/* FIO CUTSCENE ENGINE */
namespace fioCut {

	enum FadeStateEnum { FADE_IN, BLACKOUT, FADE_OUT };
	
	funcdef void TICK_EVENT_PROCESSOR_CALLBACK(jjPLAYER@ play);
	
	class Animation {
		float duration;
		float elapsed;
		float scaleX;
		float scaleY;
		int angle;
		int cycles;
		float destinationX;
		float destinationY;
		float originX;
		float originY;
		int repetitions;
		int8 currentFrame;
		int8 finalFrame;
		int8 startingFrame;
		uint8 animationId;
		uint frameRate;
		bool isReversed;
		int8 direction;
		SPRITE::Mode spriteMode;
		uint8 spriteParamInitial;
		uint8 spriteParamFinal;
		ANIM::Set animSet;
		
		// If needed, use EmptyAnimation to instantiate an animation just with the duration
		Animation(float duration) {
			this.duration = duration;
		}
		
		/*
		The smaller the frame rate, the faster the animation speed
		NOTE: frameRate has to be any number other than zero, so we don't get exceptions of Divide by Zero :-)
		
		Repetition value of 0 or below causes the animation to repeat infinitely until the duration is reached
		
		If direction is less than 0, angle and scaleX/scaleY won't apply, since direction is not supported by calls to drawRotatedSprite
		and vice versa to drawSprite.
		*/
		Animation(float duration, float originX, float originY, float destinationX, float destinationY,
				int angle, float scaleX, float scaleY, ANIM::Set animSet, uint8 animationId,
				int8 startingFrame, int8 finalFrame, uint frameRate,
				int repetitions = 0, bool isReversed = false, int8 direction = 0,
				SPRITE::Mode spriteMode = SPRITE::NORMAL, uint8 spriteParamInitial = 0, uint8 spriteParamFinal = 0) {
				
			// Apparently the exception handler functions, e.g. throw, are not registered for JJ2+ AS engine
			// so let's just use warning alerts instead
			if (frameRate == 0) {
				jjAlert("|WARNING: Frame rate of an animation was set to 0! This will cause crashes from division by zero later!");
			}
			
			this.duration = duration;
			this.originX = originX;
			this.originY = originY;
			this.destinationX = destinationX;
			this.destinationY = destinationY;
			this.angle = angle;
			this.scaleX = scaleX;
			this.scaleY = scaleY;
			this.animSet = animSet;
			this.animationId = animationId;
			this.startingFrame = startingFrame;
			this.finalFrame = finalFrame;
			this.frameRate = frameRate;
			this.repetitions = repetitions;
			this.isReversed = isReversed;
			this.direction = direction;
			this.spriteMode = spriteMode;
			this.spriteParamInitial = spriteParamInitial;
			this.spriteParamFinal = spriteParamFinal;
			currentFrame = startingFrame;
			cycles = 0;
			elapsed = 0;
		}
		bool elapse() {
			if (elapsed < duration) {
				elapsed += pace;
				return false;
			} else {
				return true;
			}
		}
		// Return true when processing is complete, return false while in progress
		bool process() {
			if ((repetitions < 1 || cycles < repetitions) && (elapsed > 0 && elapsed % float(frameRate) == 0)) {
				if (processAnimationCycle()) {
					cycles++;
					
					if (repetitions < 1 || cycles < repetitions) {
						currentFrame = startingFrame;
					}
				}
			}
			return elapse();
		}
		void render(jjCANVAS@ canvas) {
			float xDistance = destinationX - originX;
			float yDistance = destinationY - originY;
			int xPixel = int(elapsed / duration * xDistance + originX);
			int yPixel = int(elapsed / duration * yDistance + originY);
			uint8 spriteParam = spriteMode == SPRITE::NORMAL ? 0 : getCurrentSpriteParam();
			
			if (direction < 0) {
				canvas.drawSprite(xPixel, yPixel, animSet, animationId, currentFrame, direction, spriteMode, spriteParam);
			} else {
				canvas.drawRotatedSprite(xPixel, yPixel, animSet, animationId, currentFrame, angle, scaleX, scaleY,
						spriteMode, spriteParam);
			}
		}
		private bool processAnimationCycle() {
			if (isReversed) {
				if (currentFrame > finalFrame) {
					currentFrame--;
					return false;
				}
				return true;
			}
			if (currentFrame < finalFrame) {
				currentFrame++;
				return false;
			}
			return true;
		}
		private uint8 getCurrentSpriteParam() {
			// To avoid the result being 0 in the beginning when that is not desired, since spriteParamFinal - spriteParamInitial = 0 and 0 / duration = 0 
			if (spriteParamInitial == spriteParamFinal || (spriteParamInitial != 0 && elapsed == 0)) {
				return spriteParamInitial;
			}
			return uint8(elapsed / duration * int(spriteParamFinal - spriteParamInitial));
		}
	}

	class AnimationChain {
		array<Animation@> animations;
		bool isLayer5Animation = false;
		AnimationChain(array<Animation@> animations, bool isLayer5Animation = false) {
			this.animations = animations;
			this.isLayer5Animation = isLayer5Animation;
		}
		// REMINDER: DON'T FORGET TO REMOVE THE TRAILING COMMA, SINCE OTHERWISE AS WILL INSERT A NULL HANDLE AFTER THE LAST ACTUAL OBJECT ELEMENT
		// If there are weird null pointer exceptions starting to happen after an animation chain is done, it is probably because of a null handle
		// at the end of the list if there is a trailing comma after the last element in the static initialization list
		bool process() {
			if (animations.length() > 0) {
				if (animations[0].process()) {
					animations.removeAt(0);
				}
			}
			return animations.length() <= 0;
		}
		void render(jjCANVAS@ canvas) {
			if (animations.length() > 0) {
				animations[0].render(canvas);
			}
		}
	}
	
	class CameraFollowObjectEvent : Event {
		jjOBJ@ obj;
		CameraFollowObjectEvent(float duration, jjOBJ@ obj) {
			super(duration);
			@this.obj = obj;
		}
		bool process() override {
			if (@obj !is null) {
				play.cameraFreeze(obj.xPos, obj.yPos, true, true);
			}
			return elapse();
		}
	}
	
	class CameraScrollEvent : Event {
		bool centered;
		bool instant;
		float finalXPixel;
		float finalYPixel;
		float initialXPixel;
		float initialYPixel;
		CameraScrollEvent(float duration, float initialXPixel, float initialYPixel,
				float finalXPixel, float finalYPixel, bool centered, bool instant) {
			super(duration);
			this.initialXPixel = initialXPixel;
			this.initialYPixel = initialYPixel;
			this.finalXPixel = finalXPixel;
			this.finalYPixel = finalYPixel;
			this.centered = centered;
			this.instant = instant;
		}
		bool process() override {
			float xDistance = finalXPixel - initialXPixel;
			float yDistance = finalYPixel - initialYPixel;
			int xPixel = int(elapsed / duration * xDistance + initialXPixel);
			int yPixel = int(elapsed / duration * yDistance + initialYPixel);
			play.cameraFreeze(float(xPixel), float(yPixel), centered, instant);
			return elapse();
		}
	}
	
	class CameraShakeEvent : Event {
		bool centered;
		bool instant;
		float xPixel;
		float yPixel;
		int magnitude;
		CameraShakeEvent(float duration, float xPixel, float yPixel, int magnitude, bool centered, bool instant) {
			super(duration);
			this.xPixel = xPixel;
			this.yPixel = yPixel;
			this.magnitude = magnitude;
			this.centered = centered;
			this.instant = instant;
		}
		bool process() override {
			play.cameraFreeze(
					xPixel + jjSin(jjGameTicks * SHAKE_AMPLIFIER) * magnitude,
					yPixel + jjCos(jjGameTicks * SHAKE_AMPLIFIER) * magnitude,
					centered,
					instant);
			return elapse();
		}
	}
	
	// Doesn't render anything, thus it's an empty animation just for filling up time gaps between texts, events, etc.
	class EmptyAnimation : Animation {
		EmptyAnimation(float duration) {
			super(duration);
		}
		bool process() override {
			return elapse();
		}
		void render(jjCANVAS@) {}
	}

	abstract class Event {
		float duration;
		float elapsed;
		Event(float duration) {
			this.duration = duration;
			elapsed = 0;
		}
		void draw(jjCANVAS@ canvas) {
			// Based on implementation
		}
		// Return true when processing is complete, return false while in progress
		bool process() {
			jjAlert("|WARNING: Class inheriting Event does not override process-method!");
			return true;
		}
		bool elapse() {
			if (elapsed < duration) {
				elapsed += pace;
				return false;
			} else {
				return true;
			}
		}
	}
	
	class FadeEvent : Event {
		int MAXIMUM_FADE_OPACITY = 255;
		bool fadeIn;
		bool fadeOut;
		FadeStateEnum fadeState;
		float blackoutDuration;
		float elapsedBlackout;
		float elapsedFadeIn;
		float elapsedFadeOut;
		float fadeInDuration;
		float fadeOutDuration;
		float opacity;
		uint8 color;
		/*
			Reminder: The blackoutDuration stands for the part of the fade where the screen is all black, so for fadeIn/fadeOut to work
					properly, the overall duration needs to be more than the blackoutDuration, since the value of blackoutDuration
					is reserved from the overall duration for the time when the screen is all black and the rest of the duration is
					divided for fadeIn/fadeOut states accordingly, depending on whether they are enabled or not.
					
					By default the fade starts from state BLACKOUT if fadeIn is not enabled.
		*/
		FadeEvent(float duration, float blackoutDuration, bool fadeIn, bool fadeOut, uint8 color = 0) {
			super(duration);
			this.fadeIn = fadeIn;
			this.fadeOut = fadeOut;
			this.blackoutDuration = blackoutDuration;
			this.color = color;
			elapsedBlackout = 0;
			elapsedFadeIn = 0;
			elapsedFadeOut = 0;
			setFadeInDuration();
			setFadeOutDuration();
			fadeState = fadeIn ? FadeStateEnum::FADE_IN : FadeStateEnum::BLACKOUT;
			opacity = fadeIn ? 0 : MAXIMUM_FADE_OPACITY;
		}
		void draw(jjCANVAS@ canvas) override {
			canvas.drawRectangle(0, 0, jjSubscreenWidth, jjSubscreenHeight, color, SPRITE::BLEND_NORMAL, uint8(opacity));
		}
		bool process() override {
			if (fadeState == FadeStateEnum::FADE_IN) {
				if (elapsedFadeIn < fadeInDuration) {
					elapsedFadeIn += getPace();
					incrementOpacity();
				} else {
					fadeState = FadeStateEnum::BLACKOUT;
				}
			} else if (fadeState == FadeStateEnum::BLACKOUT) {
				if (elapsedBlackout < blackoutDuration) {
					elapsedBlackout += getPace();
				} else {
					fadeState = FadeStateEnum::FADE_OUT;
				}
			} else {
				if (elapsedFadeOut < fadeOutDuration) {
					elapsedFadeOut += getPace();
					decrementOpacity();
				}
			}
			return elapse();
		}
		private void decrementOpacity() {
			opacity = MAXIMUM_FADE_OPACITY - (elapsedFadeOut / fadeOutDuration * MAXIMUM_FADE_OPACITY);
			if (opacity < 0) {
				opacity = 0;
			}
		}
		private void incrementOpacity() {
			opacity = elapsedFadeIn / fadeInDuration * MAXIMUM_FADE_OPACITY;
			if (opacity > MAXIMUM_FADE_OPACITY) {
				opacity = MAXIMUM_FADE_OPACITY;
			}
		}
		private void setFadeInDuration() {
			if (fadeIn) {
				if (fadeOut) {
					fadeInDuration = (duration - blackoutDuration) / 2;
				} else {
					fadeInDuration = duration - blackoutDuration;
				}
			} else {
				fadeInDuration = 0;
			}
		}
		private void setFadeOutDuration() {
			if (fadeOut) {
				if (fadeIn) {
					fadeOutDuration = (duration - blackoutDuration) / 2;
				} else {
					fadeOutDuration = duration - blackoutDuration;
				}
			} else {
				fadeOutDuration = 0;
			}
		}
	}
	
	class LevelCycleEvent : Event {
		string filename;
		LevelCycleEvent(float duration, string filename) {
			super(duration);
			this.filename = filename;
		}
		bool process() override {
			if (elapsed < duration) {
				elapsed++;
				return false;
			} else {
				jjNxt(filename, false ,true);
				return true;
			}
		}
	}
	
	class LayerOffsetEvent : Event {
		float finalXOffset;
		float finalYOffset;
		float initialXOffset;
		float initialYOffset;
		int8 layerId;
		LayerOffsetEvent(float duration, int8 layerId, float initialXOffset, float initialYOffset,
				float finalXOffset, float finalYOffset) {
			super(duration);
			this.layerId = layerId;
			this.initialXOffset = initialXOffset;
			this.initialYOffset = initialYOffset;
			this.finalXOffset = finalXOffset;
			this.finalYOffset = finalYOffset;
		}
		bool process() override {
			float xDistance = finalXOffset - initialXOffset;
			float yDistance = finalYOffset - initialYOffset;
			float xOffset = initialXOffset + elapsed / duration * xDistance;
			float yOffset = initialYOffset + elapsed / duration * yDistance;
			jjLayers[layerId].xOffset = xOffset;
			jjLayers[layerId].yOffset = yOffset;
			return elapse();
		}
	}
	
	class ObjectAnimationEvent : Event {
		bool initialized;
		int8 currentFrame;
		int8 finalFrame;
		int8 startingFrame;
		int cycles;
		int repetitions;
		uint8 animation;
		uint frameRate;
		jjOBJ@ obj;
		ANIM::Set animSet;
		/*
		The smaller the frame rate, the faster the animation speed
		
		Repetition value of 0 or below causes the animation to repeat infinitely until the duration ends
		*/
		ObjectAnimationEvent(float duration, jjOBJ@ obj, ANIM::Set animSet, uint8 animation,
				int8 startingFrame, int8 finalFrame, uint frameRate, int repetitions) {
			super(duration);
			@this.obj = obj;
			this.animSet = animSet;
			this.animation = animation;
			this.startingFrame = startingFrame;
			this.finalFrame = finalFrame;
			this.frameRate = frameRate;
			this.repetitions = repetitions;
			initialized = false;
			cycles = 0;
		}
		bool process() override {
			if (!initialized) {
				initialize();
				initialized = true;
			}
			if ((repetitions < 1 || cycles < repetitions) && (elapsed > 0 && elapsed % frameRate == 0)) {
				if (currentFrame < finalFrame) {
					currentFrame++;
					obj.frameID = currentFrame;
				} else {
					currentFrame = startingFrame;
					cycles++;
				}
			}
			return elapse();
		}
		private void initialize() {
			obj.determineCurAnim(animSet, animation);
			obj.frameID = startingFrame;
			currentFrame = startingFrame;
		}
	}
	
	const float FAST_PACE = 0.5;
	const float NORMAL_PACE = 0.25;
	const float MAX_PACE = 1;
	const float MIN_PACE = 0.125;
	const float PACE_ARROW_SCALE_X = 1.15;
	const float PACE_ARROW_SCALE_Y = 1.15;
	
	const int CUTSCENE_TEXT_BOX_COLOR = 47;
	const int CUTSCENE_TEXT_BOX_HEIGHT = 120;
	
	const uint SHAKE_AMPLIFIER = 256;
	
	const string KEY_INCREASE_PACE_NAME = "right";
	const string KEY_DECREASE_PACE_NAME = "left";
	
	TICK_EVENT_PROCESSOR_CALLBACK@ tickEventProcessorCallback;
	
	array<AnimationChain@> activeAnimationChains;
	
	array<Event@> events;
	
	array<string> cutsceneTexts;
	
	bool canSlideTexts = false;
	bool cutsceneSkipInitiated = false;
	bool isMindstoneCommunicationRendered = false;
	bool tickEventsProcessed = false;
	
	bool wasFastForwardUsed = false;
	
	float elapsedCutscene = 0;
	float pace = NORMAL_PACE;
	
	int textXPos = jjSubscreenWidth + 2;

	uint elapsedBackgroundFadeChange = 0;
	uint textIterator = 0;

	// For debugging purposes
	array<float> firstCutsceneElapseds;
	
	string buildPaceString() {
		if (pace == NORMAL_PACE) {
			return "Normal";
		}
		return "" + (getPaceNormalized()) + "x";
	}
	
	void clearAnimationChains() {
		activeAnimationChains = array<AnimationChain@>(0);
	}
	
	void controlElapsed() {
		elapsedCutscene += pace;
		
		// For debugging purposes
		if (uint(getElapsedCutscene()) < CUTSCENE_SECOND * 2) {
			firstCutsceneElapseds.insertLast(getElapsedCutscene());
		}
		
		if (elapsedCutscene % 1 == 0) {
			setTickEventsProcessed(false);
		}
	}
	
	void controlPlayerInput(jjPLAYER@ play) {
		if (fioUtils::isKeyTapped(KEY_INCREASE_PACE_NAME) && !fioUtils::isKeyTapped(KEY_DECREASE_PACE_NAME)
				|| fioUtils::isMouseLeftClickedInArea(jjSubscreenWidth / 2 + 56, jjSubscreenHeight - 16, 32, 16)) {
			increasePace(play);
		} else if (!fioUtils::isKeyTapped(KEY_INCREASE_PACE_NAME) && fioUtils::isKeyTapped(KEY_DECREASE_PACE_NAME)
				|| fioUtils::isMouseLeftClickedInArea(jjSubscreenWidth / 2 - 90, jjSubscreenHeight - 16, 32, 16)) {
			decreasePace();
		}
	}
	
	void createAnimationChain(array<Animation@> animations, bool isLayer5Animation = false) {
		activeAnimationChains.insertLast(AnimationChain(animations, isLayer5Animation));
	}
	
	void createEventCameraFollowObject(float duration, jjOBJ@ obj) {
		events.insertLast(CameraFollowObjectEvent(duration, obj));
	}
	
	void createEventCameraScroll(float duration,  float initialXPixel, float initialYPixel,
			float finalXPixel, float finalYPixel, bool centered = true, bool instant = true) {
		events.insertLast(CameraScrollEvent(duration, initialXPixel, initialYPixel, finalXPixel, finalYPixel,
				centered, instant));
	}
	
	void createEventCameraShake(float duration,  float xPixel, float yPixel, int magnitude, bool centered = true, bool instant = true) {
		events.insertLast(CameraShakeEvent(duration, xPixel, yPixel, magnitude, centered, instant));
	}
	
	void createEventFade(float totalDuration, float blackoutDuration, bool fadeIn, bool fadeOut, uint8 color = 0) {
		events.insertLast(FadeEvent(totalDuration, blackoutDuration, fadeIn, fadeOut, color));
	}
	
	void createEventLevelCycle(float duration, string filename) {
		events.insertLast(LevelCycleEvent(duration, filename));
	}
	
	void createEventLayerOffset(float duration, int8 layerId, float initialXOffset, float initialYOffset,
			float finalXOffset, float finalYOffset) {
		events.insertLast(LayerOffsetEvent(duration, layerId, initialXOffset, initialYOffset,
				finalXOffset, finalYOffset));
	}
	
	void createEventObjectAnimation(float duration, jjOBJ@ obj, ANIM::Set animSet, uint8 animation,
			int8 startingFrame, int8 finalFrame, uint frameRate = 1, int repetitions = 1) {
		events.insertLast(ObjectAnimationEvent(duration, obj, animSet, animation, startingFrame, finalFrame,
				frameRate, repetitions));
	}
	
	void decreasePace() {
		if (pace == MAX_PACE) {
			pace = FAST_PACE;
		} else if (pace == FAST_PACE) {
			pace = NORMAL_PACE;
		} else if (pace == NORMAL_PACE) {
			pace = MIN_PACE;
		}
	}
	
	void drawCutscene(jjCANVAS@ canvas, jjTEXTAPPEARANCE centeredText, bool isTextBoxOpaque = false) {
		drawEvents(canvas);
		
		if (isMindstoneCommunicationRendered && mindstoneCommunicationTileIds.length() == 5) {
			drawMindstoneCommunication(canvas);
		}
		
		drawCutsceneTextBox(canvas, centeredText, isTextBoxOpaque);
		
		if (canSlideTexts) {
			drawCutsceneTexts(canvas);
		}
	}
	
	void drawCutsceneTextBox(jjCANVAS@ canvas, jjTEXTAPPEARANCE centeredText, bool isTextBoxOpaque) { 
		fioDraw::drawBox(canvas, -1, jjSubscreenHeight - CUTSCENE_TEXT_BOX_HEIGHT, jjSubscreenWidth + 1, CUTSCENE_TEXT_BOX_HEIGHT + 1,
				CUTSCENE_TEXT_BOX_COLOR, isTextBoxOpaque ? SPRITE::NORMAL : SPRITE::TRANSLUCENT, true, HUD_BAR_BORDER_COLOR);
		
		canvas.drawRotatedSprite(jjSubscreenWidth / 2 - 56, jjSubscreenHeight - 10, ANIM::FLAG, 0, 0, 440, 0.7, 0.7, SPRITE::PALSHIFT, 40);
		canvas.drawRotatedSprite(jjSubscreenWidth / 2 + 56, jjSubscreenHeight - 10, ANIM::FLAG, 0, 0, 950, 0.7, 0.7, SPRITE::PALSHIFT, 40);
		
		canvas.drawString(jjSubscreenWidth / 2, jjSubscreenHeight - 8, buildPaceString(), STRING::SMALL, centeredText);
	}
	
	void drawCutsceneTexts(jjCANVAS@ canvas) {
		canvas.drawString(textXPos, jjSubscreenHeight - CUTSCENE_TEXT_BOX_HEIGHT / 2, cutsceneTexts[textIterator]);
	}
	
	void drawEvents(jjCANVAS@ canvas) {
		for (uint i = 0; i < events.length(); i++) {
			events[i].draw(canvas);
		}
	}
	
	void drawMindstoneCommunication(jjCANVAS@ canvas) {
		const int mindstoneOriginX = jjSubscreenWidth / 8 * 5;
		const int mindstoneOriginY = jjSubscreenHeight / 5 * 2;
	
		canvas.drawRectangle(0, 0, jjSubscreenWidth, jjSubscreenHeight, MINDSTONE_COMMUNICATION_BACKGROUND_COLOR, SPRITE::TRANSLUCENT);
		canvas.drawResizedSprite(
				jjSubscreenWidth / 4,
				jjSubscreenHeight / 5 * 3,
				ANIM::FACES,
				getFaceAnimByCharacter(),
				getFaceAnimFrameByCharacter(),
				5,
				5);
		
		canvas.drawTile(
				mindstoneOriginX,
				mindstoneOriginY,
				mindstoneCommunicationTileIds[0]);
		canvas.drawTile(
				mindstoneOriginX - 32,
				mindstoneOriginY + 32,
				mindstoneCommunicationTileIds[1]);
		canvas.drawTile(
				mindstoneOriginX,
				mindstoneOriginY + 32,
				mindstoneCommunicationTileIds[2]);
		canvas.drawTile(
				mindstoneOriginX - 32,
				mindstoneOriginY + 64,
				mindstoneCommunicationTileIds[3]);
		canvas.drawTile(
				mindstoneOriginX,
				mindstoneOriginY + 64,
				mindstoneCommunicationTileIds[4]);
	}
	
	void endCutscene(float playerXPos, float playerYPos) {
		play.xPos = playerXPos;
		play.yPos = playerYPos;
		play.ballTime = 0;
		play.noFire = false;
		play.cameraUnfreeze();
		isMindstoneCommunicationRendered = false;
	}
	
	float getElapsedCutscene() {
		return elapsedCutscene;
	}
	
	uint8 getFaceAnimByCharacter() {
		if (play.charOrig == CHAR::LORI) {
			return 4;
		}
		if (play.charOrig == CHAR::SPAZ) {
			return 5;
		}
		return 3; // Default JAZZ
	}
	
	uint8 getFaceAnimFrameByCharacter() {
		if (play.charOrig == CHAR::LORI) {
			return 10;
		}
		if (play.charOrig == CHAR::SPAZ) {
			return 18;
		}
		return 25; // Default JAZZ
	}
	
	float getPace() {
		return pace;
	}
	
	/*
	The raw pace multiplied to a level where NORMAL_PACE (0.25) equals a whole number (1)
	*/
	float getPaceNormalized() {
		return pace * 4;
	}
	
	void goToNextText() {
		textXPos = jjSubscreenWidth + 2;
		textIterator++;
	}
	
	void increasePace(jjPLAYER@ play) {
		// It can be that the pace is increased just before the script manages to process the tick events within the onPlayer-hook, so this ensures that
		// those events get processed either way
		// Don't call run() here, as that will also increment the elapsed and prevent the correct tick events from happening
		if (@tickEventProcessorCallback !is null && !isTickEventsProcessed()) {
			tickEventProcessorCallback(play);
			setTickEventsProcessed(true);
		}
		
		if (pace == MIN_PACE) {
			synchronizeElapsedProperties(0.125);
			pace = NORMAL_PACE;
		} else if (pace == NORMAL_PACE) {
			synchronizeElapsedProperties(0.25);
			pace = FAST_PACE;
			wasFastForwardUsed = true;
		} else if (pace == FAST_PACE) {
			synchronizeElapsedProperties(0.5);
			pace = MAX_PACE;
			wasFastForwardUsed = true;
		}
		
		// Voi viddu kun unohtui tää :DDDDDD
		// Sai kyllä tapella iäisyyden ennen kuin tajusi mitä puuttui
		if (elapsedCutscene % 1 == 0) {
			setTickEventsProcessed(false);
		}
	}
	
	// Pass in a custom value for newElapsedCutscene to debug specific parts of cutscenes quicker 
	// Call this method before initializing new animations for safety
	void initializeCutscene(TICK_EVENT_PROCESSOR_CALLBACK@ newTickEventProcessorCallback, array<string> newCutsceneTexts,
			float newElapsedCutscene = 0) {
		@tickEventProcessorCallback = newTickEventProcessorCallback;
		cutsceneTexts = newCutsceneTexts;
		elapsedCutscene = newElapsedCutscene;
		cutsceneSkipInitiated = false;
		canSlideTexts = false;
		tickEventsProcessed = false;
		wasFastForwardUsed = false;
		pace = NORMAL_PACE;
		textXPos = jjSubscreenWidth + 2;
		elapsedBackgroundFadeChange = 0;
		textIterator = 0;
		events = array<Event@>();
		
		// Cheap hack to ensure no text boxes are open when cutscene begins
		fioDraw::elapsedTextDisplay = 0;
		fioDraw::elapsedQuestDisplay = 0;
	}
	
	/*
	Use this to avoid accidental skips from pressing fire before the cutscene has properly begun
	*/
	bool isCutsceneSkipInitiatedAfterDelay(jjPLAYER@ play) {
		return play.keyFire && !cutsceneSkipInitiated && elapsedCutscene > 15;
	}
	
	bool isElapsedAHalf(float elapsed) {
		return fraction(elapsed) == 0.5;
	}
	
	bool isElapsedAQuarter(float elapsed) {
		return fraction(elapsed) == 0.25 || fraction(elapsed) == 0.75;
	}
	
	bool isElapsedAnEighth(float elapsed) {
		return fraction(elapsed) == 0.125 || fraction(elapsed) == 0.375 || fraction(elapsed) == 0.625 || fraction(elapsed) == 0.875;
	}
	
	bool isTextBeyondScreen() {
		return textXPos < -(jjGetStringWidth(cutsceneTexts[textIterator], STRING::SMALL, STRING::NORMAL)) - 2;
	}
	
	bool isTextsRemaining() {
		return textIterator < cutsceneTexts.length() - 1;
	}
	
	/*
	Level scripts should check whether this function returns a value that equals false before
	executing statements based on the value returned by #getElapsedCutscene function, otherwise
	the statements will be executed multiple times during the elapsedCutscene tick. Additionally,
	the scripts should set this property to true with the function #setTickEventsProcessed after
	having executed their statements based on the #getElapsedCutscene function.
	*/
	bool isTickEventsProcessed() {
		return tickEventsProcessed;
	}
	
	void processAnimationChains() {
		uint j = 0;
		uint activeAnimationChainsLength = activeAnimationChains.length();
		for (uint i = 0; i < activeAnimationChainsLength; i++) {
			if (!activeAnimationChains[i].process()) {
				@activeAnimationChains[j] = activeAnimationChains[i];
				j++;
			}
		}
		activeAnimationChains.removeRange(j, activeAnimationChainsLength - j);
	}
	
	bool processBackgroundFadeChange(uint8 initialRed, uint8 initialGreen, uint8 initialBlue,
			uint8 finalRed, uint8 finalGreen, uint8 finalBlue, float duration) {
		jjSetFadeColors(uint8(initialRed * (1 - elapsedBackgroundFadeChange / duration)
				+ finalRed * elapsedBackgroundFadeChange / duration),
				uint8(initialGreen * (1 - elapsedBackgroundFadeChange / duration)
				+ finalGreen * elapsedBackgroundFadeChange / duration),
				uint8(initialBlue * (1 - elapsedBackgroundFadeChange / duration)
				+ finalBlue * elapsedBackgroundFadeChange / duration));
		if (elapsedBackgroundFadeChange < uint(duration)) {
			elapsedBackgroundFadeChange = uint(elapsedBackgroundFadeChange + getPaceNormalized());
			return true;
		} else {
			return false;
		}
	}
	
	void processEvents() {
		uint j = 0;
		uint eventsLength = events.length();
		for (uint i = 0; i < eventsLength; i++) {
			if (!events[i].process()) {
				@events[j] = events[i];
				j++;
			}
		}
		events.removeRange(j, eventsLength - j);
	}
	
	void renderAnimations(jjCANVAS@ canvas, bool isCalledFromOnDrawLayer5 = false) {
		for (uint i = 0; i < activeAnimationChains.length(); i++) {
			AnimationChain@ animationChain = activeAnimationChains[i];
			if ((isCalledFromOnDrawLayer5 && animationChain.isLayer5Animation)
					|| (!isCalledFromOnDrawLayer5 && !animationChain.isLayer5Animation)) {
				activeAnimationChains[i].render(canvas);
			}
		}
	}
	
	void run() {
		controlElapsed();
		processAnimationChains();
		processEvents();
		if (canSlideTexts) {
			if (isTextBeyondScreen()) {
				if (isTextsRemaining()) {
					goToNextText();
				} else {
					canSlideTexts = false;
				}
			}
			slideText();
		}
	}
	
	void setCutsceneSkipInitiated() {
		cutsceneSkipInitiated = true;
	}
	
	/* For development purposes only */
	void setElapsedCutscene(float elapsed) {
		elapsedCutscene = elapsed;
	}
	
	/* Sets the value of tickEventsProcessed, value of true indicating that events of this cutscene tick have been processed once. */
	void setTickEventsProcessed(bool processed) {
		tickEventsProcessed = processed;
	}
	
	void slideText() { 
		textXPos -= int(getPaceNormalized() * 2);
	}
	
	void startTextSliding() {
		if (cutsceneTexts.length > 0) {
			textIterator = 0;
			canSlideTexts = true;
		} else {
			jjAlert("|WARNING: Attempted to start text sliding without initializing cutscene texts first!");
		}
	}
	
	void synchronizeAnimationsElapsed(float offset) {
		for (uint i = 0; i < activeAnimationChains.length(); i++) {
			float animationElapsed = activeAnimationChains[i].animations[0].elapsed;
			
			if ((fraction(offset) == 0.125 && isElapsedAnEighth(animationElapsed))
					|| (fraction(offset) == 0.25 && isElapsedAQuarter(animationElapsed))
					|| (fraction(offset) == 0.5 && isElapsedAHalf(animationElapsed))) {
				activeAnimationChains[i].animations[0].elapsed += offset;
			}
		}
	}
	
	void synchronizeElapsedProperties(float offset) {
		synchronizeElapsedCutscene(offset);
		synchronizeAnimationsElapsed(offset);
		synchronizeEventsElapsed(offset);
	}
	
	void synchronizeElapsedCutscene(float offset) {
		if ((fraction(offset) == 0.125 && isElapsedAnEighth(elapsedCutscene))
				|| (fraction(offset) == 0.25 && isElapsedAQuarter(elapsedCutscene))
				|| (fraction(offset) == 0.5 && isElapsedAHalf(elapsedCutscene))) {
			elapsedCutscene += offset;
		}
	}
	
	void synchronizeEventsElapsed(float offset) {
		for (uint i = 0; i < events.length(); i++) {
			float eventElapsed = events[i].elapsed;
			
			if ((fraction(offset) == 0.125 && isElapsedAnEighth(eventElapsed))
					|| (fraction(offset) == 0.25 && isElapsedAQuarter(eventElapsed))
					|| (fraction(offset) == 0.5 && isElapsedAHalf(eventElapsed))) {
				events[i].elapsed += offset;
			}
		}
	}
}