mirror of
https://github.com/ninjamuffin99/Funkin.git
synced 2025-08-30 18:34:51 +00:00
3898 lines
117 KiB
Haxe
3898 lines
117 KiB
Haxe
package funkin.play;
|
|
|
|
import funkin.play.PauseSubState.PauseMode;
|
|
import flixel.addons.transition.FlxTransitionableState;
|
|
import flixel.addons.transition.Transition;
|
|
import funkin.ui.FullScreenScaleMode;
|
|
import flixel.FlxCamera;
|
|
import flixel.FlxObject;
|
|
import flixel.FlxSubState;
|
|
import flixel.math.FlxMath;
|
|
import flixel.math.FlxPoint;
|
|
import flixel.sound.FlxSound;
|
|
import flixel.text.FlxText;
|
|
import flixel.tweens.FlxTween;
|
|
import flixel.tweens.FlxEase;
|
|
import flixel.ui.FlxBar;
|
|
import flixel.util.FlxColor;
|
|
import flixel.util.FlxStringUtil;
|
|
import flixel.util.FlxTimer;
|
|
import funkin.audio.FunkinSound;
|
|
import funkin.audio.VoicesGroup;
|
|
import funkin.data.dialogue.ConversationRegistry;
|
|
import funkin.data.event.SongEventRegistry;
|
|
import funkin.data.notestyle.NoteStyleRegistry;
|
|
import funkin.data.song.SongData.SongCharacterData;
|
|
import funkin.data.song.SongData.SongEventData;
|
|
import funkin.data.song.SongData.SongNoteData;
|
|
import funkin.data.song.SongRegistry;
|
|
import funkin.data.stage.StageRegistry;
|
|
import funkin.graphics.FunkinCamera;
|
|
import funkin.graphics.FunkinSprite;
|
|
import funkin.Highscore.Tallies;
|
|
import funkin.input.PreciseInputManager;
|
|
import funkin.modding.events.ScriptEvent;
|
|
import funkin.api.newgrounds.Events;
|
|
import funkin.modding.events.ScriptEventDispatcher;
|
|
import funkin.play.character.BaseCharacter;
|
|
import funkin.play.character.CharacterData.CharacterDataParser;
|
|
import funkin.play.components.HealthIcon;
|
|
import funkin.play.components.PopUpStuff;
|
|
import funkin.play.cutscene.dialogue.Conversation;
|
|
import funkin.play.cutscene.VanillaCutscenes;
|
|
import funkin.play.cutscene.VideoCutscene;
|
|
import funkin.play.notes.NoteDirection;
|
|
import funkin.play.notes.notekind.NoteKindManager;
|
|
import funkin.play.notes.notekind.NoteKind;
|
|
import funkin.play.notes.NoteSprite;
|
|
import funkin.play.notes.notestyle.NoteStyle;
|
|
import funkin.play.notes.Strumline;
|
|
import funkin.play.notes.SustainTrail;
|
|
import funkin.play.notes.NoteVibrationsHandler;
|
|
import funkin.play.scoring.Scoring;
|
|
import funkin.play.song.Song;
|
|
import funkin.play.stage.Stage;
|
|
import funkin.save.Save;
|
|
import funkin.ui.debug.charting.ChartEditorState;
|
|
import funkin.ui.debug.stage.StageOffsetSubState;
|
|
import funkin.ui.mainmenu.MainMenuState;
|
|
import funkin.ui.MusicBeatSubState;
|
|
import funkin.ui.transition.LoadingState;
|
|
import funkin.util.SerializerUtil;
|
|
import funkin.util.HapticUtil;
|
|
import funkin.util.GRhythmUtil;
|
|
import haxe.Int64;
|
|
#if mobile
|
|
import funkin.util.TouchUtil;
|
|
import funkin.mobile.ui.FunkinHitbox;
|
|
import funkin.mobile.input.ControlsHandler;
|
|
import funkin.mobile.ui.FunkinHitbox.FunkinHitboxControlSchemes;
|
|
#if FEATURE_MOBILE_ADVERTISEMENTS
|
|
import funkin.mobile.util.AdMobUtil;
|
|
#end
|
|
#end
|
|
#if FEATURE_DISCORD_RPC
|
|
import funkin.api.discord.DiscordClient;
|
|
#end
|
|
#if FEATURE_NEWGROUNDS
|
|
import funkin.api.newgrounds.Medals;
|
|
import funkin.api.newgrounds.Leaderboards;
|
|
#end
|
|
|
|
/**
|
|
* Parameters used to initialize the PlayState.
|
|
*/
|
|
typedef PlayStateParams =
|
|
{
|
|
/**
|
|
* The song to play.
|
|
*/
|
|
targetSong:Song,
|
|
|
|
/**
|
|
* The difficulty to play the song on.
|
|
* @default `Constants.DEFAULT_DIFFICULTY`
|
|
*/
|
|
?targetDifficulty:String,
|
|
/**
|
|
* The variation to play on.
|
|
* @default `Constants.DEFAULT_VARIATION`
|
|
*/
|
|
?targetVariation:String,
|
|
/**
|
|
* The instrumental to play with.
|
|
* Significant if the `targetSong` supports alternate instrumentals.
|
|
* @default `null`
|
|
*/
|
|
?targetInstrumental:String,
|
|
/**
|
|
* Whether the song should start in Practice Mode.
|
|
* @default `false`
|
|
*/
|
|
?practiceMode:Bool,
|
|
/**
|
|
* Whether the song should start in Bot Play Mode.
|
|
* @default `false`
|
|
*/
|
|
?botPlayMode:Bool,
|
|
/**
|
|
* Whether the song should be in minimal mode.
|
|
* @default `false`
|
|
*/
|
|
?minimalMode:Bool,
|
|
/**
|
|
* If specified, the game will jump to the specified timestamp after the countdown ends.
|
|
* @default `0.0`
|
|
*/
|
|
?startTimestamp:Float,
|
|
/**
|
|
* If specified, the game will play the song with the given speed.
|
|
* @default `1.0` for 100% speed.
|
|
*/
|
|
?playbackRate:Float,
|
|
/**
|
|
* If specified, the game will not load the instrumental or vocal tracks,
|
|
* and must be loaded externally.
|
|
*/
|
|
?overrideMusic:Bool,
|
|
/**
|
|
* The initial camera follow point.
|
|
* Used to persist the position of the `cameraFollowPosition` between levels.
|
|
*/
|
|
?cameraFollowPoint:FlxPoint,
|
|
}
|
|
|
|
/**
|
|
* The gameplay state, where all the rhythm gaming happens.
|
|
* SubState so it can be loaded as a child of the chart editor.
|
|
*/
|
|
@:nullSafety
|
|
class PlayState extends MusicBeatSubState
|
|
{
|
|
/**
|
|
* STATIC VARIABLES
|
|
* Static variables should be used for information that must be persisted between states or between resets,
|
|
* such as the active song or song playlist.
|
|
*/
|
|
/**
|
|
* The currently active PlayState.
|
|
* There should be only one PlayState in existance at a time, we can use a singleton.
|
|
*/
|
|
public static var instance:Null<PlayState>;
|
|
|
|
/**
|
|
* This sucks. We need this because FlxG.resetState(); assumes the constructor has no arguments.
|
|
* @see https://github.com/HaxeFlixel/flixel/issues/2541
|
|
*/
|
|
static var lastParams:Null<PlayStateParams>;
|
|
|
|
/**
|
|
* PUBLIC INSTANCE VARIABLES
|
|
* Public instance variables should be used for information that must be reset or dereferenced
|
|
* every time the state is changed, but may need to be accessed externally.
|
|
*/
|
|
/**
|
|
* The currently selected stage.
|
|
*/
|
|
public var currentSong:Song;
|
|
|
|
/**
|
|
* The currently selected difficulty.
|
|
*/
|
|
public var currentDifficulty:String = Constants.DEFAULT_DIFFICULTY;
|
|
|
|
/**
|
|
* The currently selected variation.
|
|
*/
|
|
public var currentVariation:String = Constants.DEFAULT_VARIATION;
|
|
|
|
/**
|
|
* The currently selected instrumental ID.
|
|
* @default `''`
|
|
*/
|
|
public var currentInstrumental:String = '';
|
|
|
|
/**
|
|
* The currently active Stage. This is the object containing all the props.
|
|
*/
|
|
public var currentStage:Null<Stage> = null;
|
|
|
|
/**
|
|
* Gets set to true when the PlayState needs to reset (player opted to restart or died).
|
|
* Gets disabled once resetting happens.
|
|
*/
|
|
public var needsReset:Bool = false;
|
|
|
|
/**
|
|
* A timer that gets active once resetting happens. Used to vwoosh in notes.
|
|
*/
|
|
public var vwooshTimer:FlxTimer = new FlxTimer();
|
|
|
|
/**
|
|
* The current 'Blueball Counter' to display in the pause menu.
|
|
* Resets when you beat a song or go back to the main menu.
|
|
*/
|
|
public var deathCounter:Int = 0;
|
|
|
|
/**
|
|
* The player's current health.
|
|
*/
|
|
public var health:Float = Constants.HEALTH_STARTING;
|
|
|
|
/**
|
|
* The player's current score.
|
|
* TODO: Move this to its own class.
|
|
*/
|
|
public var songScore:Int = 0;
|
|
|
|
/**
|
|
* Start at this point in the song once the countdown is done.
|
|
* For example, if `startTimestamp` is `30000`, the song will start at the 30 second mark.
|
|
* Used for chart playtesting or practice.
|
|
*/
|
|
public var startTimestamp:Float = 0.0;
|
|
|
|
/**
|
|
* Play back the song at this speed.
|
|
* @default `1.0` for normal speed.
|
|
*/
|
|
public var playbackRate:Float = 1.0;
|
|
|
|
/**
|
|
* An empty FlxObject contained in the scene.
|
|
* The current gameplay camera will always follow this object. Tween its position to move the camera smoothly.
|
|
*
|
|
* It needs to be an object in the scene for the camera to be configured to follow it.
|
|
* We optionally make this a sprite so we can draw a debug graphic with it.
|
|
*/
|
|
public var cameraFollowPoint:FlxObject;
|
|
|
|
/**
|
|
* An FlxTween that tweens the camera to the follow point.
|
|
* Only used when tweening the camera manually, rather than tweening via follow.
|
|
*/
|
|
public var cameraFollowTween:Null<FlxTween>;
|
|
|
|
/**
|
|
* An FlxTween that zooms the camera to the desired amount.
|
|
*/
|
|
public var cameraZoomTween:Null<FlxTween>;
|
|
|
|
/**
|
|
* An FlxTween that changes the additive speed to the desired amount.
|
|
*/
|
|
public var scrollSpeedTweens:Array<FlxTween> = [];
|
|
|
|
/**
|
|
* The camera follow point from the last stage.
|
|
* Used to persist the position of the `cameraFollowPosition` between levels.
|
|
*/
|
|
public var previousCameraFollowPoint:Null<FlxPoint>;
|
|
|
|
/**
|
|
* The current camera zoom level without any modifiers applied.
|
|
*/
|
|
public var currentCameraZoom:Float = FlxCamera.defaultZoom;
|
|
|
|
/**
|
|
* Multiplier for currentCameraZoom for camera bops.
|
|
* Lerped back to 1.0x every frame.
|
|
*/
|
|
public var cameraBopMultiplier:Float = 1.0;
|
|
|
|
/**
|
|
* Default camera zoom for the current stage.
|
|
* If we aren't in a stage, just use the default zoom (1.05x).
|
|
*/
|
|
public var stageZoom(get, never):Float;
|
|
|
|
function get_stageZoom():Float
|
|
{
|
|
if (currentStage != null) return currentStage.camZoom;
|
|
else
|
|
return FlxCamera.defaultZoom * 1.05;
|
|
}
|
|
|
|
/**
|
|
* The current HUD camera zoom level.
|
|
*
|
|
* The camera zoom is increased every beat, and lerped back to this value every frame, creating a smooth 'zoom-in' effect.
|
|
*/
|
|
public var defaultHUDCameraZoom:Float = FlxCamera.defaultZoom * 1.0;
|
|
|
|
/**
|
|
* Camera bop intensity multiplier.
|
|
* Applied to cameraBopMultiplier on camera bops (usually every beat).
|
|
* @default `101.5%`
|
|
*/
|
|
public var cameraBopIntensity:Float = Constants.DEFAULT_BOP_INTENSITY;
|
|
|
|
/**
|
|
* Intensity of the HUD camera zoom.
|
|
* Need to make this a multiplier later. Just shoving in 0.015 for now so it doesn't break.
|
|
* @default `3.0%`
|
|
*/
|
|
public var hudCameraZoomIntensity:Float = 0.015 * 2.0;
|
|
|
|
/**
|
|
* How many beats (quarter notes) between camera zooms.
|
|
* @default One camera zoom per measure (four beats).
|
|
*/
|
|
public var cameraZoomRate:Int = Constants.DEFAULT_ZOOM_RATE;
|
|
|
|
/**
|
|
* How many beats (quarter notes) the zoom rate is offset.
|
|
* For if you want the zoom to happen off-beat.
|
|
* @default Zero beats (on-beat).
|
|
*/
|
|
public var cameraZoomRateOffset:Int = Constants.DEFAULT_ZOOM_OFFSET;
|
|
|
|
/**
|
|
* Whether the game is currently in the countdown before the song resumes.
|
|
*/
|
|
public var isInCountdown:Bool = false;
|
|
|
|
/**
|
|
* Whether the game is currently in Practice Mode.
|
|
* If true, player will not gain or lose score from notes.
|
|
*/
|
|
public var isPracticeMode:Bool = false;
|
|
|
|
/**
|
|
* Whether the game is currently in Bot Play Mode.
|
|
* If true, player will not gain or lose score from notes.
|
|
*/
|
|
public var isBotPlayMode:Bool = false;
|
|
|
|
/**
|
|
* Whether the player has dropped below zero health,
|
|
* and we are just waiting for an animation to play out before transitioning.
|
|
*/
|
|
public var isPlayerDying:Bool = false;
|
|
|
|
/**
|
|
* In Minimal Mode, the stage and characters are not loaded and a standard background is used.
|
|
*/
|
|
public var isMinimalMode:Bool = false;
|
|
|
|
/**
|
|
* Whether the game is currently in an animated cutscene, and gameplay should be stopped.
|
|
*/
|
|
public var isInCutscene:Bool = false;
|
|
|
|
/**
|
|
* Whether the inputs should be disabled for whatever reason...
|
|
* Used after the song ends, and in the Stage Editor.
|
|
*/
|
|
public var disableKeys:Bool = false;
|
|
|
|
/**
|
|
* The previous difficulty the player was playing on.
|
|
*/
|
|
public var previousDifficulty:String = Constants.DEFAULT_DIFFICULTY;
|
|
|
|
public var isSubState(get, never):Bool;
|
|
|
|
function get_isSubState():Bool
|
|
{
|
|
return this._parentState != null;
|
|
}
|
|
|
|
public var isChartingMode(get, never):Bool;
|
|
|
|
function get_isChartingMode():Bool
|
|
{
|
|
return this._parentState != null && Std.isOfType(this._parentState, ChartEditorState);
|
|
}
|
|
|
|
/**
|
|
* The current dialogue.
|
|
*/
|
|
public var currentConversation:Null<Conversation>;
|
|
|
|
/**
|
|
* Key press inputs which have been received but not yet processed.
|
|
* These are encoded with an OS timestamp, so we can account for input latency.
|
|
**/
|
|
var inputPressQueue:Array<PreciseInputEvent> = [];
|
|
|
|
/**
|
|
* Key release inputs which have been received but not yet processed.
|
|
* These are encoded with an OS timestamp, so we can account for input latency.
|
|
**/
|
|
var inputReleaseQueue:Array<PreciseInputEvent> = [];
|
|
|
|
/**
|
|
* If we just unpaused the game, we shouldn't be able to pause again for one frame.
|
|
*/
|
|
var justUnpaused:Bool = false;
|
|
|
|
/**
|
|
* The current note style used by the song.
|
|
*/
|
|
var noteStyle:NoteStyle;
|
|
|
|
/**
|
|
* PRIVATE INSTANCE VARIABLES
|
|
* Private instance variables should be used for information that must be reset or dereferenced
|
|
* every time the state is reset, but should not be accessed externally.
|
|
*/
|
|
/**
|
|
* The Array containing the upcoming song events.
|
|
* The `update()` function regularly shifts these out to trigger events.
|
|
*/
|
|
var songEvents:Array<SongEventData> = [];
|
|
|
|
/**
|
|
* If true, the player is allowed to pause the game.
|
|
* Disabled during the ending of a song.
|
|
*/
|
|
var mayPauseGame:Bool = true;
|
|
|
|
/**
|
|
* The displayed value of the player's health.
|
|
* Used to provide smooth animations based on linear interpolation of the player's health.
|
|
*/
|
|
var healthLerp:Float = Constants.HEALTH_STARTING;
|
|
|
|
/**
|
|
* How long the user has held the "Skip Video Cutscene" button for.
|
|
*/
|
|
var skipHeldTimer:Float = 0;
|
|
|
|
/**
|
|
* Whether the PlayState was started with instrumentals and vocals already provided.
|
|
* Used by the chart editor to prevent replacing the music.
|
|
*/
|
|
var overrideMusic:Bool = false;
|
|
|
|
/**
|
|
* Forcibly disables all update logic while the game moves back to the Menu state.
|
|
* This is used only when a critical error occurs and the game absolutely cannot continue.
|
|
*/
|
|
var criticalFailure:Bool = false;
|
|
|
|
/**
|
|
* False as long as the countdown has not finished yet.
|
|
*/
|
|
var startingSong:Bool = false;
|
|
|
|
/**
|
|
* Track if we currently have the music paused for a Pause substate, so we can unpause it when we return.
|
|
*/
|
|
var musicPausedBySubState:Bool = false;
|
|
|
|
/**
|
|
* Track any camera tweens we've paused for a Pause substate, so we can unpause them when we return.
|
|
*/
|
|
var cameraTweensPausedBySubState:List<FlxTween> = new List<FlxTween>();
|
|
|
|
/**
|
|
* Track any sounds we've paused for a Pause substate, so we can unpause them when we return.
|
|
*/
|
|
var soundsPausedBySubState:List<FlxSound> = new List<FlxSound>();
|
|
|
|
/**
|
|
* False until `create()` has completed.
|
|
*/
|
|
var initialized:Bool = false;
|
|
|
|
/**
|
|
* A group of audio tracks, used to play the song's vocals.
|
|
*/
|
|
public var vocals:Null<VoicesGroup>;
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
// Discord RPC variables
|
|
var discordRPCAlbum:String = '';
|
|
var discordRPCIcon:String = '';
|
|
#end
|
|
|
|
/**
|
|
* RENDER OBJECTS
|
|
*/
|
|
/**
|
|
* The FlxText which displays the current score.
|
|
*/
|
|
var scoreText:FlxText;
|
|
|
|
/**
|
|
* The bar which displays the player's health.
|
|
* Dynamically updated based on the value of `healthLerp` (which is based on `health`).
|
|
*/
|
|
public var healthBar:FlxBar;
|
|
|
|
/**
|
|
* The background image used for the health bar.
|
|
* Emma says the image is slightly skewed so I'm leaving it as an image instead of a `createGraphic`.
|
|
*/
|
|
public var healthBarBG:FunkinSprite;
|
|
|
|
/**
|
|
* The health icon representing the player.
|
|
*/
|
|
public var iconP1:Null<HealthIcon>;
|
|
|
|
/**
|
|
* The health icon representing the opponent.
|
|
*/
|
|
public var iconP2:Null<HealthIcon>;
|
|
|
|
/**
|
|
* The sprite group containing active player's strumline notes.
|
|
*/
|
|
public var playerStrumline:Strumline;
|
|
|
|
/**
|
|
* The sprite group containing opponent's strumline notes.
|
|
*/
|
|
public var opponentStrumline:Strumline;
|
|
|
|
/**
|
|
* The camera which contains, and controls visibility of, the user interface elements.
|
|
*/
|
|
public var camHUD:FlxCamera;
|
|
|
|
/**
|
|
* The camera which contains, and controls visibility of, the stage and characters.
|
|
*/
|
|
public var camGame:FlxCamera;
|
|
|
|
/**
|
|
* Simple helper debug variable, to be able to move the camera around for debug purposes
|
|
* without worrying about the camera tweening back to the follow point.
|
|
*/
|
|
public var debugUnbindCameraZoom:Bool = false;
|
|
|
|
/**
|
|
* The camera which contains, and controls visibility of, a video cutscene, dialogue, pause menu and sticker transition.
|
|
*/
|
|
public var camCutscene:FlxCamera;
|
|
|
|
/**
|
|
* The camera which contains, and controls visibility of menus when there are fake cutouts added.
|
|
*/
|
|
public var camCutouts:FlxCamera;
|
|
|
|
/**
|
|
* The combo popups. Includes the real-time combo counter and the rating.
|
|
*/
|
|
public var comboPopUps:PopUpStuff;
|
|
|
|
public var isSongEnd:Bool = false;
|
|
|
|
#if mobile
|
|
/**
|
|
* The pause button for the game, only appears in Mobile targets.
|
|
*/
|
|
var pauseButton:FunkinSprite;
|
|
|
|
/**
|
|
* The pause circle for the game, only appears in Mobile targets.
|
|
*/
|
|
var pauseCircle:FunkinSprite;
|
|
#end
|
|
|
|
/**
|
|
* PROPERTIES
|
|
*/
|
|
/**
|
|
* If a substate is rendering over the PlayState, it is paused and normal update logic is skipped.
|
|
* Examples include:
|
|
* - The Pause screen is open.
|
|
* - The Game Over screen is open.
|
|
* - The Chart Editor screen is open.
|
|
*/
|
|
var isGamePaused(get, never):Bool;
|
|
|
|
function get_isGamePaused():Bool
|
|
{
|
|
// Note: If there is a substate which requires the game to act unpaused,
|
|
// this should be changed to include something like `&& Std.isOfType()`
|
|
return this.subState != null;
|
|
}
|
|
|
|
var isExitingViaPauseMenu(get, never):Bool;
|
|
|
|
function get_isExitingViaPauseMenu():Bool
|
|
{
|
|
if (this.subState == null) return false;
|
|
if (!Std.isOfType(this.subState, PauseSubState)) return false;
|
|
|
|
var pauseSubState:PauseSubState = cast this.subState;
|
|
return !pauseSubState.allowInput;
|
|
}
|
|
|
|
/**
|
|
* Data for the current difficulty for the current song.
|
|
* Includes chart data, scroll speed, and other information.
|
|
*/
|
|
public var currentChart(get, never):Null<SongDifficulty>;
|
|
|
|
function get_currentChart():Null<SongDifficulty>
|
|
{
|
|
if (currentSong == null || currentDifficulty == null) return null;
|
|
return currentSong.getDifficulty(currentDifficulty, currentVariation);
|
|
}
|
|
|
|
/**
|
|
* The internal ID of the currently active Stage.
|
|
* Used to retrieve the data required to build the `currentStage`.
|
|
*/
|
|
public var currentStageId(get, never):String;
|
|
|
|
function get_currentStageId():String
|
|
{
|
|
var stage:String = currentChart?.stage ?? '';
|
|
return stage == '' ? Constants.DEFAULT_STAGE : stage;
|
|
}
|
|
|
|
/**
|
|
* The length of the current song, in milliseconds.
|
|
*/
|
|
var currentSongLengthMs(get, never):Float;
|
|
|
|
function get_currentSongLengthMs():Float
|
|
{
|
|
return FlxG.sound.music?.length ?? 0;
|
|
}
|
|
|
|
/**
|
|
* The threshold for resyncing the song.
|
|
* If the vocals deviate from the instrumental by more than this amount, then `resyncVocals()` will be called.
|
|
*/
|
|
static final RESYNC_THRESHOLD:Float = 40;
|
|
|
|
/**
|
|
* The threshold for how much the conductor lerp can drift from the music.
|
|
* If the conductor song position deviate from the music by more than this amount, then a normal conductor update is triggered.
|
|
*/
|
|
static final CONDUCTOR_DRIFT_THRESHOLD:Float = 65;
|
|
|
|
/**
|
|
* The ratio for easing the song positon for smoother notes scrolling.
|
|
*/
|
|
static final MUSIC_EASE_RATIO:Float = 42;
|
|
|
|
// TODO: Refactor or document
|
|
var generatedMusic:Bool = false;
|
|
|
|
var skipEndingTransition:Bool = false;
|
|
|
|
static final BACKGROUND_COLOR:FlxColor = FlxColor.BLACK;
|
|
|
|
/**
|
|
* Instantiate a new PlayState.
|
|
* @param params The parameters used to initialize the PlayState.
|
|
* Includes information about what song to play and more.
|
|
*/
|
|
public function new(?params:PlayStateParams)
|
|
{
|
|
super();
|
|
|
|
// Validate parameters.
|
|
var params:PlayStateParams = params ??
|
|
{
|
|
trace('WARNING: PlayState constructor called with no parameters. Reusing previous parameters.');
|
|
lastParams ?? throw 'PlayState constructor called with no available parameters.';
|
|
}
|
|
lastParams = params;
|
|
|
|
// Apply parameters.
|
|
currentSong = params.targetSong ?? throw "targetSong should not be null";
|
|
if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty;
|
|
previousDifficulty = currentDifficulty;
|
|
if (params.targetVariation != null) currentVariation = params.targetVariation;
|
|
if (params.targetInstrumental != null) currentInstrumental = params.targetInstrumental;
|
|
isPracticeMode = params.practiceMode ?? false;
|
|
isBotPlayMode = params.botPlayMode ?? false;
|
|
isMinimalMode = params.minimalMode ?? false;
|
|
startTimestamp = params.startTimestamp ?? 0.0;
|
|
playbackRate = params.playbackRate ?? 1.0;
|
|
overrideMusic = params.overrideMusic ?? false;
|
|
previousCameraFollowPoint = params.cameraFollowPoint;
|
|
|
|
// Basic object initialization
|
|
|
|
// TODO: Add something to toggle this on!
|
|
if (false)
|
|
{
|
|
// Displays the camera follow point as a sprite for debug purposes.
|
|
var cameraFollowPoint = new FunkinSprite(0, 0);
|
|
cameraFollowPoint.makeSolidColor(8, 8, 0xFF00FF00);
|
|
cameraFollowPoint.visible = false;
|
|
cameraFollowPoint.zIndex = 1000000;
|
|
this.cameraFollowPoint = cameraFollowPoint;
|
|
}
|
|
else
|
|
{
|
|
// Camera follow point is an invisible point in space.
|
|
cameraFollowPoint = new FlxObject(0, 0);
|
|
}
|
|
|
|
// Cameras
|
|
camGame = new FunkinCamera('playStateCamGame');
|
|
camHUD = new FlxCamera();
|
|
camCutscene = new FlxCamera();
|
|
camCutouts = new FlxCamera();
|
|
|
|
var currentChart = currentSong.getDifficulty(currentDifficulty, currentVariation);
|
|
var noteStyleId:Null<String> = currentChart?.noteStyle;
|
|
var nulNoteStyle:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry(noteStyleId ?? Constants.DEFAULT_NOTE_STYLE);
|
|
if (nulNoteStyle == null) throw "Failed to retrieve both note style and default note style. This shouldn't happen!";
|
|
noteStyle = nulNoteStyle;
|
|
|
|
// Strumlines
|
|
playerStrumline = new Strumline(noteStyle, !isBotPlayMode, currentChart?.scrollSpeed);
|
|
opponentStrumline = new Strumline(noteStyle, false, currentChart?.scrollSpeed);
|
|
|
|
// Healthbar
|
|
healthBarBG = FunkinSprite.create(0, 0, 'healthBar');
|
|
healthBar = new FlxBar(0, 0, RIGHT_TO_LEFT, Std.int(healthBarBG.width - 8), Std.int(healthBarBG.height - 8), null, 0, 2);
|
|
scoreText = new FlxText(0, 0, 0, '', 20);
|
|
|
|
// Combo & Pop Up
|
|
comboPopUps = new PopUpStuff(noteStyle);
|
|
|
|
// Pause sprites
|
|
#if mobile
|
|
pauseButton = FunkinSprite.createSparrow(0, 0, "pauseButton");
|
|
pauseCircle = FunkinSprite.create(0, 0, 'pauseCircle');
|
|
#end
|
|
|
|
// Don't do anything else here! Wait until create() when we attach to the camera.
|
|
}
|
|
|
|
/**
|
|
* Called when the PlayState is switched to.
|
|
*/
|
|
@:nullSafety(Off)
|
|
public override function create():Void
|
|
{
|
|
if (instance != null)
|
|
{
|
|
// TODO: Do something in this case? IDK.
|
|
trace('WARNING: PlayState instance already exists. This should not happen.');
|
|
}
|
|
instance = this;
|
|
#if !mobile
|
|
// TODO: Figure out how to do the flair for charting mode!! I can't figure it out for the love of god. -Zack
|
|
if (!isChartingMode) FlxG.autoPause = false;
|
|
#end
|
|
|
|
if (!assertChartExists()) return;
|
|
|
|
#if mobile
|
|
// Force allowScreenTimeout to be disabled
|
|
lime.system.System.allowScreenTimeout = false;
|
|
#end
|
|
|
|
// This state receives update() even when a substate is active.
|
|
this.persistentUpdate = true;
|
|
// This state receives draw calls even when a substate is active.
|
|
this.persistentDraw = true;
|
|
|
|
// Stop any pre-existing music.
|
|
if (!overrideMusic)
|
|
{
|
|
if (FlxG.sound.music != null) FlxG.sound.music.stop();
|
|
|
|
// Prepare the current song's instrumental and vocals to be played.
|
|
currentChart.cacheInst(currentInstrumental);
|
|
currentChart.cacheVocals();
|
|
}
|
|
|
|
// Prepare the Conductor.
|
|
Conductor.instance.forceBPM(null);
|
|
|
|
if (currentChart.offsets != null)
|
|
{
|
|
Conductor.instance.instrumentalOffset = currentChart.offsets.getInstrumentalOffset(currentInstrumental);
|
|
}
|
|
|
|
Conductor.instance.mapTimeChanges(currentChart.timeChanges);
|
|
var pre:Float = (Conductor.instance.beatLengthMs * -5) + startTimestamp;
|
|
|
|
trace('Attempting to start at ' + pre);
|
|
|
|
Conductor.instance.update(pre);
|
|
|
|
// The song is now loaded. We can continue to initialize the play state.
|
|
initCameras();
|
|
initHealthBar();
|
|
if (!isMinimalMode)
|
|
{
|
|
initStage();
|
|
initCharacters();
|
|
}
|
|
else
|
|
{
|
|
initMinimalMode();
|
|
}
|
|
initStrumlines();
|
|
initPopups();
|
|
|
|
#if mobile
|
|
if (!ControlsHandler.usingExternalInputDevice)
|
|
{
|
|
// Initialize the hitbox for mobile controls
|
|
addHitbox(false);
|
|
if (hitbox != null)
|
|
{
|
|
hitbox.isPixel = currentChart.noteStyle == "pixel";
|
|
|
|
if (Preferences.controlsScheme == FunkinHitboxControlSchemes.Arrows)
|
|
{
|
|
for (direction in Strumline.DIRECTIONS)
|
|
{
|
|
hitbox.getFirstHintByDirection(direction).follow(playerStrumline.getByDirection(direction));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// The camera is still needed for the pause button!
|
|
camControls = new FunkinCamera('camControls');
|
|
FlxG.cameras.add(camControls, false);
|
|
camControls.bgColor = 0x0;
|
|
}
|
|
#end
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
// Initialize Discord Rich Presence.
|
|
initDiscord();
|
|
#end
|
|
|
|
// Read the song's note data and pass it to the strumlines.
|
|
generateSong();
|
|
|
|
// Reset the camera's zoom and force it to focus on the camera follow point.
|
|
resetCamera();
|
|
|
|
initPreciseInputs();
|
|
|
|
FlxG.worldBounds.set(0, 0, FlxG.width, FlxG.height);
|
|
|
|
// The song is loaded and in the process of starting.
|
|
// This gets set back to false when the chart actually starts.
|
|
startingSong = true;
|
|
|
|
// TODO: We hardcoded the transition into Winter Horrorland. Do this with a ScriptedSong instead.
|
|
if ((currentSong.id ?? '').toLowerCase() == 'winter-horrorland')
|
|
{
|
|
// VanillaCutscenes will call startCountdown later.
|
|
VanillaCutscenes.playHorrorStartCutscene();
|
|
}
|
|
else
|
|
{
|
|
// Call a script event to start the countdown.
|
|
// Songs with cutscenes should call event.cancel().
|
|
// As long as they call `PlayState.instance.startCountdown()` later, the countdown will start.
|
|
startCountdown();
|
|
}
|
|
|
|
// Create the pause button.
|
|
#if mobile
|
|
initPauseSprites();
|
|
#end
|
|
|
|
// Do this last to prevent beatHit from being called before create() is done.
|
|
super.create();
|
|
|
|
leftWatermarkText.cameras = [camHUD];
|
|
rightWatermarkText.cameras = [camHUD];
|
|
|
|
// Initialize some debug stuff.
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
// Display the version number (and git commit hash) in the bottom right corner.
|
|
this.rightWatermarkText.text = Constants.VERSION;
|
|
|
|
FlxG.console.registerObject('playState', this);
|
|
#end
|
|
|
|
initialized = true;
|
|
|
|
// This step ensures z-indexes are applied properly,
|
|
// and it's important to call it last so all elements get affected.
|
|
refresh();
|
|
}
|
|
|
|
public function togglePauseButton(visible:Bool = false):Void
|
|
{
|
|
#if mobile
|
|
pauseCircle.alpha = visible ? 0.1 : 0;
|
|
pauseButton.alpha = visible ? 1 : 0;
|
|
#end
|
|
}
|
|
|
|
function assertChartExists():Bool
|
|
{
|
|
// Returns null if the song failed to load or doesn't have the selected difficulty.
|
|
if (currentSong == null || currentChart == null || currentChart?.notes == null)
|
|
{
|
|
// We have encountered a critical error. Prevent Flixel from trying to run any gameplay logic.
|
|
criticalFailure = true;
|
|
|
|
// Choose an error message.
|
|
var message:String = 'There was a critical error. Click OK to return to the main menu.';
|
|
if (currentSong == null)
|
|
{
|
|
message = 'There was a critical error loading this song\'s chart. Click OK to return to the main menu.';
|
|
}
|
|
else if (currentDifficulty == null)
|
|
{
|
|
message = 'There was a critical error selecting a difficulty for this song. Click OK to return to the main menu.';
|
|
}
|
|
else if (currentChart == null)
|
|
{
|
|
message = 'There was a critical error retrieving data for this song on "$currentDifficulty" difficulty with variation "$currentVariation". Click OK to return to the main menu.';
|
|
}
|
|
else if (currentChart?.notes == null)
|
|
{
|
|
message = 'There was a critical error retrieving note data for this song on "$currentDifficulty" difficulty with variation "$currentVariation". Click OK to return to the main menu.';
|
|
}
|
|
|
|
// Display a popup. This blocks the application until the user clicks OK.
|
|
funkin.util.WindowUtil.showError('Error loading PlayState', message);
|
|
|
|
// Force the user back to the main menu.
|
|
if (isSubState)
|
|
{
|
|
this.close();
|
|
}
|
|
else
|
|
{
|
|
if (currentStage != null) this.remove(currentStage);
|
|
FlxG.switchState(() -> new MainMenuState());
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public override function update(elapsed:Float):Void
|
|
{
|
|
if (criticalFailure) return;
|
|
|
|
super.update(elapsed);
|
|
|
|
updateHealthBar();
|
|
updateScoreText();
|
|
|
|
// Handle restarting the song when needed (player death or pressing Retry)
|
|
if (needsReset)
|
|
{
|
|
if (!assertChartExists()) return;
|
|
|
|
prevScrollTargets = [];
|
|
|
|
var retryEvent = new SongRetryEvent(currentDifficulty);
|
|
|
|
previousDifficulty = currentDifficulty;
|
|
|
|
dispatchEvent(retryEvent);
|
|
|
|
resetCamera();
|
|
|
|
var fromDeathState = isPlayerDying;
|
|
|
|
persistentUpdate = true;
|
|
persistentDraw = true;
|
|
|
|
startingSong = true;
|
|
isPlayerDying = false;
|
|
|
|
// Reset music properly.
|
|
if (FlxG.sound.music != null)
|
|
{
|
|
FlxG.sound.music.pause();
|
|
FlxG.sound.music.time = startTimestamp;
|
|
FlxG.sound.music.pitch = playbackRate;
|
|
}
|
|
|
|
if (!overrideMusic && vocals != null)
|
|
{
|
|
// Stop the vocals if they already exist.
|
|
vocals.stop();
|
|
vocals = currentChart?.buildVocals(currentInstrumental);
|
|
|
|
if (vocals?.members?.length == 0)
|
|
{
|
|
trace('WARNING: No vocals found for this song.');
|
|
}
|
|
}
|
|
|
|
if (FlxG.sound.music != null) FlxG.sound.music.volume = 1;
|
|
|
|
if (vocals != null)
|
|
{
|
|
vocals.pause();
|
|
vocals.time = startTimestamp - Conductor.instance.instrumentalOffset;
|
|
|
|
vocals.volume = 1;
|
|
vocals.playerVolume = 1;
|
|
vocals.opponentVolume = 1;
|
|
}
|
|
|
|
currentStage?.resetStage();
|
|
|
|
if (!fromDeathState)
|
|
{
|
|
playerStrumline.vwooshNotes();
|
|
opponentStrumline.vwooshNotes();
|
|
}
|
|
|
|
playerStrumline.clean();
|
|
opponentStrumline.clean();
|
|
|
|
// Delete all notes and reset the arrays.
|
|
regenNoteData();
|
|
|
|
// Reset camera zooming
|
|
cameraBopIntensity = Constants.DEFAULT_BOP_INTENSITY;
|
|
hudCameraZoomIntensity = (cameraBopIntensity - 1.0) * 2.0;
|
|
cameraZoomRate = Constants.DEFAULT_ZOOM_RATE;
|
|
|
|
health = Constants.HEALTH_STARTING;
|
|
songScore = 0;
|
|
Highscore.tallies.combo = 0;
|
|
|
|
// so the song doesn't start too early :D
|
|
var vwooshDelay:Float = 0.5;
|
|
Conductor.instance.update(-vwooshDelay * 1000 + startTimestamp + Conductor.instance.beatLengthMs * -5);
|
|
|
|
// timer for vwoosh
|
|
vwooshTimer.start(vwooshDelay, function(_) {
|
|
if (playerStrumline.notes.length == 0) playerStrumline.updateNotes();
|
|
if (opponentStrumline.notes.length == 0) opponentStrumline.updateNotes();
|
|
playerStrumline.vwooshInNotes();
|
|
opponentStrumline.vwooshInNotes();
|
|
Countdown.performCountdown();
|
|
});
|
|
|
|
// Stops any existing countdown.
|
|
Countdown.stopCountdown();
|
|
|
|
// Reset the health icons.
|
|
currentStage?.getBoyfriend()?.initHealthIcon(false);
|
|
currentStage?.getDad()?.initHealthIcon(true);
|
|
|
|
needsReset = false;
|
|
}
|
|
|
|
// Update the conductor.
|
|
if (startingSong)
|
|
{
|
|
if (isInCountdown)
|
|
{
|
|
// Do NOT apply offsets at this point, because they already got applied the previous frame!
|
|
Conductor.instance.update(Conductor.instance.songPosition + elapsed * 1000, false);
|
|
if (Conductor.instance.songPosition >= (startTimestamp + Conductor.instance.combinedOffset))
|
|
{
|
|
trace("started song at " + Conductor.instance.songPosition);
|
|
startSong();
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (Constants.EXT_SOUND == 'mp3')
|
|
{
|
|
Conductor.instance.formatOffset = Constants.MP3_DELAY_MS;
|
|
}
|
|
else
|
|
{
|
|
Conductor.instance.formatOffset = 0.0;
|
|
}
|
|
|
|
// Lime has some precision loss when getting the sound current position
|
|
// Since the notes scrolling is dependant on the sound time that caused it to appear "stuttery" for some people
|
|
// As a workaround for that, we lerp the conductor position to the music time to fill the gap in this lost precision making the scrolling smoother
|
|
// The previous method where it "guessed" the song position based on the elapsed time had some flaws
|
|
// Somtimes the songPosition would exceed the music length causing issues in other places
|
|
// And it was frame dependant which we don't like!!
|
|
if (FlxG.sound.music.playing)
|
|
{
|
|
final audioDiff:Float = Math.round(Math.abs(FlxG.sound.music.time - (Conductor.instance.songPosition - Conductor.instance.combinedOffset)));
|
|
if (audioDiff <= CONDUCTOR_DRIFT_THRESHOLD)
|
|
{
|
|
// Only do neat & smooth lerps as long as the lerp doesn't fuck up and go WAY behind the music time triggering false resyncs
|
|
final easeRatio:Float = 1.0 - Math.exp(-(MUSIC_EASE_RATIO * playbackRate) * elapsed);
|
|
Conductor.instance.update(FlxMath.lerp(Conductor.instance.songPosition, FlxG.sound.music.time + Conductor.instance.combinedOffset, easeRatio), false);
|
|
}
|
|
else
|
|
{
|
|
// Fallback to properly update the conductor incase the lerp messed up
|
|
// Shouldn't be fallen back to unless you're lagging alot
|
|
trace('[WARNING] Normal Conductor Update!! are you lagging?');
|
|
Conductor.instance.update();
|
|
}
|
|
}
|
|
}
|
|
|
|
var pauseButtonCheck:Bool = false;
|
|
var androidPause:Bool = false;
|
|
// So the player wouldn't miss when pressing the pause utton
|
|
#if mobile
|
|
pauseButtonCheck = TouchUtil.pressAction(pauseButton);
|
|
#end
|
|
|
|
#if android
|
|
androidPause = FlxG.android.justReleased.BACK;
|
|
#end
|
|
|
|
// Attempt to pause the game.
|
|
if ((controls.PAUSE || androidPause || pauseButtonCheck)) pause();
|
|
|
|
#if mobile
|
|
if (justUnpaused)
|
|
{
|
|
// pauseButton.alpha = 1;
|
|
// pauseCircle.alpha = 0.1;
|
|
|
|
FlxTween.cancelTweensOf(pauseButton);
|
|
FlxTween.cancelTweensOf(pauseCircle);
|
|
|
|
FlxTween.tween(pauseButton, {alpha: 1}, 0.25, {ease: FlxEase.quartOut});
|
|
FlxTween.tween(pauseCircle, {alpha: 0.1}, 0.25, {ease: FlxEase.quartOut});
|
|
|
|
if (!startingSong && hitbox != null) hitbox.visible = true;
|
|
}
|
|
#end
|
|
|
|
// Cap health.
|
|
if (health > Constants.HEALTH_MAX) health = Constants.HEALTH_MAX;
|
|
if (health < Constants.HEALTH_MIN) health = Constants.HEALTH_MIN;
|
|
|
|
// Apply camera zoom + multipliers.
|
|
if (subState == null && cameraZoomRate > 0.0) // && !isInCutscene)
|
|
{
|
|
cameraBopMultiplier = FlxMath.lerp(1.0, cameraBopMultiplier, 0.95); // Lerp bop multiplier back to 1.0x
|
|
var zoomPlusBop = currentCameraZoom * cameraBopMultiplier; // Apply camera bop multiplier.
|
|
if (!debugUnbindCameraZoom) FlxG.camera.zoom = zoomPlusBop; // Actually apply the zoom to the camera.
|
|
|
|
camHUD.zoom = FlxMath.lerp(defaultHUDCameraZoom, camHUD.zoom, 0.95);
|
|
}
|
|
|
|
if (currentStage != null && currentStage.getBoyfriend() != null)
|
|
{
|
|
FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation());
|
|
}
|
|
FlxG.watch.addQuick('health', health);
|
|
FlxG.watch.addQuick('cameraBopIntensity', cameraBopIntensity);
|
|
|
|
// TODO: Add a song event for Handle GF dance speed.
|
|
|
|
// Handle player death.
|
|
if (!isInCutscene && !disableKeys)
|
|
{
|
|
// RESET = Quick Game Over Screen
|
|
if (controls.RESET)
|
|
{
|
|
health = Constants.HEALTH_MIN;
|
|
trace('RESET = True');
|
|
}
|
|
|
|
#if CAN_CHEAT // brandon's a pussy
|
|
if (controls.CHEAT)
|
|
{
|
|
health += 0.25 * Constants.HEALTH_MAX; // +25% health.
|
|
trace('User is cheating!');
|
|
}
|
|
#end
|
|
|
|
if (health <= Constants.HEALTH_MIN && !isPracticeMode && !isPlayerDying)
|
|
{
|
|
vocals?.pause();
|
|
|
|
if (FlxG.sound.music != null) FlxG.sound.music.pause();
|
|
|
|
deathCounter += 1;
|
|
#if FEATURE_NEWGROUNDS
|
|
Events.logFailSong(currentSong.id, currentVariation);
|
|
#end
|
|
|
|
dispatchEvent(new ScriptEvent(GAME_OVER));
|
|
|
|
// Disable updates, preventing animations in the background from playing.
|
|
persistentUpdate = false;
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
if (FlxG.keys.pressed.THREE)
|
|
{
|
|
// TODO: Change the key or delete this?
|
|
// In debug builds, pressing 3 to kill the player makes the background transparent.
|
|
persistentDraw = true;
|
|
}
|
|
else
|
|
{
|
|
#end
|
|
persistentDraw = false;
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
}
|
|
#end
|
|
|
|
isPlayerDying = true;
|
|
|
|
#if FEATURE_MOBILE_ADVERTISEMENTS
|
|
if (AdMobUtil.PLAYING_COUNTER < AdMobUtil.MAX_BEFORE_AD) AdMobUtil.PLAYING_COUNTER++;
|
|
#end
|
|
|
|
var deathPreTransitionDelay = currentStage?.getBoyfriend()?.getDeathPreTransitionDelay() ?? 0.0;
|
|
if (deathPreTransitionDelay > 0)
|
|
{
|
|
new FlxTimer().start(deathPreTransitionDelay, function(_) {
|
|
moveToGameOver();
|
|
});
|
|
}
|
|
else
|
|
{
|
|
// Transition immediately.
|
|
moveToGameOver();
|
|
}
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
details: 'Game Over - ${buildDiscordRPCDetails()}',
|
|
state: buildDiscordRPCState(),
|
|
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
#end
|
|
}
|
|
else if (isPlayerDying)
|
|
{
|
|
// Wait up.
|
|
}
|
|
}
|
|
|
|
processSongEvents();
|
|
|
|
// Handle keybinds.
|
|
processInputQueue();
|
|
if (!isInCutscene && !disableKeys) debugKeyShit();
|
|
if (isInCutscene && !disableKeys) handleCutsceneKeys(elapsed);
|
|
|
|
// Moving notes into position is now done by Strumline.update().
|
|
if (!isInCutscene) processNotes(elapsed);
|
|
|
|
#if mobile
|
|
if ((VideoCutscene.isPlaying() || isInCutscene) && !pauseButton.visible) pauseButton.visible = true;
|
|
pauseCircle.visible = pauseButton.visible;
|
|
#end
|
|
|
|
justUnpaused = false;
|
|
#if !mobile
|
|
if (Preferences.autoPause) FlxG.autoPause = !mayPauseGame;
|
|
#end
|
|
}
|
|
|
|
function pause(mode:PauseMode = Standard):Void
|
|
{
|
|
if (!mayPauseGame || justUnpaused || isGamePaused || isPlayerDying) return;
|
|
|
|
switch (mode)
|
|
{
|
|
case Conversation:
|
|
preparePauseUI();
|
|
openPauseSubState(Conversation, FullScreenScaleMode.hasFakeCutouts ? camCutouts : camCutscene, () -> currentConversation?.pauseMusic());
|
|
|
|
case Cutscene:
|
|
preparePauseUI();
|
|
openPauseSubState(Cutscene, FullScreenScaleMode.hasFakeCutouts ? camCutouts : camCutscene, () -> VideoCutscene.pauseVideo());
|
|
|
|
default: // also known as standard
|
|
if (!isInCountdown || isInCutscene) return;
|
|
|
|
Countdown.pauseCountdown();
|
|
preparePauseUI();
|
|
|
|
final event = new PauseScriptEvent(FlxG.random.bool(1 / 1000 * 100));
|
|
dispatchEvent(event);
|
|
|
|
if (!event.eventCanceled)
|
|
{
|
|
persistentUpdate = false;
|
|
persistentDraw = true;
|
|
|
|
if (!isSubState && event.gitaroo)
|
|
{
|
|
if (currentStage != null) this.remove(currentStage);
|
|
FlxG.switchState(() -> new GitarooPause(lastParams));
|
|
}
|
|
else
|
|
{
|
|
var boyfriendPos:FlxPoint = new FlxPoint(0, 0);
|
|
|
|
// Prevent the game from crashing if Boyfriend isn't present.
|
|
if (currentStage != null && currentStage.getBoyfriend() != null)
|
|
{
|
|
boyfriendPos = currentStage.getBoyfriend().getScreenPosition();
|
|
}
|
|
|
|
openPauseSubState(isChartingMode ? Charting : Standard, camCutscene);
|
|
}
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
details: 'Paused - ${buildDiscordRPCDetails()}',
|
|
state: buildDiscordRPCState(),
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
#end
|
|
}
|
|
}
|
|
}
|
|
|
|
function preparePauseUI():Void
|
|
{
|
|
#if mobile
|
|
FlxTween.cancelTweensOf(pauseButton);
|
|
FlxTween.cancelTweensOf(pauseCircle);
|
|
pauseButton.alpha = 0;
|
|
pauseCircle.alpha = 0;
|
|
if (hitbox != null) hitbox.visible = false;
|
|
#end
|
|
}
|
|
|
|
function openPauseSubState(mode:PauseMode, cam:FlxCamera, ?onPause:Void->Void):Void
|
|
{
|
|
final pauseSubState = new PauseSubState({mode: mode}, onPause);
|
|
FlxTransitionableState.skipNextTransIn = true;
|
|
FlxTransitionableState.skipNextTransOut = true;
|
|
pauseSubState.camera = cam;
|
|
persistentUpdate = false;
|
|
openSubState(pauseSubState);
|
|
}
|
|
|
|
function moveToGameOver():Void
|
|
{
|
|
// Reset and update a bunch of values in advance for the transition back from the game over substate.
|
|
playerStrumline.clean();
|
|
opponentStrumline.clean();
|
|
|
|
vwooshTimer.cancel();
|
|
|
|
songScore = 0;
|
|
updateScoreText();
|
|
|
|
health = Constants.HEALTH_STARTING;
|
|
healthLerp = health;
|
|
|
|
healthBar.value = healthLerp;
|
|
|
|
if (!isMinimalMode)
|
|
{
|
|
iconP1?.updatePosition();
|
|
iconP2?.updatePosition();
|
|
}
|
|
|
|
// Transition to the game over substate.
|
|
var gameOverSubState = new GameOverSubState(
|
|
{
|
|
isChartingMode: isChartingMode,
|
|
transparent: persistentDraw
|
|
});
|
|
FlxTransitionableState.skipNextTransIn = true;
|
|
FlxTransitionableState.skipNextTransOut = true;
|
|
openSubState(gameOverSubState);
|
|
}
|
|
|
|
function processSongEvents():Void
|
|
{
|
|
// Query and activate song events.
|
|
// TODO: Check that these work appropriately even when songPosition is less than 0, to play events during countdown.
|
|
if (songEvents.length > 0)
|
|
{
|
|
var songEventsToActivate:Array<SongEventData> = SongEventRegistry.queryEvents(songEvents, Conductor.instance.songPosition);
|
|
|
|
if (songEventsToActivate.length > 0)
|
|
{
|
|
trace('Found ${songEventsToActivate.length} event(s) to activate.');
|
|
for (event in songEventsToActivate)
|
|
{
|
|
// If an event is trying to play, but it's over 1 second old, skip it.
|
|
var eventAge:Float = Conductor.instance.songPosition - event.time;
|
|
if (eventAge > 1000)
|
|
{
|
|
event.activated = true;
|
|
continue;
|
|
};
|
|
|
|
var eventEvent:SongEventScriptEvent = new SongEventScriptEvent(event);
|
|
dispatchEvent(eventEvent);
|
|
// Calling event.cancelEvent() skips the event. Neat!
|
|
if (!eventEvent.eventCanceled)
|
|
{
|
|
SongEventRegistry.handleEvent(event);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public override function dispatchEvent(event:ScriptEvent):Void
|
|
{
|
|
// ORDER: Module, Stage, Character, Song, Conversation, Note
|
|
// Modules should get the first chance to cancel the event.
|
|
|
|
// super.dispatchEvent(event) dispatches event to module scripts.
|
|
super.dispatchEvent(event);
|
|
|
|
// Dispatch event to note kind scripts
|
|
NoteKindManager.callEvent(event);
|
|
|
|
// Dispatch event to stage script.
|
|
ScriptEventDispatcher.callEvent(currentStage, event);
|
|
|
|
// Dispatch event to character script(s).
|
|
if (currentStage != null) currentStage.dispatchToCharacters(event);
|
|
|
|
// Dispatch event to song script.
|
|
ScriptEventDispatcher.callEvent(currentSong, event);
|
|
|
|
// Dispatch event to conversation script.
|
|
ScriptEventDispatcher.callEvent(currentConversation, event);
|
|
}
|
|
|
|
/**
|
|
* Function called before opening a new substate.
|
|
* @param subState The substate to open.
|
|
*/
|
|
public override function openSubState(subState:FlxSubState):Void
|
|
{
|
|
// If there is a substate which requires the game to continue,
|
|
// then make this a condition.
|
|
var shouldPause:Bool = (Std.isOfType(subState, PauseSubState) || Std.isOfType(subState, GameOverSubState));
|
|
|
|
if (shouldPause)
|
|
{
|
|
// Pause the music.
|
|
if (FlxG.sound.music != null)
|
|
{
|
|
if (FlxG.sound.music.playing)
|
|
{
|
|
FlxG.sound.music.pause();
|
|
musicPausedBySubState = true;
|
|
}
|
|
|
|
// Pause any sounds that are playing and keep track of them.
|
|
// Vocals are also paused here but are not included as they are handled separately.
|
|
if (Std.isOfType(subState, PauseSubState))
|
|
{
|
|
FlxG.sound.list.forEachAlive(function(sound:FlxSound) {
|
|
if (!sound.active || sound == FlxG.sound.music) return;
|
|
// In case it's a scheduled sound
|
|
if (Std.isOfType(sound, FunkinSound))
|
|
{
|
|
var funkinSound:FunkinSound = cast sound;
|
|
if (funkinSound != null && !funkinSound.isPlaying) return;
|
|
}
|
|
if (!sound.playing && sound.time >= 0) return;
|
|
sound.pause();
|
|
soundsPausedBySubState.add(sound);
|
|
});
|
|
|
|
vocals?.forEach(function(voice:FunkinSound) {
|
|
soundsPausedBySubState.remove(voice);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
vocals?.pause();
|
|
}
|
|
}
|
|
|
|
if (!vwooshTimer.finished) vwooshTimer.active = false;
|
|
|
|
// Pause camera tweening, and keep track of which tweens we pause.
|
|
if (cameraFollowTween != null && cameraFollowTween.active)
|
|
{
|
|
cameraFollowTween.active = false;
|
|
cameraTweensPausedBySubState.add(cameraFollowTween);
|
|
}
|
|
|
|
if (cameraZoomTween != null && cameraZoomTween.active)
|
|
{
|
|
cameraZoomTween.active = false;
|
|
cameraTweensPausedBySubState.add(cameraZoomTween);
|
|
}
|
|
|
|
// Pause camera follow
|
|
FlxG.camera.followLerp = 0;
|
|
|
|
for (tween in scrollSpeedTweens)
|
|
{
|
|
if (tween != null && tween.active)
|
|
{
|
|
tween.active = false;
|
|
cameraTweensPausedBySubState.add(tween);
|
|
}
|
|
}
|
|
}
|
|
|
|
super.openSubState(subState);
|
|
}
|
|
|
|
/**
|
|
* Function called before closing the current substate.
|
|
* @param subState
|
|
*/
|
|
public override function closeSubState():Void
|
|
{
|
|
if (Std.isOfType(subState, PauseSubState))
|
|
{
|
|
var event:ScriptEvent = new ScriptEvent(RESUME, true);
|
|
|
|
dispatchEvent(event);
|
|
|
|
if (event.eventCanceled) return;
|
|
|
|
// Resume vwooshTimer
|
|
if (!vwooshTimer.finished) vwooshTimer.active = true;
|
|
|
|
// Resume music if we paused it.
|
|
if (musicPausedBySubState)
|
|
{
|
|
FlxG.sound.music.play();
|
|
musicPausedBySubState = false;
|
|
}
|
|
|
|
// The logic here is that if this sound doesn't auto-destroy
|
|
// then it's gonna be reused somewhere, so we just stop it instead.
|
|
forEachPausedSound(s -> needsReset ? (s.autoDestroy ? s.destroy() : s.stop()) : s.resume());
|
|
|
|
// Resume camera tweens if we paused any.
|
|
for (camTween in cameraTweensPausedBySubState)
|
|
{
|
|
camTween.active = true;
|
|
}
|
|
cameraTweensPausedBySubState.clear();
|
|
|
|
// Resume camera follow
|
|
FlxG.camera.followLerp = Constants.DEFAULT_CAMERA_FOLLOW_RATE;
|
|
|
|
if (currentConversation != null)
|
|
{
|
|
currentConversation.resumeMusic();
|
|
}
|
|
|
|
// Re-sync vocals.
|
|
if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals();
|
|
|
|
// Resume the countdown.
|
|
Countdown.resumeCountdown();
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
if (Conductor.instance.songPosition > 0)
|
|
{
|
|
// DiscordClient.changePresence(detailsText, '${currentChart.songName} ($discordRPCDifficulty)', discordRPCIcon, true,
|
|
// currentSongLengthMs - Conductor.instance.songPosition);
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
state: buildDiscordRPCState(),
|
|
details: buildDiscordRPCDetails(),
|
|
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
}
|
|
else
|
|
{
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
state: buildDiscordRPCState(),
|
|
details: buildDiscordRPCDetails(),
|
|
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
}
|
|
#end
|
|
|
|
justUnpaused = true;
|
|
}
|
|
else if (Std.isOfType(subState, Transition))
|
|
{
|
|
// Do nothing.
|
|
}
|
|
|
|
super.closeSubState();
|
|
}
|
|
|
|
/**
|
|
* Function called when the game window gains focus.
|
|
*/
|
|
public override function onFocus():Void
|
|
{
|
|
if (VideoCutscene.isPlaying() && Preferences.autoPause && isGamePaused) VideoCutscene.pauseVideo();
|
|
#if html5
|
|
else if (Preferences.autoPause) VideoCutscene.resumeVideo();
|
|
#end
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
if (health > Constants.HEALTH_MIN && !isGamePaused && Preferences.autoPause)
|
|
{
|
|
if (Conductor.instance.songPosition > 0.0)
|
|
{
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
state: buildDiscordRPCState(),
|
|
details: buildDiscordRPCDetails(),
|
|
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
}
|
|
else
|
|
{
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
state: buildDiscordRPCState(),
|
|
details: buildDiscordRPCDetails(),
|
|
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
// DiscordClient.changePresence(detailsText, '${currentChart.songName} ($discordRPCDifficulty)', discordRPCIcon, true,
|
|
// currentSongLengthMs - Conductor.instance.songPosition);
|
|
}
|
|
}
|
|
#end
|
|
|
|
super.onFocus();
|
|
}
|
|
|
|
/**
|
|
* Function called when the game window loses focus.
|
|
*/
|
|
public override function onFocusLost():Void
|
|
{
|
|
#if html5
|
|
if (Preferences.autoPause) VideoCutscene.pauseVideo();
|
|
#end
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
if (health > Constants.HEALTH_MIN && !isGamePaused && Preferences.autoPause)
|
|
{
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
state: buildDiscordRPCState(),
|
|
details: buildDiscordRPCDetails(),
|
|
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
}
|
|
#end
|
|
|
|
// if else if else if else if else if else AAAAAAAAAAAAAAAAAAAAAAA
|
|
if (!isGamePaused && Preferences.autoPause)
|
|
{
|
|
if (currentConversation != null)
|
|
{
|
|
pause(Conversation);
|
|
}
|
|
else if (VideoCutscene.isPlaying())
|
|
{
|
|
pause(Cutscene);
|
|
}
|
|
else
|
|
{
|
|
pause();
|
|
}
|
|
}
|
|
super.onFocusLost();
|
|
}
|
|
|
|
/**
|
|
* Call this by pressing F5 on a debug build.
|
|
*/
|
|
override function reloadAssets():Void
|
|
{
|
|
performCleanup();
|
|
|
|
// `performCleanup()` clears the static reference to this state
|
|
// scripts might still need it, so we set it back to `this`
|
|
instance = this;
|
|
|
|
funkin.modding.PolymodHandler.forceReloadAssets();
|
|
if (lastParams == null)
|
|
{
|
|
throw "No lastParams to refer to";
|
|
}
|
|
lastParams.targetSong = SongRegistry.instance.fetchEntry(currentSong.id,
|
|
{variation: currentVariation}) ?? throw "Could not load current song from ID. This shouldn't happen!";
|
|
LoadingState.loadPlayState(lastParams);
|
|
}
|
|
|
|
override function stepHit():Bool
|
|
{
|
|
if (criticalFailure || !initialized) return false;
|
|
|
|
// super.stepHit() returns false if a module cancelled the event.
|
|
if (!super.stepHit()) return false;
|
|
|
|
if (isGamePaused) return false;
|
|
|
|
iconP1?.onStepHit(Std.int(Conductor.instance.currentStep));
|
|
iconP2?.onStepHit(Std.int(Conductor.instance.currentStep));
|
|
|
|
// Try to call hold note haptics each step hit. Works if atleast one note status is NoteStatus.isHoldNotePressed.
|
|
playerStrumline.noteVibrations.tryHoldNoteVibration();
|
|
|
|
return true;
|
|
}
|
|
|
|
override function beatHit():Bool
|
|
{
|
|
if (criticalFailure || !initialized) return false;
|
|
|
|
// super.beatHit() returns false if a module cancelled the event.
|
|
if (!super.beatHit()) return false;
|
|
|
|
if (isGamePaused) return false;
|
|
|
|
if (generatedMusic)
|
|
{
|
|
// TODO: Sort more efficiently, or less often, to improve performance.
|
|
// activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING);
|
|
}
|
|
|
|
if (FlxG.sound.music != null)
|
|
{
|
|
var correctSync:Float = Math.min(FlxG.sound.music.length, Math.max(0, Conductor.instance.songPosition - Conductor.instance.combinedOffset));
|
|
var playerVoicesError:Float = 0;
|
|
var opponentVoicesError:Float = 0;
|
|
if (vocals != null && vocals.playing)
|
|
{
|
|
@:nullSafety(Off)
|
|
@:privateAccess // todo: maybe make the groups public :thinking:
|
|
{
|
|
vocals.playerVoices?.forEachAlive(function(voice:FunkinSound) {
|
|
var currentRawVoiceTime:Float = voice.time + vocals.playerVoicesOffset;
|
|
if (Math.abs(currentRawVoiceTime - correctSync) > Math.abs(playerVoicesError)) playerVoicesError = currentRawVoiceTime - correctSync;
|
|
});
|
|
|
|
vocals.opponentVoices?.forEachAlive(function(voice:FunkinSound) {
|
|
var currentRawVoiceTime:Float = voice.time + vocals.opponentVoicesOffset;
|
|
if (Math.abs(currentRawVoiceTime - correctSync) > Math.abs(opponentVoicesError)) opponentVoicesError = currentRawVoiceTime - correctSync;
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!startingSong
|
|
&& (Math.abs(FlxG.sound.music.time - correctSync) > RESYNC_THRESHOLD
|
|
|| Math.abs(playerVoicesError) > RESYNC_THRESHOLD
|
|
|| Math.abs(opponentVoicesError) > RESYNC_THRESHOLD))
|
|
{
|
|
trace("VOCALS NEED RESYNC");
|
|
if (vocals != null)
|
|
{
|
|
trace(playerVoicesError);
|
|
trace(opponentVoicesError);
|
|
}
|
|
trace(FlxG.sound.music.time);
|
|
trace(correctSync);
|
|
resyncVocals();
|
|
}
|
|
}
|
|
|
|
// Only bop camera if zoom level is below 135%
|
|
if (Preferences.zoomCamera
|
|
&& FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom)
|
|
&& cameraZoomRate > 0
|
|
&& (Conductor.instance.currentBeat + cameraZoomRateOffset) % cameraZoomRate == 0)
|
|
{
|
|
// Set zoom multiplier for camera bop.
|
|
cameraBopMultiplier = cameraBopIntensity;
|
|
// HUD camera zoom still uses old system. To change. (+3%)
|
|
camHUD.zoom += hudCameraZoomIntensity * defaultHUDCameraZoom;
|
|
}
|
|
// trace('Not bopping camera: ${FlxG.camera.zoom} < ${(1.35 * defaultCameraZoom)} && ${cameraZoomRate} > 0 && ${Conductor.instance.currentBeat} % ${cameraZoomRate} == ${Conductor.instance.currentBeat % cameraZoomRate}}');
|
|
|
|
if (playerStrumline != null) playerStrumline.onBeatHit();
|
|
if (opponentStrumline != null) opponentStrumline.onBeatHit();
|
|
|
|
return true;
|
|
}
|
|
|
|
public override function destroy():Void
|
|
{
|
|
performCleanup();
|
|
|
|
#if mobile
|
|
// Syncing allowScreenTimeout with Preferences option.
|
|
lime.system.System.allowScreenTimeout = Preferences.screenTimeout;
|
|
#end
|
|
|
|
#if !mobile
|
|
FlxG.autoPause = Preferences.autoPause;
|
|
#end
|
|
|
|
super.destroy();
|
|
}
|
|
|
|
public override function initConsoleHelpers():Void
|
|
{
|
|
FlxG.console.registerFunction("debugUnbindCameraZoom", () -> {
|
|
debugUnbindCameraZoom = !debugUnbindCameraZoom;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Initializes the game and HUD cameras.
|
|
*/
|
|
function initCameras():Void
|
|
{
|
|
camGame.bgColor = BACKGROUND_COLOR; // Show a pink background behind the stage.
|
|
camHUD.bgColor.alpha = 0; // Show the game scene behind the camera.
|
|
camCutscene.bgColor.alpha = 0; // Show the game scene behind the camera.
|
|
camCutouts.setPosition((FlxG.width - FlxG.initialWidth) / 2, (FlxG.height - FlxG.initialHeight) / 2);
|
|
camCutouts.setSize(FlxG.initialWidth, FlxG.initialHeight);
|
|
camCutouts.bgColor.alpha = 0; // Show the game scene behind the camera.
|
|
|
|
FlxG.cameras.reset(camGame);
|
|
FlxG.cameras.add(camHUD, false);
|
|
FlxG.cameras.add(camCutscene, false);
|
|
FlxG.cameras.add(camCutouts, false);
|
|
|
|
// Configure camera follow point.
|
|
if (previousCameraFollowPoint != null)
|
|
{
|
|
cameraFollowPoint.setPosition(previousCameraFollowPoint.x, previousCameraFollowPoint.y);
|
|
previousCameraFollowPoint = null;
|
|
}
|
|
add(cameraFollowPoint);
|
|
}
|
|
|
|
/**
|
|
* Initializes the health bar on the HUD.
|
|
*/
|
|
function initHealthBar():Void
|
|
{
|
|
var healthBarYPos:Float = Preferences.downscroll ? FlxG.height * 0.1 : FlxG.height * 0.9;
|
|
#if mobile
|
|
if (Preferences.controlsScheme == FunkinHitboxControlSchemes.Arrows
|
|
&& !ControlsHandler.usingExternalInputDevice) healthBarYPos = FlxG.height * 0.1;
|
|
#end
|
|
|
|
healthBarBG.y = healthBarYPos;
|
|
healthBarBG.screenCenter(X);
|
|
healthBarBG.scrollFactor.set(0, 0);
|
|
healthBarBG.zIndex = 800;
|
|
add(healthBarBG);
|
|
|
|
healthBar.x = healthBarBG.x + 4;
|
|
healthBar.y = healthBarBG.y + 4;
|
|
healthBar.parent = this;
|
|
healthBar.parentVariable = 'healthLerp';
|
|
healthBar.scrollFactor.set();
|
|
healthBar.createFilledBar(Constants.COLOR_HEALTH_BAR_RED, Constants.COLOR_HEALTH_BAR_GREEN);
|
|
healthBar.zIndex = 801;
|
|
add(healthBar);
|
|
|
|
// The score text below the health bar.
|
|
scoreText.x = healthBarBG.x + healthBarBG.width - 190;
|
|
scoreText.y = healthBarBG.y + 30;
|
|
scoreText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, RIGHT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK);
|
|
scoreText.scrollFactor.set();
|
|
scoreText.zIndex = 802;
|
|
add(scoreText);
|
|
|
|
// Move the health bar to the HUD camera.
|
|
healthBar.cameras = [camHUD];
|
|
healthBarBG.cameras = [camHUD];
|
|
scoreText.cameras = [camHUD];
|
|
}
|
|
|
|
/**
|
|
* Generates the stage and all its props.
|
|
*/
|
|
function initStage():Void
|
|
{
|
|
loadStage(currentStageId);
|
|
}
|
|
|
|
function initMinimalMode():Void
|
|
{
|
|
// Create the green background.
|
|
var menuBG = FunkinSprite.create('menuDesat');
|
|
menuBG.color = 0xFF4CAF50;
|
|
menuBG.setGraphicSize(Std.int(menuBG.width * 1.1));
|
|
menuBG.updateHitbox();
|
|
menuBG.screenCenter();
|
|
menuBG.scrollFactor.set(0, 0);
|
|
menuBG.zIndex = -1000;
|
|
add(menuBG);
|
|
}
|
|
|
|
/**
|
|
* Loads stage data from cache, assembles the props,
|
|
* and adds it to the state.
|
|
* @param id
|
|
*/
|
|
function loadStage(id:String):Void
|
|
{
|
|
currentStage = StageRegistry.instance.fetchEntry(id);
|
|
|
|
if (currentStage != null)
|
|
{
|
|
currentStage.revive(); // Stages are killed and props destroyed when the PlayState is destroyed to save memory.
|
|
|
|
// Actually create and position the sprites.
|
|
var event:ScriptEvent = new ScriptEvent(CREATE, false);
|
|
ScriptEventDispatcher.callEvent(currentStage, event);
|
|
|
|
resetCameraZoom();
|
|
|
|
// Add the stage to the scene.
|
|
this.add(currentStage);
|
|
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
FlxG.console.registerObject('stage', currentStage);
|
|
#end
|
|
}
|
|
else
|
|
{
|
|
// lolol
|
|
funkin.util.WindowUtil.showError('Stage Error', 'Unable to load stage $id, is its data corrupted?.');
|
|
}
|
|
}
|
|
|
|
public function resetCameraZoom():Void
|
|
{
|
|
if (isMinimalMode) return;
|
|
// Apply camera zoom level from stage data.
|
|
currentCameraZoom = stageZoom;
|
|
FlxG.camera.zoom = currentCameraZoom;
|
|
|
|
// Reset bop multiplier.
|
|
cameraBopMultiplier = 1.0;
|
|
}
|
|
|
|
/**
|
|
* Generates the character sprites and adds them to the stage.
|
|
*/
|
|
function initCharacters():Void
|
|
{
|
|
if (currentSong == null || currentChart == null)
|
|
{
|
|
throw 'Song difficulty could not be loaded.';
|
|
}
|
|
|
|
var currentCharacterData:Null<SongCharacterData> = currentChart?.characters;
|
|
if (currentCharacterData == null)
|
|
{
|
|
trace('Cannot retrieve character data');
|
|
return;
|
|
}
|
|
// Switch the variation we are playing on by manipulating targetVariation.
|
|
|
|
//
|
|
// GIRLFRIEND
|
|
//
|
|
var girlfriend:Null<BaseCharacter> = CharacterDataParser.fetchCharacter(currentCharacterData.girlfriend);
|
|
|
|
if (girlfriend != null)
|
|
{
|
|
// Don't need to do anything.
|
|
}
|
|
else if (currentCharacterData.girlfriend != '')
|
|
{
|
|
trace('WARNING: Could not load girlfriend character with ID ${currentCharacterData.girlfriend}, skipping...');
|
|
}
|
|
else
|
|
{
|
|
// Chosen GF was '' so we don't load one.
|
|
}
|
|
|
|
//
|
|
// DAD
|
|
//
|
|
var dad:Null<BaseCharacter> = CharacterDataParser.fetchCharacter(currentCharacterData.opponent);
|
|
|
|
if (dad != null)
|
|
{
|
|
//
|
|
// OPPONENT HEALTH ICON
|
|
//
|
|
iconP2 = new HealthIcon('dad', 1);
|
|
iconP2.y = healthBar.y - (iconP2.height / 2);
|
|
dad.initHealthIcon(true); // Apply the character ID here
|
|
iconP2.zIndex = 850;
|
|
add(iconP2);
|
|
iconP2.cameras = [camHUD];
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
discordRPCAlbum = 'album-${currentChart?.album}';
|
|
discordRPCIcon = 'icon-${currentCharacterData.opponent}';
|
|
#end
|
|
}
|
|
|
|
//
|
|
// BOYFRIEND
|
|
//
|
|
var boyfriend:Null<BaseCharacter> = CharacterDataParser.fetchCharacter(currentCharacterData.player);
|
|
|
|
if (boyfriend != null)
|
|
{
|
|
//
|
|
// PLAYER HEALTH ICON
|
|
//
|
|
iconP1 = new HealthIcon('bf', 0);
|
|
iconP1.y = healthBar.y - (iconP1.height / 2);
|
|
boyfriend.initHealthIcon(false); // Apply the character ID here
|
|
iconP1.zIndex = 850;
|
|
add(iconP1);
|
|
iconP1.cameras = [camHUD];
|
|
}
|
|
|
|
//
|
|
// ADD CHARACTERS TO SCENE
|
|
//
|
|
|
|
if (currentStage != null)
|
|
{
|
|
// Characters get added to the stage, not the main scene.
|
|
if (girlfriend != null)
|
|
{
|
|
currentStage.addCharacter(girlfriend, GF);
|
|
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
FlxG.console.registerObject('gf', girlfriend);
|
|
#end
|
|
}
|
|
|
|
if (boyfriend != null)
|
|
{
|
|
currentStage.addCharacter(boyfriend, BF);
|
|
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
FlxG.console.registerObject('bf', boyfriend);
|
|
#end
|
|
}
|
|
|
|
if (dad != null)
|
|
{
|
|
currentStage.addCharacter(dad, DAD);
|
|
// Camera starts at dad.
|
|
cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
|
|
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
FlxG.console.registerObject('dad', dad);
|
|
#end
|
|
}
|
|
|
|
// Rearrange by z-indexes.
|
|
currentStage.refresh();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Constructs the strumlines for each player.
|
|
*/
|
|
function initStrumlines():Void
|
|
{
|
|
playerStrumline.onNoteIncoming.add(onStrumlineNoteIncoming);
|
|
opponentStrumline.onNoteIncoming.add(onStrumlineNoteIncoming);
|
|
add(playerStrumline);
|
|
add(opponentStrumline);
|
|
|
|
final cutoutSize = FullScreenScaleMode.gameCutoutSize.x / 2.5;
|
|
// Position the player strumline on the right half of the screen
|
|
playerStrumline.x = (FlxG.width / 2 + Constants.STRUMLINE_X_OFFSET) + (cutoutSize / 2.0); // Classic style
|
|
// playerStrumline.x = FlxG.width - playerStrumline.width - Constants.STRUMLINE_X_OFFSET; // Centered style
|
|
|
|
playerStrumline.y = Preferences.downscroll ? FlxG.height - playerStrumline.height - Constants.STRUMLINE_Y_OFFSET - noteStyle.getStrumlineOffsets()[1] : Constants.STRUMLINE_Y_OFFSET;
|
|
|
|
playerStrumline.zIndex = 1001;
|
|
playerStrumline.cameras = [camHUD];
|
|
|
|
// Position the opponent strumline on the left half of the screen
|
|
opponentStrumline.x = Constants.STRUMLINE_X_OFFSET + cutoutSize;
|
|
opponentStrumline.y = Preferences.downscroll ? FlxG.height - opponentStrumline.height - Constants.STRUMLINE_Y_OFFSET - noteStyle.getStrumlineOffsets()[1] : Constants.STRUMLINE_Y_OFFSET;
|
|
|
|
opponentStrumline.zIndex = 1000;
|
|
opponentStrumline.cameras = [camHUD];
|
|
|
|
#if mobile
|
|
if (Preferences.controlsScheme == FunkinHitboxControlSchemes.Arrows && !ControlsHandler.usingExternalInputDevice)
|
|
{
|
|
initNoteHitbox();
|
|
}
|
|
#end
|
|
|
|
playerStrumline.fadeInArrows();
|
|
opponentStrumline.fadeInArrows();
|
|
}
|
|
|
|
/**
|
|
* Configures the position of strumline for the default control scheme
|
|
*/
|
|
#if mobile
|
|
function initNoteHitbox()
|
|
{
|
|
final amplification:Float = (FlxG.width / FlxG.height) / (FlxG.initialWidth / FlxG.initialHeight);
|
|
final playerStrumlineScale:Float = ((FlxG.height / FlxG.width) * 1.95) * amplification;
|
|
final playerNoteSpacing:Float = ((FlxG.height / FlxG.width) * 2.8) * amplification;
|
|
|
|
playerStrumline.strumlineScale.set(playerStrumlineScale, playerStrumlineScale);
|
|
playerStrumline.setNoteSpacing(playerNoteSpacing);
|
|
@:nullSafety(Off) // Who thought it'd be a good idea to make the iterator nullable?
|
|
for (strum in playerStrumline)
|
|
{
|
|
strum.width *= 2;
|
|
}
|
|
opponentStrumline.enterMiniMode(0.4 * amplification);
|
|
|
|
playerStrumline.x = (FlxG.width - playerStrumline.width) / 2 + Constants.STRUMLINE_X_OFFSET;
|
|
playerStrumline.y = (FlxG.height - playerStrumline.height) * 0.95 - Constants.STRUMLINE_Y_OFFSET;
|
|
if (currentChart?.noteStyle != "pixel")
|
|
{
|
|
#if android playerStrumline.y += 10; #end
|
|
}
|
|
else
|
|
{
|
|
playerStrumline.y -= 10;
|
|
}
|
|
opponentStrumline.y = Constants.STRUMLINE_Y_OFFSET * 0.3;
|
|
opponentStrumline.x -= 30;
|
|
}
|
|
|
|
function initPauseSprites()
|
|
{
|
|
pauseButton.animation.addByIndices('idle', 'back', [0], "", 24, false);
|
|
pauseButton.animation.addByIndices('hold', 'back', [5], "", 24, false);
|
|
pauseButton.animation.addByIndices('confirm', 'back', [
|
|
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32
|
|
], "", 24, false);
|
|
pauseButton.scale.set(0.8, 0.8);
|
|
pauseButton.updateHitbox();
|
|
pauseButton.animation.play("idle");
|
|
pauseButton.setPosition((FlxG.width - pauseButton.width) - 35, 35);
|
|
@:nullSafety(Off) // AAAAAAA why did camControls have to be nullable AAAAAAAAAA
|
|
pauseButton.cameras = [camControls];
|
|
|
|
pauseCircle.scale.set(0.84, 0.8);
|
|
pauseCircle.updateHitbox();
|
|
@:nullSafety(Off)
|
|
pauseCircle.cameras = [camControls];
|
|
pauseCircle.x = ((pauseButton.x + (pauseButton.width / 2)) - (pauseCircle.width / 2));
|
|
pauseCircle.y = ((pauseButton.y + (pauseButton.height / 2)) - (pauseCircle.height / 2));
|
|
pauseCircle.alpha = 0.1;
|
|
|
|
add(pauseCircle);
|
|
add(pauseButton);
|
|
hitbox?.forEachAlive(function(hint:FunkinHint) {
|
|
hint.deadZones.push(pauseButton);
|
|
});
|
|
}
|
|
#end
|
|
|
|
/**
|
|
* Configures the judgement and combo popups.
|
|
*/
|
|
function initPopups():Void
|
|
{
|
|
// Initialize the judgements and combo meter.
|
|
comboPopUps.zIndex = 900;
|
|
add(comboPopUps);
|
|
comboPopUps.cameras = [camHUD];
|
|
}
|
|
|
|
/**
|
|
* Initializes the Discord Rich Presence.
|
|
*/
|
|
function initDiscord():Void
|
|
{
|
|
#if FEATURE_DISCORD_RPC
|
|
// Determine the details strings once and reuse them.
|
|
|
|
// Updating Discord Rich Presence.
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
state: buildDiscordRPCState(),
|
|
details: buildDiscordRPCDetails(),
|
|
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
#end
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
// Updating Discord Rich Presence.
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
state: buildDiscordRPCState(),
|
|
details: buildDiscordRPCDetails(),
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
#end
|
|
}
|
|
|
|
function buildDiscordRPCDetails():String
|
|
{
|
|
if (PlayStatePlaylist.isStoryMode)
|
|
{
|
|
return 'Story Mode: ${PlayStatePlaylist.campaignTitle}';
|
|
}
|
|
else
|
|
{
|
|
if (isChartingMode)
|
|
{
|
|
return 'Chart Editor [Playtest]';
|
|
}
|
|
else if (isPracticeMode)
|
|
{
|
|
return 'Freeplay [Practice]';
|
|
}
|
|
else if (isBotPlayMode)
|
|
{
|
|
return 'Freeplay [Bot Play]';
|
|
}
|
|
else
|
|
{
|
|
return 'Freeplay';
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildDiscordRPCState():String
|
|
{
|
|
if (currentChart == null)
|
|
{
|
|
trace("WARNING: Difficulty data for RPC is null.");
|
|
}
|
|
var discordRPCDifficulty = PlayState.instance?.currentDifficulty?.replace('-', ' ')?.toTitleCase() ?? '???';
|
|
return '${currentChart?.songName ?? '???'} [${discordRPCDifficulty}]';
|
|
}
|
|
|
|
function initPreciseInputs():Void
|
|
{
|
|
PreciseInputManager.instance.onInputPressed.add(onKeyPress);
|
|
PreciseInputManager.instance.onInputReleased.add(onKeyRelease);
|
|
}
|
|
|
|
/**
|
|
* Initializes the song (applying the chart, generating the notes, etc.)
|
|
* Should be done before the countdown starts.
|
|
*/
|
|
function generateSong():Void
|
|
{
|
|
if (currentChart == null)
|
|
{
|
|
throw 'Song difficulty could not be loaded.';
|
|
}
|
|
|
|
// Conductor.instance.forceBPM(currentChart.getStartingBPM());
|
|
|
|
if (!overrideMusic)
|
|
{
|
|
// Stop the vocals if they already exist.
|
|
vocals?.stop();
|
|
vocals = currentChart?.buildVocals(currentInstrumental);
|
|
|
|
if (vocals?.members?.length == 0)
|
|
{
|
|
trace('WARNING: No vocals found for this song.');
|
|
}
|
|
}
|
|
|
|
regenNoteData();
|
|
|
|
var event:ScriptEvent = new ScriptEvent(CREATE, false);
|
|
ScriptEventDispatcher.callEvent(currentSong, event);
|
|
|
|
generatedMusic = true;
|
|
}
|
|
|
|
/**
|
|
* Read note data from the chart and generate the notes.
|
|
*/
|
|
function regenNoteData(startTime:Float = 0):Void
|
|
{
|
|
if (currentChart == null)
|
|
{
|
|
trace('Cannot regenerate note data for null chart');
|
|
return;
|
|
}
|
|
|
|
Highscore.tallies.combo = 0;
|
|
Highscore.tallies = new Tallies();
|
|
|
|
@:nullSafety(Off)
|
|
var event:SongLoadScriptEvent = new SongLoadScriptEvent(currentChart.song.id, currentChart.difficulty, currentChart.notes.copy(), currentChart.getEvents());
|
|
|
|
dispatchEvent(event);
|
|
|
|
var builtNoteData = event.notes;
|
|
var builtEventData = event.events;
|
|
|
|
songEvents = builtEventData;
|
|
SongEventRegistry.resetEvents(songEvents);
|
|
|
|
// Reset the notes on each strumline.
|
|
var playerNoteData:Array<SongNoteData> = [];
|
|
var opponentNoteData:Array<SongNoteData> = [];
|
|
|
|
for (songNote in builtNoteData)
|
|
{
|
|
var strumTime:Float = songNote.time;
|
|
if (strumTime < startTime) continue; // Skip notes that are before the start time.
|
|
|
|
var scoreable = true;
|
|
if (songNote.kind != null)
|
|
{
|
|
var noteKind:Null<NoteKind> = NoteKindManager.getNoteKind(songNote.kind ?? '');
|
|
if (noteKind != null) scoreable = noteKind.scoreable;
|
|
}
|
|
|
|
var noteData:Int = songNote.getDirection();
|
|
var playerNote:Bool = true;
|
|
|
|
if (noteData > 3) playerNote = false;
|
|
|
|
switch (songNote.getStrumlineIndex())
|
|
{
|
|
case 0:
|
|
playerNoteData.push(songNote);
|
|
// increment totalNotes for total possible notes able to be hit by the player
|
|
if (scoreable) Highscore.tallies.totalNotes++;
|
|
case 1:
|
|
opponentNoteData.push(songNote);
|
|
}
|
|
}
|
|
|
|
playerStrumline.applyNoteData(playerNoteData);
|
|
opponentStrumline.applyNoteData(opponentNoteData);
|
|
}
|
|
|
|
function onStrumlineNoteIncoming(noteSprite:NoteSprite):Void
|
|
{
|
|
var event:NoteScriptEvent = new NoteScriptEvent(NOTE_INCOMING, noteSprite, 0, false);
|
|
|
|
dispatchEvent(event);
|
|
}
|
|
|
|
/**
|
|
* Prepares to start the countdown.
|
|
* Ends any running cutscenes, creates the strumlines, and starts the countdown.
|
|
* This is public so that scripts can call it.
|
|
*/
|
|
public function startCountdown():Void
|
|
{
|
|
// If Countdown.performCountdown returns false, then the countdown was canceled by a script.
|
|
var result:Bool = Countdown.performCountdown();
|
|
if (!result) return;
|
|
|
|
isInCutscene = false;
|
|
|
|
// TODO: Maybe tween in the camera after any cutscenes.
|
|
camHUD.visible = true;
|
|
}
|
|
|
|
/**
|
|
* Displays a dialogue cutscene with the given ID.
|
|
* This is used by song scripts to display dialogue.
|
|
*/
|
|
public function startConversation(conversationId:String):Void
|
|
{
|
|
isInCutscene = true;
|
|
|
|
currentConversation = ConversationRegistry.instance.fetchEntry(conversationId);
|
|
if (currentConversation == null) return;
|
|
if (!currentConversation.alive) currentConversation.revive();
|
|
|
|
currentConversation.completeCallback = onConversationComplete;
|
|
currentConversation.cameras = [camCutscene];
|
|
currentConversation.zIndex = 1000;
|
|
add(currentConversation);
|
|
refresh();
|
|
|
|
var event:ScriptEvent = new ScriptEvent(CREATE, false);
|
|
ScriptEventDispatcher.callEvent(currentConversation, event);
|
|
}
|
|
|
|
/**
|
|
* Handler function called when a conversation ends.
|
|
*/
|
|
function onConversationComplete():Void
|
|
{
|
|
isInCutscene = false;
|
|
|
|
if (currentConversation != null)
|
|
{
|
|
currentConversation.kill();
|
|
remove(currentConversation);
|
|
currentConversation = null;
|
|
}
|
|
|
|
if (startingSong && !isInCountdown)
|
|
{
|
|
startCountdown();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts playing the song after the countdown has completed.
|
|
*/
|
|
function startSong():Void
|
|
{
|
|
startingSong = false;
|
|
|
|
#if mobile
|
|
if (hitbox != null) hitbox.visible = true;
|
|
#end
|
|
|
|
if (!overrideMusic && !isGamePaused && currentChart != null)
|
|
{
|
|
currentChart?.playInst(1.0, currentInstrumental, false);
|
|
}
|
|
|
|
if (FlxG.sound.music == null)
|
|
{
|
|
FlxG.log.error('PlayState failed to initialize instrumental!');
|
|
return;
|
|
}
|
|
|
|
FlxG.sound.music.onComplete = function() {
|
|
if (mayPauseGame) endSong(skipEndingTransition);
|
|
};
|
|
|
|
FlxG.sound.music.pause();
|
|
FlxG.sound.music.time = startTimestamp;
|
|
FlxG.sound.music.pitch = playbackRate;
|
|
|
|
// Prevent the volume from being wrong.
|
|
FlxG.sound.music.volume = 1.0;
|
|
if (FlxG.sound.music.fadeTween != null) FlxG.sound.music.fadeTween.cancel();
|
|
|
|
if (vocals != null)
|
|
{
|
|
trace('Playing vocals...');
|
|
add(vocals);
|
|
|
|
vocals.time = startTimestamp - Conductor.instance.instrumentalOffset;
|
|
vocals.pitch = playbackRate;
|
|
vocals.volume = 1.0;
|
|
|
|
// trace('STARTING SONG AT:');
|
|
// trace('${FlxG.sound.music.time}');
|
|
// trace('${vocals.time}');
|
|
|
|
vocals.play();
|
|
}
|
|
|
|
FlxG.sound.music.play();
|
|
|
|
#if FEATURE_DISCORD_RPC
|
|
// Updating Discord Rich Presence (with Time Left)
|
|
DiscordClient.instance.setPresence(
|
|
{
|
|
state: buildDiscordRPCState(),
|
|
details: buildDiscordRPCDetails(),
|
|
|
|
largeImageKey: discordRPCAlbum,
|
|
smallImageKey: discordRPCIcon
|
|
});
|
|
// DiscordClient.changePresence(detailsText, '${currentChart.songName} ($discordRPCDifficulty)', discordRPCIcon, true, currentSongLengthMs);
|
|
#end
|
|
|
|
if (startTimestamp > 0)
|
|
{
|
|
// FlxG.sound.music.time = startTimestamp - Conductor.instance.combinedOffset;
|
|
handleSkippedNotes();
|
|
}
|
|
|
|
dispatchEvent(new ScriptEvent(SONG_START));
|
|
|
|
#if FEATURE_NEWGROUNDS
|
|
Events.logStartSong(currentSong.id, currentVariation);
|
|
#end
|
|
|
|
resyncVocals();
|
|
}
|
|
|
|
/**
|
|
* Resynchronize the vocal tracks if they have become offset from the instrumental.
|
|
*/
|
|
function resyncVocals():Void
|
|
{
|
|
if (vocals == null) return;
|
|
|
|
// Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.)
|
|
if (!(FlxG.sound.music?.playing ?? false)) return;
|
|
|
|
var timeToPlayAt:Float = Math.min(FlxG.sound.music.length,
|
|
Math.max(Math.min(Conductor.instance.combinedOffset, 0), Conductor.instance.songPosition) - Conductor.instance.combinedOffset);
|
|
trace('Resyncing vocals to ${timeToPlayAt}');
|
|
|
|
FlxG.sound.music.pause();
|
|
vocals.pause();
|
|
|
|
FlxG.sound.music.time = timeToPlayAt;
|
|
FlxG.sound.music.play(false, timeToPlayAt);
|
|
|
|
vocals.time = timeToPlayAt;
|
|
vocals.play(false, timeToPlayAt);
|
|
}
|
|
|
|
/**
|
|
* Updates the position and contents of the score display.
|
|
*/
|
|
function updateScoreText():Void
|
|
{
|
|
// TODO: Add functionality for modules to update the score text.
|
|
if (isBotPlayMode)
|
|
{
|
|
scoreText.text = 'Bot Play Enabled';
|
|
}
|
|
else
|
|
{
|
|
// TODO: Add an option for this maybe?
|
|
var commaSeparated:Bool = true;
|
|
scoreText.text = 'Score: ${FlxStringUtil.formatMoney(songScore, false, commaSeparated)}';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the values of the health bar.
|
|
*/
|
|
function updateHealthBar():Void
|
|
{
|
|
if (isBotPlayMode)
|
|
{
|
|
healthLerp = Constants.HEALTH_MAX;
|
|
}
|
|
else
|
|
{
|
|
healthLerp = FlxMath.lerp(healthLerp, health, 0.15);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Callback executed when one of the note keys is pressed.
|
|
*/
|
|
function onKeyPress(event:PreciseInputEvent):Void
|
|
{
|
|
if (isGamePaused) return;
|
|
|
|
// Do the minimal possible work here.
|
|
inputPressQueue.push(event);
|
|
}
|
|
|
|
/**
|
|
* Callback executed when one of the note keys is released.
|
|
*/
|
|
function onKeyRelease(event:PreciseInputEvent):Void
|
|
{
|
|
if (isGamePaused) return;
|
|
|
|
// Do the minimal possible work here.
|
|
inputReleaseQueue.push(event);
|
|
}
|
|
|
|
/**
|
|
* Handles opponent note hits and player note misses.
|
|
*/
|
|
function processNotes(elapsed:Float):Void
|
|
{
|
|
if (playerStrumline.notes?.members == null || opponentStrumline.notes?.members == null) return;
|
|
|
|
// Process notes on the opponent's side.
|
|
for (note in opponentStrumline.notes.members)
|
|
{
|
|
if (note == null) continue;
|
|
var r = GRhythmUtil.processWindow(note, false);
|
|
if (r.botplayHit)
|
|
{
|
|
var event:NoteScriptEvent = new HitNoteScriptEvent(note, 0.0, 0, 'perfect', false, 0);
|
|
dispatchEvent(event);
|
|
|
|
// Calling event.cancelEvent() skips all the other logic! Neat!
|
|
if (event.eventCanceled) continue;
|
|
|
|
// Command the opponent to hit the note on time.
|
|
// NOTE: This is what handles the strumline and cleaning up the note itself!
|
|
opponentStrumline.hitNote(note);
|
|
|
|
if (note.holdNoteSprite != null)
|
|
{
|
|
opponentStrumline.playNoteHoldCover(note.holdNoteSprite);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process hold notes on the opponent's side.
|
|
for (holdNote in opponentStrumline.holdNotes.members)
|
|
{
|
|
if (holdNote == null || !holdNote.alive || holdNote.noteData == null) continue;
|
|
|
|
// While the hold note is being hit, and there is length on the hold note...
|
|
if (holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0)
|
|
{
|
|
// Make sure the opponent keeps singing while the note is held.
|
|
if (currentStage != null && currentStage.getDad() != null && currentStage.getDad().isSinging())
|
|
{
|
|
currentStage.getDad().holdTimer = 0;
|
|
}
|
|
}
|
|
|
|
if (holdNote.missedNote && !holdNote.handledMiss)
|
|
{
|
|
// When the opponent drops a hold note.
|
|
holdNote.handledMiss = true;
|
|
|
|
// We dropped a hold note.
|
|
// Play miss animation, but don't penalize.
|
|
if (currentStage != null) currentStage.getOpponent().playSingAnimation(holdNote.noteData.getDirection(), true);
|
|
}
|
|
}
|
|
|
|
// Process notes on the player's side.
|
|
for (note in playerStrumline.notes.members)
|
|
{
|
|
if (note == null) continue;
|
|
var r = GRhythmUtil.processWindow(note, !isBotPlayMode);
|
|
if (r.botplayHit)
|
|
{
|
|
// We call onHitNote to play the proper animations,
|
|
// but not goodNoteHit! This means zero score and zero notes hit for the results screen!
|
|
|
|
// Call an event to allow canceling the note hit.
|
|
// NOTE: This is what handles the character animations!
|
|
var event:NoteScriptEvent = new HitNoteScriptEvent(note, 0.0, 0, 'perfect', false, 0);
|
|
dispatchEvent(event);
|
|
|
|
// Calling event.cancelEvent() skips all the other logic! Neat!
|
|
if (event.eventCanceled) continue;
|
|
|
|
// Command the bot to hit the note on time.
|
|
// NOTE: This is what handles the strumline and cleaning up the note itself!
|
|
playerStrumline.hitNote(note);
|
|
|
|
if (note.holdNoteSprite != null)
|
|
{
|
|
playerStrumline.playNoteHoldCover(note.holdNoteSprite);
|
|
}
|
|
}
|
|
if (!r.cont) continue;
|
|
|
|
// This becomes true when the note leaves the hit window.
|
|
// It might still be on screen.
|
|
if (note.hasMissed && !note.handledMiss)
|
|
{
|
|
// Call an event to allow canceling the note miss.
|
|
// NOTE: This is what handles the character animations!
|
|
var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, Constants.HEALTH_MISS_PENALTY, Highscore.tallies.combo, true);
|
|
dispatchEvent(event);
|
|
|
|
// Calling event.cancelEvent() skips all the other logic! Neat!
|
|
if (event.eventCanceled) continue;
|
|
|
|
// Skip handling the miss in botplay!
|
|
if (!isBotPlayMode)
|
|
{
|
|
// Judge the miss.
|
|
// NOTE: This is what handles the scoring.
|
|
// trace('Missed note! ${note.noteData}');
|
|
onNoteMiss(note, event.playSound, event.healthChange);
|
|
}
|
|
|
|
note.handledMiss = true;
|
|
}
|
|
}
|
|
|
|
// Process hold notes on the player's side.
|
|
// This handles scoring so we don't need it on the opponent's side.
|
|
for (holdNote in playerStrumline.holdNotes.members)
|
|
{
|
|
if (holdNote == null || !holdNote.alive) continue;
|
|
|
|
// While the hold note is being hit, and there is length on the hold note...
|
|
if (holdNote.hitNote && !holdNote.missedNote && holdNote.sustainLength > 0)
|
|
{
|
|
// Grant the player health.
|
|
if (!isBotPlayMode)
|
|
{
|
|
health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed;
|
|
songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed);
|
|
}
|
|
|
|
// Make sure the player keeps singing while the note is held by the bot.
|
|
if (isBotPlayMode && currentStage != null && currentStage.getBoyfriend() != null && currentStage.getBoyfriend().isSinging())
|
|
{
|
|
currentStage.getBoyfriend().holdTimer = 0;
|
|
}
|
|
}
|
|
|
|
if (holdNote.missedNote && !holdNote.handledMiss)
|
|
{
|
|
// The player dropped a hold note.
|
|
holdNote.handledMiss = true;
|
|
|
|
// Mute vocals and play miss animation.
|
|
// vocals.playerVolume = 0;
|
|
// if (currentStage != null && currentStage.getBoyfriend() != null) currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true);
|
|
|
|
if (!isBotPlayMode)
|
|
{
|
|
if (holdNote.sustainLength > Constants.HOLD_DROP_PENALTY_THRESHOLD_MS)
|
|
{
|
|
// Penalize the player for letting go of a hold note too early.
|
|
trace('Player dropped a hold note, penalizing... (has hit: ${holdNote.hitNote})');
|
|
|
|
// Different penalty based on whether the note itself was missed,
|
|
// or the note was hit and then the hold was dropped.
|
|
var remainingLengthSec = holdNote.sustainLength / Constants.MS_PER_SEC;
|
|
var healthChangeUncapped = remainingLengthSec * Constants.HEALTH_HOLD_DROP_PENALTY_PER_SECOND;
|
|
// If the base note of the hold was missed, don't penalize them more on top of that.
|
|
var healthChangeMax = Constants.HEALTH_HOLD_DROP_PENALTY_MAX - (holdNote.hitNote ? -Constants.HEALTH_MISS_PENALTY : 0);
|
|
var healthChange = healthChangeUncapped.clamp(healthChangeMax, 0);
|
|
var scoreChange = Std.int(Constants.SCORE_HOLD_DROP_PENALTY_PER_SECOND * remainingLengthSec);
|
|
|
|
var event:HoldNoteScriptEvent = new HoldNoteScriptEvent(NOTE_HOLD_DROP, holdNote, healthChange, scoreChange, true, Highscore.tallies.combo);
|
|
dispatchEvent(event);
|
|
|
|
// Calling event.cancelEvent() skips all the other logic! Neat!
|
|
if (event.eventCanceled) continue;
|
|
|
|
trace('Penalizing score by ${event.score} and health by ${event.healthChange} for dropping hold note (is combo break: ${event.isComboBreak})!');
|
|
applyScore(event.score, '', event.healthChange, event.isComboBreak);
|
|
|
|
// Play the miss sound.
|
|
if (event.playSound)
|
|
{
|
|
if (vocals != null) vocals.playerVolume = 0;
|
|
FunkinSound.playOnce(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.5, 0.6));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
trace('Hold note too short, not penalizing...');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleSkippedNotes():Void
|
|
{
|
|
for (note in playerStrumline.notes.members)
|
|
{
|
|
if (note == null || note.hasBeenHit) continue;
|
|
var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS;
|
|
|
|
if (Conductor.instance.songPosition > hitWindowEnd)
|
|
{
|
|
// We have passed this note.
|
|
// Flag the note for deletion without actually penalizing the player.
|
|
note.handledMiss = true;
|
|
}
|
|
}
|
|
|
|
// Respawns notes that were between the previous time and the current time when skipping backward, or destroy notes between the previous time and the current time when skipping forward.
|
|
playerStrumline.handleSkippedNotes();
|
|
opponentStrumline.handleSkippedNotes();
|
|
}
|
|
|
|
/**
|
|
* PreciseInputEvents are put into a queue between update() calls,
|
|
* and then processed here.
|
|
*/
|
|
function processInputQueue():Void
|
|
{
|
|
if (inputPressQueue.length + inputReleaseQueue.length == 0) return;
|
|
|
|
// Ignore inputs during cutscenes.
|
|
if (isInCutscene || disableKeys)
|
|
{
|
|
inputPressQueue = [];
|
|
inputReleaseQueue = [];
|
|
return;
|
|
}
|
|
|
|
// Generate a list of notes within range.
|
|
var notesInRange:Array<NoteSprite> = playerStrumline.getNotesMayHit();
|
|
|
|
var notesByDirection:Array<Array<NoteSprite>> = [[], [], [], []];
|
|
|
|
for (note in notesInRange)
|
|
notesByDirection[note.direction].push(note);
|
|
|
|
while (inputPressQueue.length > 0)
|
|
{
|
|
var input:Null<PreciseInputEvent> = inputPressQueue.shift();
|
|
if (input == null) continue;
|
|
|
|
playerStrumline.pressKey(input.noteDirection);
|
|
|
|
// Don't credit or penalize inputs in Bot Play.
|
|
if (isBotPlayMode) continue;
|
|
|
|
var notesInDirection:Array<NoteSprite> = notesByDirection[input.noteDirection];
|
|
|
|
#if FEATURE_GHOST_TAPPING
|
|
if ((!playerStrumline.mayGhostTap()) && notesInDirection.length == 0)
|
|
#else
|
|
if (notesInDirection.length == 0)
|
|
#end
|
|
{
|
|
// Pressed a wrong key with no notes nearby.
|
|
// Perform a ghost miss (anti-spam).
|
|
ghostNoteMiss(input.noteDirection, notesInRange.length > 0);
|
|
|
|
// Play the strumline animation.
|
|
playerStrumline.playPress(input.noteDirection);
|
|
trace('PENALTY Score: ${songScore}');
|
|
}
|
|
else if (notesInDirection.length == 0)
|
|
{
|
|
// Press a key with no penalty.
|
|
|
|
// Play the strumline animation.
|
|
playerStrumline.playPress(input.noteDirection);
|
|
trace('NO PENALTY Score: ${songScore}');
|
|
}
|
|
else
|
|
{
|
|
// Choose the first note, deprioritizing low priority notes.
|
|
var targetNote:Null<NoteSprite> = notesInDirection.find((note) -> !note.lowPriority);
|
|
if (targetNote == null) targetNote = notesInDirection[0];
|
|
if (targetNote == null) continue;
|
|
|
|
// Judge and hit the note.
|
|
// trace('Hit note! ${targetNote.noteData}');
|
|
goodNoteHit(targetNote, input);
|
|
// trace('Score: ${songScore}');
|
|
|
|
notesInDirection.remove(targetNote);
|
|
|
|
// Play the strumline animation.
|
|
playerStrumline.playConfirm(input.noteDirection);
|
|
}
|
|
}
|
|
|
|
while (inputReleaseQueue.length > 0)
|
|
{
|
|
var input:Null<PreciseInputEvent> = inputReleaseQueue.shift();
|
|
if (input == null) continue;
|
|
|
|
// Play the strumline animation.
|
|
playerStrumline.playStatic(input.noteDirection);
|
|
|
|
playerStrumline.releaseKey(input.noteDirection);
|
|
}
|
|
|
|
playerStrumline.noteVibrations.tryNoteVibration();
|
|
}
|
|
|
|
function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void
|
|
{
|
|
// Calculate the input latency (do this as late as possible).
|
|
// trace('Compare: ${PreciseInputManager.getCurrentTimestamp()} - ${input.timestamp}');
|
|
var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp;
|
|
var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS;
|
|
// trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!');
|
|
|
|
// Get the offset and compensate for input latency.
|
|
// Round inward (trim remainder) for consistency.
|
|
var diff:Float = Conductor.instance.songPosition - note.noteData.time;
|
|
|
|
var totalDiff:Float = diff;
|
|
if (diff < 0) totalDiff = diff + inputLatencyMs;
|
|
else
|
|
totalDiff = diff - inputLatencyMs;
|
|
|
|
var noteDiff:Int = Std.int(totalDiff);
|
|
|
|
var score = Scoring.scoreNote(noteDiff, PBOT1);
|
|
var daRating = Scoring.judgeNote(noteDiff, PBOT1);
|
|
|
|
var healthChange = 0.0;
|
|
var isComboBreak = false;
|
|
switch (daRating)
|
|
{
|
|
case 'sick':
|
|
healthChange = Constants.HEALTH_SICK_BONUS;
|
|
isComboBreak = Constants.JUDGEMENT_SICK_COMBO_BREAK;
|
|
case 'good':
|
|
healthChange = Constants.HEALTH_GOOD_BONUS;
|
|
isComboBreak = Constants.JUDGEMENT_GOOD_COMBO_BREAK;
|
|
case 'bad':
|
|
healthChange = Constants.HEALTH_BAD_BONUS;
|
|
isComboBreak = Constants.JUDGEMENT_BAD_COMBO_BREAK;
|
|
case 'shit':
|
|
healthChange = Constants.HEALTH_SHIT_BONUS;
|
|
isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK;
|
|
}
|
|
|
|
// Send the note hit event.
|
|
var event:HitNoteScriptEvent = new HitNoteScriptEvent(note, healthChange, score, daRating, isComboBreak,
|
|
note.scoreable ? Highscore.tallies.combo + 1 : Highscore.tallies.combo, noteDiff, daRating == 'sick');
|
|
dispatchEvent(event);
|
|
|
|
// Calling event.cancelEvent() skips all the other logic! Neat!
|
|
if (event.eventCanceled) return;
|
|
// Display the hit on the strums
|
|
playerStrumline.hitNote(note, !event.isComboBreak);
|
|
if (event.doesNotesplash) playerStrumline.playNoteSplash(note.noteData.getDirection());
|
|
if (note.isHoldNote && note.holdNoteSprite != null) playerStrumline.playNoteHoldCover(note.holdNoteSprite);
|
|
if (vocals != null) vocals.playerVolume = 1;
|
|
|
|
// Display the combo meter and add the calculation to the score.
|
|
if (note.scoreable)
|
|
{
|
|
Highscore.tallies.totalNotesHit++;
|
|
applyScore(event.score, event.judgement, event.healthChange, event.isComboBreak);
|
|
popUpScore(event.judgement);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when a note leaves the screen and is considered missed by the player.
|
|
* @param note
|
|
*/
|
|
function onNoteMiss(note:NoteSprite, playSound:Bool = false, healthChange:Float):Void
|
|
{
|
|
// If we are here, we already CALLED the onNoteMiss script hook!
|
|
|
|
if (!isPracticeMode)
|
|
{
|
|
// messy copy paste rn lol
|
|
var pressArray:Array<Bool> = [
|
|
controls.NOTE_LEFT_P,
|
|
controls.NOTE_DOWN_P,
|
|
controls.NOTE_UP_P,
|
|
controls.NOTE_RIGHT_P
|
|
];
|
|
|
|
var indices:Array<Int> = [];
|
|
for (i in 0...pressArray.length)
|
|
{
|
|
if (pressArray[i]) indices.push(i);
|
|
}
|
|
}
|
|
|
|
applyScore(Scoring.getMissScore(), 'miss', healthChange, true);
|
|
|
|
if (playSound)
|
|
{
|
|
if (vocals != null) vocals.playerVolume = 0;
|
|
FunkinSound.playOnce(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.5, 0.6));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when a player presses a key with no note present.
|
|
* Scripts can modify the amount of health/score lost, whether player animations or sounds are used,
|
|
* or even cancel the event entirely.
|
|
*
|
|
* @param direction
|
|
* @param hasPossibleNotes
|
|
*/
|
|
function ghostNoteMiss(direction:NoteDirection, hasPossibleNotes:Bool = true):Void
|
|
{
|
|
var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in.
|
|
hasPossibleNotes, // Whether there was a note you could have hit.
|
|
Constants.HEALTH_GHOST_MISS_PENALTY, // How much health to add (negative).
|
|
- 10 // Amount of score to add (negative).
|
|
);
|
|
dispatchEvent(event);
|
|
|
|
// Calling event.cancelEvent() skips animations and penalties. Neat!
|
|
if (event.eventCanceled) return;
|
|
|
|
health += event.healthChange;
|
|
songScore += event.scoreChange;
|
|
|
|
if (!isPracticeMode)
|
|
{
|
|
var pressArray:Array<Bool> = [
|
|
controls.NOTE_LEFT_P,
|
|
controls.NOTE_DOWN_P,
|
|
controls.NOTE_UP_P,
|
|
controls.NOTE_RIGHT_P
|
|
];
|
|
|
|
var indices:Array<Int> = [];
|
|
for (i in 0...pressArray.length)
|
|
{
|
|
if (pressArray[i]) indices.push(i);
|
|
}
|
|
}
|
|
|
|
if (event.playSound)
|
|
{
|
|
if (vocals != null) vocals.playerVolume = 0;
|
|
FunkinSound.playOnce(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Debug keys. Disabled while in cutscenes.
|
|
*/
|
|
function debugKeyShit():Void
|
|
{
|
|
#if FEATURE_STAGE_EDITOR
|
|
// Open the stage editor overlaying the current state.
|
|
if (controls.DEBUG_STAGE)
|
|
{
|
|
// hack for HaxeUI generation, doesn't work unless persistentUpdate is false at state creation!!
|
|
disableKeys = true;
|
|
persistentUpdate = false;
|
|
openSubState(new StageOffsetSubState());
|
|
}
|
|
#end
|
|
|
|
#if FEATURE_CHART_EDITOR
|
|
// Redirect to the chart editor playing the current song.
|
|
if (controls.DEBUG_CHART)
|
|
{
|
|
disableKeys = true;
|
|
persistentUpdate = false;
|
|
if (isChartingMode)
|
|
{
|
|
// Close the playtest substate.
|
|
FlxG.sound.music?.pause();
|
|
this.close();
|
|
}
|
|
else
|
|
{
|
|
if (currentStage != null) this.remove(currentStage);
|
|
FlxG.switchState(() -> new ChartEditorState(
|
|
{
|
|
targetSongId: currentSong.id,
|
|
targetSongDifficulty: currentDifficulty,
|
|
targetSongVariation: currentVariation,
|
|
}));
|
|
}
|
|
}
|
|
#end
|
|
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
// H: Hide the HUD.
|
|
if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible;
|
|
|
|
// 1: End the song immediately.
|
|
if (FlxG.keys.justPressed.ONE) endSong(true);
|
|
|
|
// 2: Gain 10% health.
|
|
if (FlxG.keys.justPressed.TWO) health += 0.1 * Constants.HEALTH_MAX;
|
|
|
|
// 3: Lose 5% health.
|
|
if (FlxG.keys.justPressed.THREE) health -= 0.05 * Constants.HEALTH_MAX;
|
|
#end
|
|
|
|
// 9: Toggle the old icon.
|
|
if ((FlxG.keys.justPressed.NINE #if FEATURE_TOUCH_CONTROLS || (TouchUtil.justPressed && TouchUtil.overlapsComplex(iconP1)) #end)
|
|
&& iconP1 != null) iconP1.toggleOldIcon();
|
|
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
// PAGEUP: Skip forward two sections.
|
|
// SHIFT+PAGEUP: Skip forward twenty sections.
|
|
if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 20 : 2);
|
|
// PAGEDOWN: Skip backward two section. Doesn't replace notes.
|
|
// SHIFT+PAGEDOWN: Skip backward twenty sections.
|
|
if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -20 : -2);
|
|
#end
|
|
}
|
|
|
|
/**
|
|
* Handles applying health, score, and ratings.
|
|
*/
|
|
function applyScore(score:Int, daRating:String, healthChange:Float, isComboBreak:Bool)
|
|
{
|
|
switch (daRating)
|
|
{
|
|
case 'sick':
|
|
Highscore.tallies.sick += 1;
|
|
case 'good':
|
|
Highscore.tallies.good += 1;
|
|
case 'bad':
|
|
Highscore.tallies.bad += 1;
|
|
case 'shit':
|
|
Highscore.tallies.shit += 1;
|
|
case 'miss':
|
|
Highscore.tallies.missed += 1;
|
|
default:
|
|
// Nothing!
|
|
}
|
|
health += healthChange;
|
|
if (isComboBreak)
|
|
{
|
|
// Break the combo, but don't increment tallies.misses.
|
|
if (Highscore.tallies.combo >= 10) comboPopUps.displayCombo(0);
|
|
Highscore.tallies.combo = 0;
|
|
}
|
|
else
|
|
{
|
|
Highscore.tallies.combo++;
|
|
if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
|
|
}
|
|
songScore += score;
|
|
}
|
|
|
|
/**
|
|
* Handles rating popups when a note is hit.
|
|
*/
|
|
function popUpScore(daRating:String, ?combo:Int):Void
|
|
{
|
|
if (daRating == 'miss')
|
|
{
|
|
// If daRating is 'miss', that means we made a mistake and should not continue.
|
|
FlxG.log.warn('popUpScore judged a note as a miss!');
|
|
// TODO: Remove this.
|
|
// comboPopUps.displayRating('miss');
|
|
return;
|
|
}
|
|
if (combo == null) combo = Highscore.tallies.combo;
|
|
|
|
if (!isPracticeMode)
|
|
{
|
|
// TODO: Input splitter uses old input system, make it pull from the precise input queue directly.
|
|
var pressArray:Array<Bool> = [
|
|
controls.NOTE_LEFT_P,
|
|
controls.NOTE_DOWN_P,
|
|
controls.NOTE_UP_P,
|
|
controls.NOTE_RIGHT_P
|
|
];
|
|
|
|
var indices:Array<Int> = [];
|
|
for (i in 0...pressArray.length)
|
|
{
|
|
if (pressArray[i]) indices.push(i);
|
|
}
|
|
}
|
|
comboPopUps.displayRating(daRating);
|
|
if (combo >= 10) comboPopUps.displayCombo(combo);
|
|
|
|
if (vocals != null) vocals.playerVolume = 1;
|
|
}
|
|
|
|
/**
|
|
* Handle keyboard inputs during cutscenes.
|
|
* This includes advancing conversations and skipping videos.
|
|
* @param elapsed Time elapsed since last game update.
|
|
*/
|
|
function handleCutsceneKeys(elapsed:Float):Void
|
|
{
|
|
if (isGamePaused) return;
|
|
|
|
var pauseButtonCheck:Bool = false;
|
|
var androidPause:Bool = false;
|
|
|
|
#if android
|
|
androidPause = FlxG.android.justPressed.BACK;
|
|
#end
|
|
|
|
#if mobile
|
|
pauseButtonCheck = TouchUtil.pressAction(pauseButton);
|
|
#end
|
|
|
|
if (currentConversation != null)
|
|
{
|
|
// Pause/unpause may conflict with advancing the conversation!
|
|
if ((controls.CUTSCENE_ADVANCE #if mobile || (!pauseButtonCheck && TouchUtil.justPressed) #end) && !justUnpaused)
|
|
{
|
|
currentConversation.advanceConversation();
|
|
}
|
|
else if ((controls.PAUSE || androidPause || pauseButtonCheck) && !justUnpaused)
|
|
{
|
|
pause(Conversation);
|
|
}
|
|
}
|
|
else if (VideoCutscene.isPlaying())
|
|
{
|
|
// This is a video cutscene.
|
|
if ((controls.PAUSE || androidPause || pauseButtonCheck) && !justUnpaused)
|
|
{
|
|
pause(Cutscene);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle logic for actually skipping a video cutscene after it has been held.
|
|
*/
|
|
function skipVideoCutscene():Void
|
|
{
|
|
VideoCutscene.finishVideo();
|
|
}
|
|
|
|
/**
|
|
* End the song. Handle saving high scores and transitioning to the results screen.
|
|
*
|
|
* Broadcasts an `onSongEnd` event, which can be cancelled to prevent the song from ending (for a cutscene or something).
|
|
* Remember to call `endSong` again when the song should actually end!
|
|
* @param rightGoddamnNow If true, don't play the fancy animation where you zoom onto Girlfriend. Used after a cutscene.
|
|
*/
|
|
public function endSong(rightGoddamnNow:Bool = false):Void
|
|
{
|
|
if (FlxG.sound.music != null) FlxG.sound.music.volume = 0;
|
|
if (vocals != null) vocals.volume = 0;
|
|
mayPauseGame = false;
|
|
isSongEnd = true;
|
|
|
|
// Prevent ghost misses while the song is ending.
|
|
disableKeys = true;
|
|
|
|
#if mobile
|
|
// Hide the buttons while the song is ending.
|
|
if (hitbox != null) hitbox.visible = false;
|
|
pauseButton.visible = false;
|
|
pauseCircle.visible = false;
|
|
#end
|
|
|
|
// Check if any events want to prevent the song from ending.
|
|
var event = new ScriptEvent(SONG_END, true);
|
|
dispatchEvent(event);
|
|
if (event.eventCanceled) return;
|
|
|
|
deathCounter = 0;
|
|
|
|
// TODO: This line of code makes me sad, but you can't really fix it without a breaking migration.
|
|
// `easy`, `erect`, `normal-pico`, etc.
|
|
var suffixedDifficulty = (currentVariation != Constants.DEFAULT_VARIATION
|
|
&& currentVariation != 'erect') ? '$currentDifficulty-${currentVariation}' : currentDifficulty;
|
|
|
|
var isNewHighscore = false;
|
|
var prevScoreData:Null<SaveScoreData> = Save.instance.getSongScore(currentSong.id, suffixedDifficulty);
|
|
|
|
if (currentSong != null && currentSong.validScore)
|
|
{
|
|
// crackhead double thingie, sets whether was new highscore, AND saves the song!
|
|
var data =
|
|
{
|
|
score: songScore,
|
|
tallies:
|
|
{
|
|
sick: Highscore.tallies.sick,
|
|
good: Highscore.tallies.good,
|
|
bad: Highscore.tallies.bad,
|
|
shit: Highscore.tallies.shit,
|
|
missed: Highscore.tallies.missed,
|
|
combo: Highscore.tallies.combo,
|
|
maxCombo: Highscore.tallies.maxCombo,
|
|
totalNotesHit: Highscore.tallies.totalNotesHit,
|
|
totalNotes: Highscore.tallies.totalNotes,
|
|
},
|
|
};
|
|
|
|
// adds current song data into the tallies for the level (story levels)
|
|
Highscore.talliesLevel = Highscore.combineTallies(Highscore.tallies, Highscore.talliesLevel);
|
|
|
|
#if FEATURE_NEWGROUNDS
|
|
Leaderboards.submitSongScore(currentSong.id, suffixedDifficulty, songScore);
|
|
#end
|
|
|
|
if (!isPracticeMode && !isBotPlayMode)
|
|
{
|
|
#if FEATURE_NEWGROUNDS
|
|
Events.logCompleteSong(currentSong.id, currentVariation);
|
|
#end
|
|
|
|
isNewHighscore = Save.instance.isSongHighScore(currentSong.id, suffixedDifficulty, data);
|
|
|
|
// If no high score is present, save both score and rank.
|
|
// If score or rank are better, save the highest one.
|
|
// If neither are higher, nothing will change.
|
|
Save.instance.applySongRank(currentSong.id, suffixedDifficulty, data);
|
|
|
|
if (isNewHighscore) {}
|
|
}
|
|
}
|
|
|
|
#if FEATURE_NEWGROUNDS
|
|
// Only award medals if we are LEGIT.
|
|
if (!isPracticeMode && !isBotPlayMode && !isChartingMode && currentSong.validScore)
|
|
{
|
|
// Award a medal for beating at least one song on any difficulty on a Friday.
|
|
if (Date.now().getDay() == 5) Medals.award(FridayNight);
|
|
|
|
// Determine the score rank for this song we just finished.
|
|
var scoreRank:Null<ScoringRank> = Scoring.calculateRank(
|
|
{
|
|
score: songScore,
|
|
tallies:
|
|
{
|
|
sick: Highscore.tallies.sick,
|
|
good: Highscore.tallies.good,
|
|
bad: Highscore.tallies.bad,
|
|
shit: Highscore.tallies.shit,
|
|
missed: Highscore.tallies.missed,
|
|
combo: Highscore.tallies.combo,
|
|
maxCombo: Highscore.tallies.maxCombo,
|
|
totalNotesHit: Highscore.tallies.totalNotesHit,
|
|
totalNotes: Highscore.tallies.totalNotes,
|
|
}
|
|
});
|
|
|
|
// Award various medals based on variation, difficulty, song ID, and scoring rank.
|
|
if (scoreRank == ScoringRank.SHIT) Medals.award(LossRating);
|
|
if (scoreRank >= ScoringRank.PERFECT && currentDifficulty == 'hard') Medals.award(PerfectRatingHard);
|
|
if (scoreRank == ScoringRank.PERFECT_GOLD && currentDifficulty == 'hard') Medals.award(GoldPerfectRatingHard);
|
|
if (Constants.DEFAULT_DIFFICULTY_LIST_ERECT.contains(currentDifficulty)) Medals.award(ErectDifficulty);
|
|
if (scoreRank == ScoringRank.PERFECT_GOLD && currentDifficulty == 'nightmare') Medals.award(GoldPerfectRatingNightmare);
|
|
if (currentVariation == 'pico' && !PlayStatePlaylist.isStoryMode) Medals.award(FreeplayPicoMix);
|
|
if (currentVariation == 'pico' && currentSong.id == 'stress') Medals.award(FreeplayStressPico);
|
|
|
|
if (scoreRank != null) Events.logEarnRank(scoreRank.toString());
|
|
}
|
|
#end
|
|
|
|
#if FEATURE_MOBILE_ADVERTISEMENTS
|
|
if (AdMobUtil.PLAYING_COUNTER < AdMobUtil.MAX_BEFORE_AD) AdMobUtil.PLAYING_COUNTER++;
|
|
#end
|
|
|
|
if (PlayStatePlaylist.isStoryMode)
|
|
{
|
|
isNewHighscore = false;
|
|
|
|
PlayStatePlaylist.campaignScore += songScore;
|
|
|
|
// Pop the next song ID from the list.
|
|
// Returns null if the list is empty.
|
|
var targetSongId:Null<String> = PlayStatePlaylist.playlistSongIds.shift();
|
|
|
|
if (targetSongId == null)
|
|
{
|
|
if (currentSong.validScore)
|
|
{
|
|
var data =
|
|
{
|
|
score: PlayStatePlaylist.campaignScore,
|
|
tallies:
|
|
{
|
|
// TODO: Sum up the values for the whole week!
|
|
sick: 0,
|
|
good: 0,
|
|
bad: 0,
|
|
shit: 0,
|
|
missed: 0,
|
|
combo: 0,
|
|
maxCombo: 0,
|
|
totalNotesHit: 0,
|
|
totalNotes: 0,
|
|
},
|
|
};
|
|
|
|
if (PlayStatePlaylist.campaignId != null)
|
|
{
|
|
#if FEATURE_NEWGROUNDS
|
|
// Award a medal for beating a Story level.
|
|
Medals.awardStoryLevel(PlayStatePlaylist.campaignId);
|
|
|
|
// Submit the score for the Story level to Newgrounds.
|
|
Leaderboards.submitLevelScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, PlayStatePlaylist.campaignScore);
|
|
|
|
Events.logCompleteLevel(PlayStatePlaylist.campaignId);
|
|
#end
|
|
|
|
if (Save.instance.isLevelHighScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data))
|
|
{
|
|
Save.instance.setLevelScore(PlayStatePlaylist.campaignId, PlayStatePlaylist.campaignDifficulty, data);
|
|
isNewHighscore = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isSubState)
|
|
{
|
|
this.close();
|
|
}
|
|
else
|
|
{
|
|
if (rightGoddamnNow)
|
|
{
|
|
moveToResultsScreen(isNewHighscore);
|
|
}
|
|
else
|
|
{
|
|
zoomIntoResultsScreen(isNewHighscore);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var difficulty:String = '';
|
|
|
|
trace('Loading next song ($targetSongId : $difficulty)');
|
|
|
|
FlxTransitionableState.skipNextTransIn = true;
|
|
FlxTransitionableState.skipNextTransOut = true;
|
|
|
|
FlxG.sound.music?.stop();
|
|
vocals?.stop();
|
|
|
|
// TODO: Softcode this cutscene.
|
|
if (currentSong.id == 'eggnog')
|
|
{
|
|
var blackBG:FunkinSprite = new FunkinSprite(-FlxG.width * FlxG.camera.zoom, -FlxG.height * FlxG.camera.zoom);
|
|
blackBG.makeSolidColor(FlxG.width * 3, FlxG.height * 3, FlxColor.BLACK);
|
|
blackBG.scrollFactor.set();
|
|
add(blackBG);
|
|
camHUD.visible = false;
|
|
isInCutscene = true;
|
|
|
|
FunkinSound.playOnce(Paths.sound('Lights_Shut_off'), function() {
|
|
// no camFollow so it centers on horror tree
|
|
var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId) ?? throw 'Could not find a song with the ID $targetSongId';
|
|
var targetVariation:String = currentVariation;
|
|
if (!targetSong.hasDifficulty(PlayStatePlaylist.campaignDifficulty, currentVariation))
|
|
{
|
|
targetVariation = targetSong.getFirstValidVariation(PlayStatePlaylist.campaignDifficulty) ?? Constants.DEFAULT_VARIATION;
|
|
}
|
|
if (currentStage != null) this.remove(currentStage);
|
|
LoadingState.loadPlayState(
|
|
{
|
|
targetSong: targetSong,
|
|
targetDifficulty: PlayStatePlaylist.campaignDifficulty,
|
|
targetVariation: targetVariation,
|
|
cameraFollowPoint: cameraFollowPoint.getPosition(),
|
|
});
|
|
});
|
|
}
|
|
else
|
|
{
|
|
var targetSong:Song = SongRegistry.instance.fetchEntry(targetSongId) ?? throw 'Could not find a song with ID $targetSongId';
|
|
var targetVariation:String = currentVariation;
|
|
if (!targetSong.hasDifficulty(PlayStatePlaylist.campaignDifficulty, currentVariation))
|
|
{
|
|
targetVariation = targetSong.getFirstValidVariation(PlayStatePlaylist.campaignDifficulty) ?? Constants.DEFAULT_VARIATION;
|
|
}
|
|
if (currentStage != null) this.remove(currentStage);
|
|
LoadingState.loadPlayState(
|
|
{
|
|
targetSong: targetSong,
|
|
targetDifficulty: PlayStatePlaylist.campaignDifficulty,
|
|
targetVariation: targetVariation,
|
|
cameraFollowPoint: cameraFollowPoint.getPosition(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (isSubState)
|
|
{
|
|
this.close();
|
|
}
|
|
else
|
|
{
|
|
if (rightGoddamnNow)
|
|
{
|
|
moveToResultsScreen(isNewHighscore, prevScoreData);
|
|
}
|
|
else
|
|
{
|
|
zoomIntoResultsScreen(isNewHighscore, prevScoreData);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public override function close():Void
|
|
{
|
|
criticalFailure = true; // Stop game updates.
|
|
performCleanup();
|
|
super.close();
|
|
}
|
|
|
|
/**
|
|
* Perform necessary cleanup before leaving the PlayState.
|
|
*/
|
|
function performCleanup():Void
|
|
{
|
|
// If the camera is being tweened, stop it.
|
|
cancelAllCameraTweens();
|
|
|
|
// Dispatch the destroy event.
|
|
dispatchEvent(new ScriptEvent(DESTROY, false));
|
|
|
|
if (currentConversation != null)
|
|
{
|
|
remove(currentConversation);
|
|
currentConversation.kill();
|
|
}
|
|
|
|
if (currentChart != null)
|
|
{
|
|
// TODO: Uncache the song.
|
|
}
|
|
|
|
// Prevent vwoosh timer from running outside PlayState (e.g Chart Editor)
|
|
vwooshTimer.cancel();
|
|
|
|
if (overrideMusic)
|
|
{
|
|
// Stop the music. Do NOT destroy it, something still references it!
|
|
if (FlxG.sound.music != null) FlxG.sound.music.pause();
|
|
if (vocals != null)
|
|
{
|
|
vocals.pause();
|
|
remove(vocals);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Stop and destroy the music.
|
|
if (FlxG.sound.music != null) FlxG.sound.music.pause();
|
|
if (vocals != null)
|
|
{
|
|
vocals.destroy();
|
|
remove(vocals);
|
|
}
|
|
}
|
|
|
|
forEachPausedSound((s) -> s.destroy());
|
|
|
|
FlxTween.globalManager.clear();
|
|
FlxTimer.globalManager.clear();
|
|
|
|
// Remove reference to stage and remove sprites from it to save memory.
|
|
if (currentStage != null)
|
|
{
|
|
remove(currentStage);
|
|
currentStage.kill();
|
|
currentStage = null;
|
|
}
|
|
|
|
GameOverSubState.reset();
|
|
PauseSubState.reset();
|
|
Countdown.reset();
|
|
|
|
// Clear the static reference to this state.
|
|
instance = null;
|
|
}
|
|
|
|
/**
|
|
* Play the camera zoom animation and then move to the results screen once it's done.
|
|
*/
|
|
function zoomIntoResultsScreen(isNewHighscore:Bool, ?prevScoreData:SaveScoreData):Void
|
|
{
|
|
trace('WENT TO RESULTS SCREEN!');
|
|
|
|
// Stop camera zooming on beat.
|
|
cameraZoomRate = 0;
|
|
|
|
// Cancel camera and scroll tweening if it's active.
|
|
cancelAllCameraTweens();
|
|
cancelScrollSpeedTweens();
|
|
|
|
// If the opponent is GF, zoom in on the opponent.
|
|
// Else, if there is no GF, zoom in on BF.
|
|
// Else, zoom in on GF.
|
|
var boyfriend:Null<BaseCharacter> = currentStage?.getBoyfriend();
|
|
var girlfriend:Null<BaseCharacter> = currentStage?.getGirlfriend();
|
|
var dad:Null<BaseCharacter> = currentStage?.getDad();
|
|
|
|
var targetDad:Bool = dad?.characterId == 'gf';
|
|
var targetBF:Bool = girlfriend == null && !targetDad;
|
|
|
|
if (targetBF && boyfriend != null)
|
|
{
|
|
FlxG.camera.follow(boyfriend, null, 0.05);
|
|
}
|
|
else if (targetDad && dad != null)
|
|
{
|
|
FlxG.camera.follow(dad, null, 0.05);
|
|
}
|
|
else if (girlfriend != null)
|
|
{
|
|
FlxG.camera.follow(girlfriend, null, 0.05);
|
|
}
|
|
|
|
// TODO: Make target offset configurable.
|
|
// In the meantime, we have to replace the zoom animation with a fade out.
|
|
FlxG.camera.targetOffset.y -= 350;
|
|
FlxG.camera.targetOffset.x += 20;
|
|
|
|
// Replace zoom animation with a fade out for now.
|
|
FlxG.camera.fade(FlxColor.BLACK, 0.6);
|
|
|
|
FlxTween.tween(camHUD, {alpha: 0}, 0.6,
|
|
{
|
|
onComplete: function(_) {
|
|
moveToResultsScreen(isNewHighscore, prevScoreData);
|
|
}
|
|
});
|
|
|
|
// Zoom in on Girlfriend (or BF if no GF)
|
|
new FlxTimer().start(0.8, function(_) {
|
|
if (targetBF)
|
|
{
|
|
boyfriend?.animation.play('hey');
|
|
}
|
|
else if (targetDad)
|
|
{
|
|
dad?.animation.play('cheer');
|
|
}
|
|
else
|
|
{
|
|
girlfriend?.animation.play('cheer');
|
|
}
|
|
|
|
// Zoom over to the Results screen.
|
|
// TODO: Re-enable this.
|
|
/*
|
|
FlxTween.tween(FlxG.camera, {zoom: 1200}, 1.1,
|
|
{
|
|
ease: FlxEase.expoIn,
|
|
});
|
|
*/
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Move to the results screen right goddamn now.
|
|
*/
|
|
function moveToResultsScreen(isNewHighscore:Bool, ?prevScoreData:SaveScoreData):Void
|
|
{
|
|
var currentChart:SongDifficulty = currentChart ??
|
|
{
|
|
trace('ERROR: Cannot move to results screen with a null chart.');
|
|
return;
|
|
}
|
|
|
|
persistentUpdate = false;
|
|
vocals?.stop();
|
|
camHUD.alpha = 1;
|
|
|
|
var talliesToUse:Tallies = PlayStatePlaylist.isStoryMode ? Highscore.talliesLevel : Highscore.tallies;
|
|
var res:ResultState = new ResultState(
|
|
{
|
|
storyMode: PlayStatePlaylist.isStoryMode,
|
|
songId: currentChart.song.id,
|
|
difficultyId: currentDifficulty,
|
|
variationId: currentVariation,
|
|
characterId: currentChart.characters.player,
|
|
title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
|
|
prevScoreData: prevScoreData,
|
|
scoreData:
|
|
{
|
|
score: PlayStatePlaylist.isStoryMode ? PlayStatePlaylist.campaignScore : songScore,
|
|
tallies:
|
|
{
|
|
sick: talliesToUse.sick,
|
|
good: talliesToUse.good,
|
|
bad: talliesToUse.bad,
|
|
shit: talliesToUse.shit,
|
|
missed: talliesToUse.missed,
|
|
combo: talliesToUse.combo,
|
|
maxCombo: talliesToUse.maxCombo,
|
|
totalNotesHit: talliesToUse.totalNotesHit,
|
|
totalNotes: talliesToUse.totalNotes,
|
|
},
|
|
},
|
|
isNewHighscore: isNewHighscore,
|
|
isPracticeMode: isPracticeMode,
|
|
isBotPlayMode: isBotPlayMode,
|
|
});
|
|
this.persistentDraw = false;
|
|
openSubState(res);
|
|
}
|
|
|
|
/**
|
|
* Pauses music and vocals easily.
|
|
*/
|
|
public function pauseMusic():Void
|
|
{
|
|
if (FlxG.sound.music != null) FlxG.sound.music.pause();
|
|
if (vocals != null) vocals.pause();
|
|
}
|
|
|
|
/**
|
|
* Resets the camera's zoom level and focus point.
|
|
*/
|
|
public function resetCamera(resetZoom:Bool = true, cancelTweens:Bool = true, snap:Bool = true):Void
|
|
{
|
|
// Cancel camera tweens if any are active.
|
|
if (cancelTweens)
|
|
{
|
|
cancelAllCameraTweens();
|
|
}
|
|
|
|
FlxG.camera.follow(cameraFollowPoint, LOCKON, Constants.DEFAULT_CAMERA_FOLLOW_RATE);
|
|
FlxG.camera.targetOffset.set();
|
|
|
|
if (resetZoom)
|
|
{
|
|
resetCameraZoom();
|
|
}
|
|
|
|
// Snap the camera to the follow point immediately.
|
|
if (snap) FlxG.camera.focusOn(cameraFollowPoint.getPosition());
|
|
}
|
|
|
|
/**
|
|
* Sets the camera follow point's position and tweens the camera there.
|
|
*/
|
|
public function tweenCameraToPosition(x:Float = 0, y:Float = 0, duration:Float = 0, ?ease:Null<Float->Float>):Void
|
|
{
|
|
cameraFollowPoint.setPosition(x, y);
|
|
tweenCameraToFollowPoint(duration, ease);
|
|
}
|
|
|
|
/**
|
|
* Disables camera following and tweens the camera to the follow point manually.
|
|
*/
|
|
public function tweenCameraToFollowPoint(duration:Float = 0, ?ease:Null<Float->Float>):Void
|
|
{
|
|
// Cancel the current tween if it's active.
|
|
cancelCameraFollowTween();
|
|
|
|
if (duration == 0)
|
|
{
|
|
// Instant movement. Just reset the camera to force it to the follow point.
|
|
resetCamera(false, false);
|
|
}
|
|
else
|
|
{
|
|
// Disable camera following for the duration of the tween.
|
|
@:nullSafety(Off)
|
|
FlxG.camera.target = null;
|
|
|
|
// Follow tween! Caching it so we can cancel/pause it later if needed.
|
|
var followPos:FlxPoint = cameraFollowPoint.getPosition() - FlxPoint.weak(FlxG.camera.width * 0.5, FlxG.camera.height * 0.5);
|
|
cameraFollowTween = FlxTween.tween(FlxG.camera.scroll, {x: followPos.x, y: followPos.y}, duration,
|
|
{
|
|
ease: ease,
|
|
onComplete: function(_) {
|
|
resetCamera(false, false); // Re-enable camera following when the tween is complete.
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
public function cancelCameraFollowTween()
|
|
{
|
|
if (cameraFollowTween != null)
|
|
{
|
|
cameraFollowTween.cancel();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tweens the camera zoom to the desired amount.
|
|
*/
|
|
public function tweenCameraZoom(zoom:Float = 1, duration:Float = 0, direct:Bool = false, ?ease:Null<Float->Float>):Void
|
|
{
|
|
// Cancel the current tween if it's active.
|
|
cancelCameraZoomTween();
|
|
|
|
// Direct mode: Set zoom directly.
|
|
// Stage mode: Set zoom as a multiplier of the current stage's default zoom.
|
|
var targetZoom = zoom * (direct ? FlxCamera.defaultZoom : stageZoom);
|
|
|
|
if (duration == 0)
|
|
{
|
|
// Instant zoom. No tween needed.
|
|
currentCameraZoom = targetZoom;
|
|
}
|
|
else
|
|
{
|
|
// Zoom tween! Caching it so we can cancel/pause it later if needed.
|
|
cameraZoomTween = FlxTween.tween(this, {currentCameraZoom: targetZoom}, duration, {ease: ease});
|
|
}
|
|
}
|
|
|
|
public function cancelCameraZoomTween():Void
|
|
{
|
|
if (cameraZoomTween != null)
|
|
{
|
|
cameraZoomTween.cancel();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel all active camera tweens simultaneously.
|
|
*/
|
|
public function cancelAllCameraTweens()
|
|
{
|
|
cancelCameraFollowTween();
|
|
cancelCameraZoomTween();
|
|
}
|
|
|
|
var prevScrollTargets:Array<Dynamic> = []; // used to snap scroll speed when things go unruly
|
|
|
|
/**
|
|
* The magical function that shall tween the scroll speed.
|
|
*/
|
|
public function tweenScrollSpeed(?speed:Float, ?duration:Float, ?ease:Null<Float->Float>, strumlines:Array<String>):Void
|
|
{
|
|
// Cancel the current tween if it's active.
|
|
cancelScrollSpeedTweens();
|
|
|
|
// Snap to previous event value to prevent the tween breaking when another event cancels the previous tween.
|
|
for (i in prevScrollTargets)
|
|
{
|
|
var value:Float = i[0];
|
|
var strum:Strumline = Reflect.getProperty(this, i[1]);
|
|
strum.scrollSpeed = value;
|
|
}
|
|
|
|
// for next event, clean array.
|
|
prevScrollTargets = [];
|
|
|
|
for (i in strumlines)
|
|
{
|
|
var value:Float = speed ?? 0;
|
|
var strum:Strumline = Reflect.getProperty(this, i);
|
|
|
|
if (duration == 0)
|
|
{
|
|
strum.scrollSpeed = value;
|
|
}
|
|
else
|
|
{
|
|
scrollSpeedTweens.push(FlxTween.tween(strum,
|
|
{
|
|
'scrollSpeed': value
|
|
}, duration, {ease: ease}));
|
|
}
|
|
// make sure charts dont break if the charter is dumb and stupid
|
|
prevScrollTargets.push([value, i]);
|
|
}
|
|
}
|
|
|
|
public function cancelScrollSpeedTweens()
|
|
{
|
|
for (tween in scrollSpeedTweens)
|
|
{
|
|
if (tween != null)
|
|
{
|
|
tween.cancel();
|
|
}
|
|
}
|
|
scrollSpeedTweens = [];
|
|
}
|
|
|
|
function forEachPausedSound(f:FlxSound->Void):Void
|
|
{
|
|
for (sound in soundsPausedBySubState)
|
|
{
|
|
f(sound);
|
|
}
|
|
soundsPausedBySubState.clear();
|
|
}
|
|
|
|
#if FEATURE_DEBUG_FUNCTIONS
|
|
/**
|
|
* Jumps forward or backward a number of sections in the song.
|
|
* Accounts for BPM changes, does not prevent death from skipped notes.
|
|
* @param sections The number of sections to jump, negative to go backwards.
|
|
*/
|
|
function changeSection(sections:Int):Void
|
|
{
|
|
// FlxG.sound.music.pause();
|
|
|
|
var targetTimeSteps:Float = Conductor.instance.currentStepTime + (Conductor.instance.stepsPerMeasure * sections);
|
|
var targetTimeMs:Float = Conductor.instance.getStepTimeInMs(targetTimeSteps);
|
|
|
|
// Don't go back in time to before the song started.
|
|
targetTimeMs = Math.max(0, targetTimeMs);
|
|
|
|
if (FlxG.sound.music != null)
|
|
{
|
|
FlxG.sound.music.time = targetTimeMs;
|
|
}
|
|
|
|
handleSkippedNotes();
|
|
SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition);
|
|
// regenNoteData(FlxG.sound.music.time);
|
|
|
|
Conductor.instance.update(FlxG.sound?.music?.time ?? 0.0);
|
|
|
|
resyncVocals();
|
|
}
|
|
#end
|
|
}
|