First iteration of stable custom characters, plus some other neat changes.

This commit is contained in:
Eric Myllyoja 2022-03-21 00:19:05 -04:00
parent 1a85b4a1ce
commit a7d338becb
36 changed files with 1744 additions and 812 deletions

View file

@ -122,7 +122,8 @@
<!--haxelib name="newgrounds" unless="switch"/> -->
<haxelib name="faxe" if='switch' />
<haxelib name="polymod" />
<haxelib name="firetongue" />
<haxelib name="thx.semver" />
<!-- <haxelib name="colyseus"/> -->
<!-- <haxelib name="colyseus-websocket" /> -->

Binary file not shown.

Before

(image error) Size: 2.3 MiB

View file

@ -46,8 +46,6 @@ class Main extends Sprite
// 4. Replace the call to PolymodHandler.loadAllMods() with a call to PolymodHandler.loadModsById(ids:Array<String>).
funkin.modding.PolymodHandler.loadAllMods();
funkin.i18n.FireTongueHandler.init();
if (stage != null)
{
init();

View file

@ -57,7 +57,6 @@ class Character extends FlxSprite
loadOffsetFile(curCharacter);
playAnim('danceRight');
case 'gf-christmas':
tex = Paths.getSparrowAtlas('characters/gfChristmas');
frames = tex;
@ -133,19 +132,6 @@ class Character extends FlxSprite
updateHitbox();
antialiasing = false;
case 'dad':
// DAD ANIMATION LOADING CODE
tex = Paths.getSparrowAtlas('characters/DADDY_DEAREST');
frames = tex;
quickAnimAdd('idle', 'Dad idle dance');
quickAnimAdd('singUP', 'Dad Sing Note UP');
quickAnimAdd('singRIGHT', 'Dad Sing Note RIGHT');
quickAnimAdd('singDOWN', 'Dad Sing Note DOWN');
quickAnimAdd('singLEFT', 'Dad Sing Note LEFT');
loadOffsetFile(curCharacter);
playAnim('idle');
case 'spooky':
tex = Paths.getSparrowAtlas('characters/spooky_kids_assets');
frames = tex;
@ -259,36 +245,6 @@ class Character extends FlxSprite
loadMappedAnims();
case 'bf':
var tex = Paths.getSparrowAtlas('characters/BOYFRIEND');
frames = tex;
quickAnimAdd('idle', 'BF idle dance');
quickAnimAdd('singUP', 'BF NOTE UP0');
quickAnimAdd('singLEFT', 'BF NOTE LEFT0');
quickAnimAdd('singRIGHT', 'BF NOTE RIGHT0');
quickAnimAdd('singDOWN', 'BF NOTE DOWN0');
quickAnimAdd('singUPmiss', 'BF NOTE UP MISS');
quickAnimAdd('singLEFTmiss', 'BF NOTE LEFT MISS');
quickAnimAdd('singRIGHTmiss', 'BF NOTE RIGHT MISS');
quickAnimAdd('singDOWNmiss', 'BF NOTE DOWN MISS');
quickAnimAdd('preAttack', 'bf pre attack');
quickAnimAdd('attack', 'boyfriend attack');
quickAnimAdd('hey', 'BF HEY');
quickAnimAdd('firstDeath', "BF dies");
animation.addByPrefix('deathLoop', "BF Dead Loop", 24, true);
quickAnimAdd('deathConfirm', "BF Dead confirm");
animation.addByPrefix('scared', 'BF idle shaking', 24, true);
loadOffsetFile(curCharacter);
playAnim('idle');
flipX = true;
loadOffsetFile(curCharacter);
case 'bf-christmas':
var tex = Paths.getSparrowAtlas('characters/bfChristmas');
frames = tex;
@ -693,7 +649,7 @@ class Character extends FlxSprite
*/
public function dance()
{
if (animation == null)
if (animation == null || animation.curAnim == null)
return;
if (!debugMode)
{

View file

@ -1,5 +1,8 @@
package funkin;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.modding.events.ScriptEvent;
import funkin.play.character.BaseCharacter;
import flixel.FlxObject;
import flixel.FlxSubState;
import flixel.math.FlxPoint;
@ -10,80 +13,83 @@ import haxe.display.Display;
import funkin.ui.PreferencesMenu;
import funkin.play.PlayState;
using StringTools;
/**
* A substate which renders over the PlayState when the player dies.
* Displays the player death animation, plays the music, and handles restarting the song.
*
* The newest implementation uses a substate, which prevents having to reload the song and stage each reset.
*/
class GameOverSubstate extends MusicBeatSubstate
{
var bf:Boyfriend;
var camFollow:FlxObject;
/**
* The boyfriend character.
*/
var boyfriend:BaseCharacter;
var stageSuffix:String = "";
var randomGameover:Int = 1;
/**
* The invisible object in the scene which the camera focuses on.
*/
var cameraFollowPoint:FlxObject;
var gameOverMusic:FlxSound;
/**
* The music playing in the background of the state.
*/
var gameOverMusic:FlxSound = new FlxSound();
/**
* Whether the player has confirmed and prepared to restart the level.
* This means the animation and transition have already started.
*/
var isEnding:Bool = false;
/**
* Music variant to use.
* TODO: De-hardcode this somehow.
*/
var musicVariant:String = "";
public function new()
{
gameOverMusic = new FlxSound();
FlxG.sound.list.add(gameOverMusic);
var daStage = PlayState.instance.currentStageId;
var daBf:String = '';
switch (daStage)
{
case 'school' | 'schoolEvil':
stageSuffix = '-pixel';
daBf = 'bf-pixel-dead';
default:
daBf = 'bf';
}
var daSong = PlayState.currentSong.song.toLowerCase();
switch (daSong)
{
case 'stress':
daBf = 'bf-holding-gf-dead';
}
super();
FlxG.sound.list.add(gameOverMusic);
gameOverMusic.stop();
Conductor.songPosition = 0;
var bfXPos = PlayState.instance.currentStage.getBoyfriend().getScreenPosition().x;
var bfYPos = PlayState.instance.currentStage.getBoyfriend().getScreenPosition().y;
bf = new Boyfriend(bfXPos, bfYPos, daBf);
add(bf);
playBlueBalledSFX();
camFollow = new FlxObject(bf.getGraphicMidpoint().x, bf.getGraphicMidpoint().y, 1, 1);
add(camFollow);
FlxG.sound.play(Paths.sound('fnf_loss_sfx' + stageSuffix));
// Conductor.changeBPM(100);
switch (PlayState.currentSong.player1)
switch (PlayState.instance.currentStageId)
{
case 'pico':
stageSuffix = 'Pico';
case 'school' | 'schoolEvil':
musicVariant = "-pixel";
default:
if (PlayState.instance.currentStage.getBoyfriend().characterId == 'pico')
{
musicVariant = "Pico";
}
else
{
musicVariant = "";
}
}
// FlxG.camera.followLerp = 1;
// FlxG.camera.focusOn(FlxPoint.get(FlxG.width / 2, FlxG.height / 2));
// We have to remove boyfriend from the stage. Then we can add him back at the end.
boyfriend = PlayState.instance.currentStage.getBoyfriend(true);
boyfriend.isDead = true;
boyfriend.playAnimation('firstDeath');
add(boyfriend);
// commented out for now
FlxG.camera.scroll.set();
cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
add(cameraFollowPoint);
// FlxG.camera.scroll.set();
FlxG.camera.target = null;
bf.playAnim('firstDeath');
var randomCensor:Array<Int> = [];
if (PreferencesMenu.getPref('censor-naughty'))
randomCensor = [1, 3, 8, 13, 17, 21];
randomGameover = FlxG.random.int(1, 25, randomCensor);
FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01);
}
var playingDeathSound:Bool = false;
override function update(elapsed:Float)
{
// makes the lerp non-dependant on the framerate
@ -96,14 +102,14 @@ class GameOverSubstate extends MusicBeatSubstate
var touch = FlxG.touches.getFirst();
if (touch != null)
{
if (touch.overlaps(bf))
endBullshit();
if (touch.overlaps(boyfriend))
confirmDeath();
}
}
if (controls.ACCEPT)
{
endBullshit();
confirmDeath();
}
if (controls.BACK)
@ -119,74 +125,129 @@ class GameOverSubstate extends MusicBeatSubstate
FlxG.switchState(new FreeplayState());
}
if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.curFrame == 12)
// Start panning the camera to BF after 12 frames.
// TODO: Should this be de-hardcoded?
if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.animation.curAnim.curFrame == 12)
{
FlxG.camera.follow(camFollow, LOCKON, 0.01);
}
switch (PlayState.storyWeek)
{
case 7:
if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.finished && !playingDeathSound)
{
playingDeathSound = true;
bf.startedDeath = true;
coolStartDeath(0.2);
FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + randomGameover), 1, false, null, true, function()
{
if (!isEnding)
{
gameOverMusic.fadeIn(4, 0.2, 1);
}
// FlxG.sound.music.fadeIn(4, 0.2, 1);
});
}
default:
if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.finished)
{
bf.startedDeath = true;
coolStartDeath();
}
cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
}
if (gameOverMusic.playing)
{
Conductor.songPosition = gameOverMusic.time;
}
else
{
switch (PlayState.storyWeek)
{
case 7:
if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !playingJeffQuote)
{
playingJeffQuote = true;
playJeffQuote();
startDeathMusic(0.2);
}
default:
if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished())
{
startDeathMusic();
}
}
}
dispatchEvent(new UpdateScriptEvent(elapsed));
}
private function coolStartDeath(?vol:Float = 1):Void
override function dispatchEvent(event:ScriptEvent)
{
super.dispatchEvent(event);
ScriptEventDispatcher.callEvent(boyfriend, event);
}
/**
* Starts the death music at the appropriate volume.
* @param startingVolume
*/
function startDeathMusic(?startingVolume:Float = 1):Void
{
if (!isEnding)
{
gameOverMusic.loadEmbedded(Paths.music('gameOver' + stageSuffix));
gameOverMusic.volume = vol;
gameOverMusic.loadEmbedded(Paths.music('gameOver' + musicVariant));
gameOverMusic.volume = startingVolume;
gameOverMusic.play();
}
else
{
gameOverMusic.loadEmbedded(Paths.music('gameOverEnd' + musicVariant));
gameOverMusic.volume = startingVolume;
gameOverMusic.play();
}
// FlxG.sound.playMusic();
}
var isEnding:Bool = false;
/**
* Play the sound effect that occurs when
* boyfriend's testicles get utterly annihilated.
*/
function playBlueBalledSFX()
{
FlxG.sound.play(Paths.sound('fnf_loss_sfx' + musicVariant));
}
function endBullshit():Void
var playingJeffQuote:Bool = false;
/**
* Week 7-specific hardcoded behavior, to play a custom death quote.
* TODO: Make this a module somehow.
*/
function playJeffQuote()
{
var randomCensor:Array<Int> = [];
if (PreferencesMenu.getPref('censor-naughty'))
randomCensor = [1, 3, 8, 13, 17, 21];
FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + FlxG.random.int(1, 25, randomCensor)), 1, false, null, true, function()
{
// Once the quote ends, fade in the game over music.
if (!isEnding && gameOverMusic != null)
{
gameOverMusic.fadeIn(4, 0.2, 1);
}
});
}
/**
* Do behavior which occurs when you confirm and move to restart the level.
*/
function confirmDeath():Void
{
if (!isEnding)
{
isEnding = true;
bf.playAnim('deathConfirm', true);
gameOverMusic.stop();
// FlxG.sound.music.stop();
FlxG.sound.play(Paths.music('gameOverEnd' + stageSuffix));
startDeathMusic(); // isEnding changes this function's behavior.
boyfriend.playAnimation('deathConfirm', true);
// After the animation finishes...
new FlxTimer().start(0.7, function(tmr:FlxTimer)
{
// ...fade out the graphics. Then after that happens...
FlxG.camera.fade(FlxColor.BLACK, 2, false, function()
{
// ...close the GameOverSubstate.
FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true);
PlayState.needsReset = true;
// Readd Boyfriend to the stage.
boyfriend.isDead = false;
remove(boyfriend);
PlayState.instance.currentStage.addCharacter(boyfriend, BF);
// Close the substate.
close();
// LoadingState.loadAndSwitchState(new PlayState());
});
});
}

View file

@ -1,5 +1,9 @@
package funkin;
import funkin.play.stage.StageData.StageDataParser;
import funkin.play.character.CharacterData.CharacterDataParser;
import flixel.FlxState;
import flixel.FlxSubState;
import flixel.util.FlxColor;
import flixel.text.FlxText;
import funkin.modding.events.ScriptEvent;
@ -8,6 +12,10 @@ import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
import funkin.Conductor.BPMChangeEvent;
import flixel.addons.ui.FlxUIState;
/**
* MusicBeatState actually represents the core utility FlxState of the game.
* It includes functionality for event handling, as well as maintaining BPM-based update events.
*/
class MusicBeatState extends FlxUIState
{
private var curStep:Int = 0;
@ -21,6 +29,19 @@ class MusicBeatState extends FlxUIState
public var leftWatermarkText:FlxText = null;
public var rightWatermarkText:FlxText = null;
public function new()
{
super();
initCallbacks();
}
function initCallbacks()
{
subStateOpened.add(onOpenSubstateComplete);
subStateClosed.add(onCloseSubstateComplete);
}
override function create()
{
super.create();
@ -35,6 +56,10 @@ class MusicBeatState extends FlxUIState
{
super.update(elapsed);
// This can now be used in EVERY STATE YAY!
if (FlxG.keys.justPressed.F5)
debug_refreshModules();
// everyStep();
var oldStep:Int = curStep;
@ -71,6 +96,23 @@ class MusicBeatState extends FlxUIState
ModuleHandler.callEvent(event);
}
function debug_refreshModules()
{
ModuleHandler.clearModuleCache();
// Forcibly reload scripts so that scripted stages can be edited.
polymod.hscript.PolymodScriptClass.clearScriptClasses();
polymod.hscript.PolymodScriptClass.registerAllScriptClasses();
// Reload the stages in cache. This might cause a lag spike but who cares this is a debug utility.
StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache();
ModuleHandler.loadModuleCache();
// Create a new instance of the current state class.
FlxG.resetState();
}
private function updateBeat():Void
{
curBeat = Math.floor(curStep / 4);
@ -103,4 +145,56 @@ class MusicBeatState extends FlxUIState
lastBeatHitTime = Conductor.songPosition;
// do literally nothing dumbass
}
override function switchTo(nextState:FlxState):Bool
{
var event = new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_BEGIN, nextState, true);
dispatchEvent(event);
if (event.eventCanceled)
{
return false;
}
return super.switchTo(nextState);
}
public override function openSubState(targetSubstate:FlxSubState):Void
{
var event = new SubStateScriptEvent(ScriptEvent.SUBSTATE_OPEN_BEGIN, targetSubstate, true);
dispatchEvent(event);
if (event.eventCanceled)
{
return;
}
super.openSubState(targetSubstate);
}
function onOpenSubstateComplete(targetState:FlxSubState):Void
{
dispatchEvent(new SubStateScriptEvent(ScriptEvent.SUBSTATE_OPEN_END, targetState, true));
}
public override function closeSubState():Void
{
var event = new SubStateScriptEvent(ScriptEvent.SUBSTATE_CLOSE_BEGIN, this.subState, true);
dispatchEvent(event);
if (event.eventCanceled)
{
return;
}
super.closeSubState();
}
function onCloseSubstateComplete(targetState:FlxSubState):Void
{
dispatchEvent(new SubStateScriptEvent(ScriptEvent.SUBSTATE_CLOSE_END, targetState, true));
}
}

View file

@ -1,8 +1,13 @@
package funkin;
import funkin.modding.module.ModuleHandler;
import funkin.modding.events.ScriptEvent;
import funkin.Conductor.BPMChangeEvent;
import flixel.FlxSubState;
/**
* MusicBeatSubstate reincorporates the functionality of MusicBeatState into an FlxSubState.
*/
class MusicBeatSubstate extends FlxSubState
{
public function new()
@ -53,6 +58,11 @@ class MusicBeatSubstate extends FlxSubState
beatHit();
}
function dispatchEvent(event:ScriptEvent)
{
ModuleHandler.callEvent(event);
}
public function beatHit():Void
{
// do literally nothing dumbass

View file

@ -227,6 +227,7 @@ class Note extends FlxSprite
{
super.update(elapsed);
// mustPress indicates the player is the one pressing the key
if (mustPress)
{
// miss on the NEXT frame so lag doesnt make u miss notes
@ -244,7 +245,8 @@ class Note extends FlxSprite
}
if (data.strumTime > Conductor.songPosition - HIT_WINDOW)
{ // * 0.5 if sustain note, so u have to keep holding it closer to all the way thru!
{
// * 0.5 if sustain note, so u have to keep holding it closer to all the way thru!
if (data.strumTime < Conductor.songPosition + (HIT_WINDOW * (isSustainNote ? 0.5 : 1)))
canBeHit = true;
}
@ -455,7 +457,12 @@ enum abstract NoteColor(NoteType) from Int to Int from NoteType
enum abstract NoteKind(String) from String to String
{
/**
* The default note type.
*/
var NORMAL = "normal";
// Testing shiz
var PYRO_LIGHT = "pyro_light";
var PYRO_KICK = "pyro_kick";
var PYRO_TOSS = "pyro_toss";

View file

@ -34,6 +34,8 @@ class StoryMenuState extends MusicBeatState
];
var curDifficulty:Int = 1;
// TODO: This info is just hardcoded right now.
// We should probably make it so that weeks must be completed in order to unlock the next week.
public static var weekUnlocked:Array<Bool> = [true, true, true, true, true, true, true, true];
var weekCharacters:Array<Dynamic> = [

View file

@ -1,114 +0,0 @@
package funkin.i18n;
import firetongue.FireTongue;
class FireTongueHandler
{
static final DEFAULT_LOCALE = 'en-US';
// static final DEFAULT_LOCALE = 'pt-BR';
static final LOCALE_DIR = 'assets/locales/';
static var tongue:FireTongue;
/**
* Initialize the FireTongue instance.
* This will automatically start with the default locale for you.
*/
public static function init():Void
{
tongue = new FireTongue(OPENFL, // Haxe framework being used.
// This should really have been a parameterized object...
null, // Function to check if a file exists. Specify null to use the one from the framework.
null, // Function to retrieve the text of a file. Specify null to use the one from the framework.
null, // Function to get a list of files in a directory. Specify null to use the one from the framework.
firetongue.FireTongue.Case.Upper);
// TODO: Make this use the language from the user's preferences.
setLanguage(DEFAULT_LOCALE);
trace('[FIRETONGUE] Initialized. Available locales: ${tongue.locales.join(', ')}');
}
/**
* Switch the language used by FireTongue.
* @param locale The name of the locale to use, such as `en-US`.
*/
public static function setLanguage(locale:String):Void
{
tongue.initialize({
locale: locale, // The locale to load.
finishedCallback: onFinishLoad, // Function run when the locale is loaded.
directory: LOCALE_DIR, // Folder (relative to assets/) to load data from.
replaceMissing: false, // If true, missing flags fallback to the default locale.
checkMissing: true, // If true, check for and store the list of missing flags for this locale.
});
}
/**
* Function called when FireTongue finishes loading a language.
*/
static function onFinishLoad()
{
if (tongue == null)
return;
trace('[FIRETONGUE] Finished loading locale: ${tongue.locale}');
if (tongue.missingFlags != null)
{
if (tongue.missingFlags.get(tongue.locale) != null && tongue.missingFlags.get(tongue.locale).length != 0)
{
trace('[FIRETONGUE] Missing flags: ${tongue.missingFlags.get(tongue.locale).join(', ')}');
}
else
{
trace('[FIRETONGUE] No missing flags for this locale. (Note: Another locale has missing flags.)');
}
}
else
{
trace('[FIRETONGUE] No missing flags.');
}
trace('[FIRETONGUE] HELLO_WORLD = ${t("HELLO_WORLD")}');
}
/**
* Retrieve a localized string based on the given key.
*
* Example:
* import i18n.FiretongueHandler.t;
* trace(t('HELLO')); // Prints "Hello!"
*
* @param key The key to use to retrieve the localized string.
* @param context The data file to load the key from.
* @return The localized string.
*/
public static function t(key:String, context:String = 'data'):String
{
// The localization strings can be stored all in one file,
// or split into several contexts.
return tongue.get(key, context);
}
/**
* Retrieve a localized string while replacing specific values.
* In this way, you can use the same invocation call to properly localize
* a variety of different languages with distinct grammar.
*
* Example:
* import i18n.FiretongueHandler.f;
* trace(f('COLLECT_X_APPLES', 'data', ['<X>'], ['10']); // Prints "Collect 10 apples!"
*
* @param key The key to use to retrieve the localized string.
* @param context The data file to load the key from.
* @param flags The flags to replace in the string.
* @param values The values to replace those flags with.
* @return The localized string.
*/
public static function f(key:String, context:String = 'data', flags:Array<String> = null, values:Array<String> = null):String
{
var str = t(key, context);
return firetongue.Replace.flags(str, flags, values);
}
}

View file

@ -1,3 +0,0 @@
# i18n
This package contains functions used for internationalization (i18n).

View file

@ -23,6 +23,20 @@ interface IStateChangingScriptedClass extends IScriptedClass
{
public function onStateChangeBegin(event:StateChangeScriptEvent):Void;
public function onStateChangeEnd(event:StateChangeScriptEvent):Void;
public function onSubstateOpenBegin(event:SubStateScriptEvent):Void;
public function onSubstateOpenEnd(event:SubStateScriptEvent):Void;
public function onSubstateCloseBegin(event:SubStateScriptEvent):Void;
public function onSubstateCloseEnd(event:SubStateScriptEvent):Void;
}
/**
* Defines a set of callbacks available to scripted classes which represent notes.
*/
interface INoteScriptedClass extends IScriptedClass
{
public function onNoteHit(event:NoteScriptEvent):Void;
public function onNoteMiss(event:NoteScriptEvent):Void;
}
/**
@ -46,12 +60,12 @@ interface IPlayStateScriptedClass extends IScriptedClass
public function onSongLoaded(eent:SongLoadScriptEvent):Void;
public function onSongStart(event:ScriptEvent):Void;
public function onSongEnd(event:ScriptEvent):Void;
public function onSongReset(event:ScriptEvent):Void;
public function onGameOver(event:ScriptEvent):Void;
public function onGameRetry(event:ScriptEvent):Void;
public function onSongRetry(event:ScriptEvent):Void;
public function onNoteHit(event:NoteScriptEvent):Void;
public function onNoteMiss(event:NoteScriptEvent):Void;
public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void;
public function onStepHit(event:SongTimeScriptEvent):Void;
public function onBeatHit(event:SongTimeScriptEvent):Void;

View file

@ -1,5 +1,8 @@
package funkin.modding.events;
import flixel.FlxState;
import flixel.FlxSubState;
import funkin.Note.NoteDir;
import funkin.play.Countdown.CountdownStep;
import openfl.events.EventType;
import openfl.events.KeyboardEvent;
@ -83,6 +86,15 @@ class ScriptEvent
*/
public static inline final NOTE_MISS:ScriptEventType = "NOTE_MISS";
/**
* Called when a character presses a note when there was none there, causing them to lose health.
* Important information such as direction pressed, etc. are all provided.
*
* This event IS cancelable! Canceling this event prevents the note from being considered missed,
* avoiding lost health/score and preventing the miss animation.
*/
public static inline final NOTE_GHOST_MISS:ScriptEventType = "NOTE_GHOST_MISS";
/**
* Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin.
*
@ -97,13 +109,6 @@ class ScriptEvent
*/
public static inline final SONG_END:ScriptEventType = "SONG_END";
/**
* Called when the song is reset. This can happen from the pause menu or the game over screen.
*
* This event is not cancelable.
*/
public static inline final SONG_RESET:ScriptEventType = "SONG_RESET";
/**
* Called when the countdown begins. This occurs before the song starts.
*
@ -130,18 +135,19 @@ class ScriptEvent
public static inline final COUNTDOWN_END:ScriptEventType = "COUNTDOWN_END";
/**
* Called when the game over screen triggers and the death animation plays.
* Called before the game over screen triggers and the death animation plays.
*
* This event is not cancelable.
*/
public static inline final GAME_OVER:ScriptEventType = "GAME_OVER";
/**
* Called when the player presses a key to restart the game after the death animation.
* Called when the player presses a key to restart the game.
* This can happen from the pause menu or the game over screen.
*
* This event IS cancelable! Canceling this event will prevent the game from restarting.
*/
public static inline final GAME_RETRY:ScriptEventType = "GAME_RETRY";
public static inline final SONG_RETRY:ScriptEventType = "SONG_RETRY";
/**
* Called when the player pushes down any key on the keyboard.
@ -166,11 +172,46 @@ class ScriptEvent
public static inline final SONG_LOADED:ScriptEventType = "SONG_LOADED";
/**
* Called when the game is entering the current FlxState.
* Called when the game is about to switch the current FlxState.
*
* This event is not cancelable.
*/
public static inline final STATE_ENTER:ScriptEventType = "STATE_ENTER";
public static inline final STATE_CHANGE_BEGIN:ScriptEventType = "STATE_CHANGE_BEGIN";
/**
* Called when the game has finished switching the current FlxState.
*
* This event is not cancelable.
*/
public static inline final STATE_CHANGE_END:ScriptEventType = "STATE_CHANGE_END";
/**
* Called when the game is about to open a new FlxSubState.
*
* This event is not cancelable.
*/
public static inline final SUBSTATE_OPEN_BEGIN:ScriptEventType = "SUBSTATE_OPEN_BEGIN";
/**
* Called when the game has finished opening a new FlxSubState.
*
* This event is not cancelable.
*/
public static inline final SUBSTATE_OPEN_END:ScriptEventType = "SUBSTATE_OPEN_END";
/**
* Called when the game is about to close the current FlxSubState.
*
* This event is not cancelable.
*/
public static inline final SUBSTATE_CLOSE_BEGIN:ScriptEventType = "SUBSTATE_CLOSE_BEGIN";
/**
* Called when the game has finished closing the current FlxSubState.
*
* This event is not cancelable.
*/
public static inline final SUBSTATE_CLOSE_END:ScriptEventType = "SUBSTATE_CLOSE_END";
/**
* Called when the game is exiting the current FlxState.
@ -265,6 +306,59 @@ class NoteScriptEvent extends ScriptEvent
}
}
/**
* An event that is fired when you press a key with no note present.
*/
class GhostMissNoteScriptEvent extends ScriptEvent
{
/**
* The direction that was mistakenly pressed.
*/
public var dir(default, null):NoteDir;
/**
* Whether there was a note within judgement range when this ghost note was pressed.
*/
public var hasPossibleNotes(default, null):Bool;
/**
* How much health should be lost when this ghost note is pressed.
* Remember that max health is 2.00.
*/
public var healthChange(default, default):Float;
/**
* How much score should be lost when this ghost note is pressed.
*/
public var scoreChange(default, default):Int;
/**
* Whether to play the record scratch sound.
*/
public var playSound(default, default):Bool;
/**
* Whether to play the miss animation on the player.
*/
public var playAnim(default, default):Bool;
public function new(dir:NoteDir, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void
{
super(ScriptEvent.NOTE_GHOST_MISS, true);
this.dir = dir;
this.hasPossibleNotes = hasPossibleNotes;
this.healthChange = healthChange;
this.scoreChange = scoreChange;
this.playSound = true;
this.playAnim = true;
}
public override function toString():String
{
return 'GhostMissNoteScriptEvent(dir=' + dir + ', hasPossibleNotes=' + hasPossibleNotes + ')';
}
}
/**
* An event that is fired during the update loop.
*/
@ -403,13 +497,41 @@ class SongLoadScriptEvent extends ScriptEvent
*/
class StateChangeScriptEvent extends ScriptEvent
{
public function new(type:ScriptEventType):Void
/**
* The state the game is moving into.
*/
public var targetState(default, null):FlxState;
public function new(type:ScriptEventType, targetState:FlxState, cancelable:Bool = false):Void
{
super(type, false);
super(type, cancelable);
this.targetState = targetState;
}
public override function toString():String
{
return 'StateChangeScriptEvent(type=' + type + ')';
return 'StateChangeScriptEvent(type=' + type + ', targetState=' + targetState + ')';
}
}
/**
* An event that is fired when moving out of or into an FlxSubState.
*/
class SubStateScriptEvent extends ScriptEvent
{
/**
* The state the game is moving into.
*/
public var targetState(default, null):FlxSubState;
public function new(type:ScriptEventType, targetState:FlxSubState, cancelable:Bool = false):Void
{
super(type, cancelable);
this.targetState = targetState;
}
public override function toString():String
{
return 'SubStateScriptEvent(type=' + type + ', targetState=' + targetState + ')';
}
}

View file

@ -35,18 +35,6 @@ class ScriptEventDispatcher
return;
}
if (Std.isOfType(target, IStateChangingScriptedClass))
{
var t = cast(target, IStateChangingScriptedClass);
var t = cast(target, IPlayStateScriptedClass);
switch (event.type)
{
case ScriptEvent.NOTE_HIT:
t.onNoteHit(cast event);
return;
}
}
if (Std.isOfType(target, IPlayStateScriptedClass))
{
var t = cast(target, IPlayStateScriptedClass);
@ -58,6 +46,9 @@ class ScriptEventDispatcher
case ScriptEvent.NOTE_MISS:
t.onNoteMiss(cast event);
return;
case ScriptEvent.NOTE_GHOST_MISS:
t.onNoteGhostMiss(cast event);
return;
case ScriptEvent.SONG_BEAT_HIT:
t.onBeatHit(cast event);
return;
@ -70,8 +61,11 @@ class ScriptEventDispatcher
case ScriptEvent.SONG_END:
t.onSongEnd(event);
return;
case ScriptEvent.SONG_RESET:
t.onSongReset(event);
case ScriptEvent.SONG_RETRY:
t.onSongRetry(event);
return;
case ScriptEvent.GAME_OVER:
t.onGameOver(event);
return;
case ScriptEvent.PAUSE:
t.onPause(event);
@ -94,7 +88,38 @@ class ScriptEventDispatcher
}
}
throw "No helper for event type: " + event.type;
if (Std.isOfType(target, IStateChangingScriptedClass))
{
var t = cast(target, IStateChangingScriptedClass);
switch (event.type)
{
case ScriptEvent.STATE_CHANGE_BEGIN:
t.onStateChangeBegin(cast event);
return;
case ScriptEvent.STATE_CHANGE_END:
t.onStateChangeEnd(cast event);
return;
case ScriptEvent.SUBSTATE_OPEN_BEGIN:
t.onSubstateOpenBegin(cast event);
return;
case ScriptEvent.SUBSTATE_OPEN_END:
t.onSubstateOpenEnd(cast event);
return;
case ScriptEvent.SUBSTATE_CLOSE_BEGIN:
t.onSubstateCloseBegin(cast event);
return;
case ScriptEvent.SUBSTATE_CLOSE_END:
t.onSubstateCloseEnd(cast event);
return;
}
}
else
{
// Prevent "NO HELPER error."
return;
}
throw "No function called for event type: " + event.type;
}
public static function callEventOnAllTargets(targets:Iterator<IScriptedClass>, event:ScriptEvent):Void

View file

@ -18,7 +18,7 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
/**
* Whether the module is currently active.
*/
public var active(default, set):Bool = false;
public var active(default, set):Bool = true;
function set_active(value:Bool):Bool
{
@ -48,14 +48,11 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
* Called when the module is initialized.
* It may not be safe to reference other modules here since they may not be loaded yet.
*
* @param startActive Whether to start with the module active.
* If false, the module will be inactive and must be enabled by another script,
* such as a stage or another module.
* NOTE: To make the module start inactive, call `this.active = false` in the constructor.
*/
public function new(moduleId:String, active:Bool = true, priority:Int = 1000):Void
public function new(moduleId:String, priority:Int = 1000):Void
{
this.moduleId = moduleId;
this.active = active;
this.priority = priority;
}
@ -90,16 +87,14 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
public function onSongEnd(event:ScriptEvent) {}
public function onSongReset(event:ScriptEvent) {}
public function onGameOver(event:ScriptEvent) {}
public function onGameRetry(event:ScriptEvent) {}
public function onNoteHit(event:NoteScriptEvent) {}
public function onNoteMiss(event:NoteScriptEvent) {}
public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
public function onStepHit(event:SongTimeScriptEvent) {}
public function onBeatHit(event:SongTimeScriptEvent) {}
@ -110,9 +105,19 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
public function onCountdownEnd(event:CountdownScriptEvent) {}
public function onSongLoaded(eent:SongLoadScriptEvent) {}
public function onSongLoaded(event:SongLoadScriptEvent) {}
public function onStateChangeBegin(event:StateChangeScriptEvent) {}
public function onStateChangeEnd(event:StateChangeScriptEvent) {}
public function onSubstateOpenBegin(event:SubStateScriptEvent) {}
public function onSubstateOpenEnd(event:SubStateScriptEvent) {}
public function onSubstateCloseBegin(event:SubStateScriptEvent) {}
public function onSubstateCloseEnd(event:SubStateScriptEvent) {}
public function onSongRetry(event:ScriptEvent) {}
}

View file

@ -47,6 +47,16 @@ class ModuleHandler
trace("[MODULEHANDLER] Module cache loaded.");
}
public static function buildModuleCallbacks():Void
{
FlxG.signals.postStateSwitch.add(onStateSwitchComplete);
}
static function onStateSwitchComplete():Void
{
callEvent(new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_END, FlxG.state, true));
}
static function addToModuleCache(module:Module):Void
{
moduleCache.set(module.moduleId, module);

View file

@ -16,11 +16,18 @@ typedef AnimationData =
*/
var prefix:String;
/**
* Optionally specify an asset path to use for this specific animation.
* ONLY for use by MultiSparrow characters.
* @default The assetPath of the parent sprite
*/
var assetPath:Null<String>;
/**
* Offset the character's position by this amount when playing this animation.
* @default [0, 0]
*/
var offsets:Null<Array<Float>>;
var offsets:Null<Array<Int>>;
/**
* Whether the animation should loop when it finishes.

View file

@ -37,6 +37,9 @@ class Countdown
PlayState.isInCountdown = true;
Conductor.songPosition = Conductor.crochet * -5;
countdownStep = BEFORE;
// Handle onBeatHit events manually
@:privateAccess
PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
var cancelled:Bool = propagateCountdownEvent(countdownStep);
if (cancelled)
@ -49,9 +52,9 @@ class Countdown
{
countdownStep = decrement(countdownStep);
// Play the dance animations manually.
// Handle onBeatHit events manually
@:privateAccess
PlayState.instance.danceOnBeat();
PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0));
// Countdown graphic.
showCountdownGraphic(countdownStep, isPixelStyle);

View file

@ -1,6 +1,6 @@
package funkin.play;
import funkin.play.character.CharacterBase;
import funkin.play.character.BaseCharacter;
import flixel.addons.effects.FlxTrail;
import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxCamera;
@ -86,6 +86,12 @@ class PlayState extends MusicBeatState implements IHook
*/
public static var isInCountdown:Bool = false;
/**
* Gets set to true when the PlayState needs to reset (player opted to restart or died).
* Gets disabled once resetting happens.
*/
public static var needsReset:Bool = false;
/**
* The current "Blueball Counter" to display in the pause menu.
* Resets when you beat a song or go back to the main menu.
@ -125,6 +131,11 @@ class PlayState extends MusicBeatState implements IHook
*/
public var health:Float = 1;
/**
* The player's current score.
*/
public var songScore:Int = 0;
/**
* An empty FlxObject contained in the scene.
* The current gameplay camera will be centered on this object. Tween its position to move the camera smoothly.
@ -225,7 +236,6 @@ class PlayState extends MusicBeatState implements IHook
public static var storyWeek:Int = 0;
public static var storyPlaylist:Array<String> = [];
public static var storyDifficulty:Int = 1;
public static var needsReset:Bool = false;
public static var seenCutscene:Bool = false;
public static var campaignScore:Int = 0;
@ -242,7 +252,6 @@ class PlayState extends MusicBeatState implements IHook
var dialogue:Array<String>;
var talking:Bool = true;
var songScore:Int = 0;
var doof:DialogueBox;
var grpNoteSplashes:FlxTypedGroup<NoteSplash>;
var comboPopUps:PopUpStuff;
@ -377,6 +386,8 @@ class PlayState extends MusicBeatState implements IHook
iconP1.cameras = [camHUD];
iconP2.cameras = [camHUD];
scoreText.cameras = [camHUD];
leftWatermarkText.cameras = [camHUD];
rightWatermarkText.cameras = [camHUD];
// if (SONG.song == 'South')
// FlxG.camera.alpha = 0.7;
@ -494,26 +505,48 @@ class PlayState extends MusicBeatState implements IHook
if (currentSong.song.toLowerCase() == 'stress')
gfVersion = 'pico-speaker';
var girlfriend:Character = new Character(350, -70, gfVersion);
girlfriend.scrollFactor.set(0.95, 0.95);
if (gfVersion == 'pico-speaker')
if (currentSong.song.toLowerCase() == 'tutorial')
gfVersion = '';
//
// GIRLFRIEND
//
var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(gfVersion);
if (girlfriend != null)
{
girlfriend.x -= 50;
girlfriend.y -= 200;
girlfriend.characterType = CharacterType.GF;
girlfriend.scrollFactor.set(0.95, 0.95);
if (gfVersion == 'pico-speaker')
{
girlfriend.x -= 50;
girlfriend.y -= 200;
}
}
else if (gfVersion != '')
{
trace('WARNING: Could not load girlfriend character with ID ${gfVersion}, skipping...');
}
//
// DAD
//
var dad = new Character(100, 100, currentSong.player2);
var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player2);
cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y);
if (dad != null)
{
dad.characterType = CharacterType.DAD;
cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y);
}
switch (currentSong.player2)
{
case 'gf':
dad.setPosition(girlfriend.x, girlfriend.y);
girlfriend.visible = false;
var gfPoint:FlxPoint = currentStage.getGirlfriendPosition();
dad.setPosition(gfPoint.x, gfPoint.y);
// girlfriend.visible = false;
if (isStoryMode)
{
cameraFollowPoint.x += 600;
@ -553,12 +586,11 @@ class PlayState extends MusicBeatState implements IHook
//
// BOYFRIEND
//
var boyfriend:CharacterBase;
switch (currentSong.player1)
var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player1);
if (boyfriend != null)
{
default:
boyfriend = CharacterDataParser.fetchCharacter(currentSong.player1);
boyfriend.characterType = CharacterType.BF;
boyfriend.characterType = CharacterType.BF;
}
// REPOSITIONING PER STAGE
@ -589,8 +621,8 @@ class PlayState extends MusicBeatState implements IHook
// We're using Eric's stage handler.
// Characters get added to the stage, not the main scene.
currentStage.addCharacter(boyfriend, BF);
currentStage.addCharacterOld(girlfriend, GF);
currentStage.addCharacterOld(dad, DAD);
currentStage.addCharacter(girlfriend, GF);
currentStage.addCharacter(dad, DAD);
// Redo z-indexes.
currentStage.refresh();
@ -612,7 +644,7 @@ class PlayState extends MusicBeatState implements IHook
*
* Call this by pressing F5 on a debug build.
*/
function debug_refreshStages()
override function debug_refreshModules()
{
// Remove the current stage. If the stage gets deleted while it's still in use,
// it'll probably crash the game or something.
@ -624,19 +656,7 @@ class PlayState extends MusicBeatState implements IHook
currentStage = null;
}
ModuleHandler.clearModuleCache();
// Forcibly reload scripts so that scripted stages can be edited.
polymod.hscript.PolymodScriptClass.clearScriptClasses();
polymod.hscript.PolymodScriptClass.registerAllScriptClasses();
// Reload the stages in cache. This might cause a lag spike but who cares this is a debug utility.
StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache();
ModuleHandler.loadModuleCache();
// Reload the level. This should use new data from the assets folder.
LoadingState.loadAndSwitchState(new PlayState());
super.debug_refreshModules();
}
/**
@ -957,6 +977,8 @@ class PlayState extends MusicBeatState implements IHook
if (needsReset)
{
dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY));
resetCamera();
persistentUpdate = true;
@ -967,11 +989,10 @@ class PlayState extends MusicBeatState implements IHook
FlxG.sound.music.pause();
vocals.pause();
var event:ScriptEvent = new ScriptEvent(ScriptEvent.SONG_RESET, false);
FlxG.sound.music.time = 0;
regenNoteData(); // loads the note data from start
health = 1;
songScore = 0;
Countdown.performCountdown(currentStageId.startsWith('school'));
needsReset = false;
@ -1059,9 +1080,6 @@ class PlayState extends MusicBeatState implements IHook
if (FlxG.keys.justPressed.EIGHT)
FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
if (FlxG.keys.justPressed.F5)
debug_refreshStages();
if (FlxG.keys.justPressed.NINE)
iconP1.swapOldIcon();
@ -1158,6 +1176,8 @@ class PlayState extends MusicBeatState implements IHook
deathCounter += 1;
dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER));
openSubState(new GameOverSubstate());
#if discord_rpc
@ -1226,35 +1246,28 @@ class PlayState extends MusicBeatState implements IHook
}
}
if (!daNote.mustPress && daNote.wasGoodHit)
if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate)
{
if (currentSong.song != 'Tutorial')
camZooming = true;
var altAnim:String = "";
var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, true);
dispatchEvent(event);
if (SongLoad.getSong()[Math.floor(curStep / 16)] != null)
// Calling event.cancelEvent() in a module should force the CPU to miss the note.
// This is useful for cool shit, including but not limited to:
// - Making the AI ignore notes which are hazardous.
// - Making the AI miss notes on purpose for aesthetic reasons.
if (event.eventCanceled)
{
if (SongLoad.getSong()[Math.floor(curStep / 16)].altAnim)
altAnim = '-alt';
daNote.tooLate = true;
}
if (daNote.data.altNote)
altAnim = '-alt';
if (!daNote.isSustainNote)
else
{
currentStage.getDad().playAnim('sing' + daNote.dirNameUpper + altAnim, true);
// Volume of DAD.
if (currentSong.needsVoices)
vocals.volume = 1;
}
currentStage.getDad().holdTimer = 0;
if (currentSong.needsVoices)
vocals.volume = 1;
daNote.kill();
activeNotes.remove(daNote, true);
daNote.destroy();
}
// WIP interpolation shit? Need to fix the pause issue
@ -1279,18 +1292,8 @@ class PlayState extends MusicBeatState implements IHook
daNote.destroy();
}
}
else if (daNote.tooLate || daNote.wasGoodHit)
if (daNote.wasGoodHit)
{
// TODO: Why the hell is the noteMiss logic in two different places?
if (daNote.tooLate)
{
var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, daNote, true);
dispatchEvent(event);
health -= 0.0775;
vocals.volume = 0;
killCombo();
}
daNote.active = false;
daNote.visible = false;
@ -1298,6 +1301,11 @@ class PlayState extends MusicBeatState implements IHook
activeNotes.remove(daNote, true);
daNote.destroy();
}
if (daNote.tooLate)
{
noteMiss(daNote);
}
});
}
@ -1329,8 +1337,10 @@ class PlayState extends MusicBeatState implements IHook
function killCombo():Void
{
if (combo > 5 && currentStage.getGirlfriend().animOffsets.exists('sad'))
currentStage.getGirlfriend().playAnim('sad');
// Girlfriend gets sad if you combo break after hitting 5 notes.
if (currentStage.getGirlfriend() != null)
if (combo > 5 && currentStage.getGirlfriend().hasAnimation('sad'))
currentStage.getGirlfriend().playAnimation('sad');
if (combo != 0)
{
@ -1494,15 +1504,6 @@ class PlayState extends MusicBeatState implements IHook
health += healthMulti;
// TODO: Redo note hit logic to make sure this always gets called
var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, true);
dispatchEvent(event);
if (event.eventCanceled)
{
// TODO: Do a thing!
}
if (isSick)
{
var noteSplash:NoteSplash = grpNoteSplashes.recycle(NoteSplash);
@ -1531,7 +1532,7 @@ class PlayState extends MusicBeatState implements IHook
cameraFollowPoint.setPosition(currentStage.getDad().getMidpoint().x + 150, currentStage.getDad().getMidpoint().y - 100);
// camFollow.setPosition(lucky.getMidpoint().x - 120, lucky.getMidpoint().y + 210);
switch (currentStage.getDad().curCharacter)
switch (currentStage.getDad().characterId)
{
case 'mom':
cameraFollowPoint.y = currentStage.getDad().getMidpoint().y;
@ -1540,7 +1541,7 @@ class PlayState extends MusicBeatState implements IHook
cameraFollowPoint.x = currentStage.getDad().getMidpoint().x - 100;
}
if (currentStage.getDad().curCharacter == 'mom')
if (currentStage.getDad().characterId == 'mom')
vocals.volume = 1;
if (currentSong.song.toLowerCase() == 'tutorial')
@ -1573,9 +1574,11 @@ class PlayState extends MusicBeatState implements IHook
trace(instance.currentStageId);
};
@:hookable
public function keyShit(test:Bool):Void
{
if (PlayState.instance == null)
return;
// control arrays, order L D R U
var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
var pressArray:Array<Bool> = [
@ -1659,7 +1662,7 @@ class PlayState extends MusicBeatState implements IHook
for (shit in 0...pressArray.length)
{ // if a direction is hit that shouldn't be
if (pressArray[shit] && !directionList.contains(shit))
PlayState.instance.noteMiss(shit);
PlayState.instance.ghostNoteMiss(shit);
}
for (coolNote in possibleNotes)
{
@ -1669,23 +1672,15 @@ class PlayState extends MusicBeatState implements IHook
}
else
{
// HNGGG I really want to add an option for ghost tapping
for (shit in 0...pressArray.length)
if (pressArray[shit])
PlayState.instance.noteMiss(shit);
PlayState.instance.ghostNoteMiss(shit, false);
}
}
if (PlayState.instance.currentStage == null)
return;
if (PlayState.instance.currentStage.getBoyfriend().holdTimer > Conductor.stepCrochet * 4 * 0.001 && !holdArray.contains(true))
{
if (PlayState.instance.currentStage.getBoyfriend().animation != null
&& PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.startsWith('sing')
&& !PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.endsWith('miss'))
{
PlayState.instance.currentStage.getBoyfriend().playAnimation('idle');
}
}
for (keyId => isPressed in pressArray)
{
@ -1702,33 +1697,78 @@ class PlayState extends MusicBeatState implements IHook
}
}
function noteMiss(direction:NoteDir = 1):Void
/**
* 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:NoteType = 1, hasPossibleNotes:Bool = true):Void
{
// whole function used to be encased in if (!boyfriend.stunned)
health -= 0.07;
killCombo();
var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in.
hasPossibleNotes, // Whether there was a note you could have hit.
- 0.035 * 2, // 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;
if (!isPracticeMode)
songScore += event.scoreChange;
if (event.playSound)
{
vocals.volume = 0;
FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
}
}
function noteMiss(note:Note):Void
{
var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, true);
dispatchEvent(event);
// Calling event.cancelEvent() skips all the other logic! Neat!
if (event.eventCanceled)
return;
health -= 0.0775;
if (!isPracticeMode)
songScore -= 10;
vocals.volume = 0;
FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2));
killCombo();
currentStage.getBoyfriend().playAnimation('sing' + direction.nameUpper + 'miss', true);
note.active = false;
note.visible = false;
note.kill();
activeNotes.remove(note, true);
note.destroy();
}
function goodNoteHit(note:Note):Void
{
if (!note.wasGoodHit)
{
var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, true);
dispatchEvent(event);
// Calling event.cancelEvent() skips all the other logic! Neat!
if (event.eventCanceled)
return;
if (!note.isSustainNote)
{
combo += 1;
popUpScore(note.data.strumTime, note);
}
currentStage.getBoyfriend().playAnimation('sing' + note.dirNameUpper, true);
playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true);
note.wasGoodHit = true;
@ -1813,22 +1853,6 @@ class PlayState extends MusicBeatState implements IHook
if (currentStage == null)
return;
if (curBeat % gfSpeed == 0)
currentStage.getGirlfriend().dance();
if (curBeat % 2 == 0)
{
if (currentStage.getBoyfriend().animation != null && !currentStage.getBoyfriend().animation.curAnim.name.startsWith("sing"))
currentStage.getBoyfriend().playAnimation('idle');
if (currentStage.getDad().animation != null && !currentStage.getDad().animation.curAnim.name.startsWith("sing"))
currentStage.getDad().dance();
}
else if (currentStage.getDad().curCharacter == 'spooky')
{
if (!currentStage.getDad().animation.curAnim.name.startsWith("sing"))
currentStage.getDad().dance();
}
if (curBeat % 8 == 7 && currentSong.song == 'Bopeebo')
{
currentStage.getBoyfriend().playAnimation('hey', true);
@ -1836,12 +1860,12 @@ class PlayState extends MusicBeatState implements IHook
if (curBeat % 16 == 15
&& currentSong.song == 'Tutorial'
&& currentStage.getDad().curCharacter == 'gf'
&& currentStage.getDad().characterId == 'gf'
&& curBeat > 16
&& curBeat < 48)
{
currentStage.getBoyfriend().playAnimation('hey', true);
currentStage.getDad().playAnim('cheer', true);
currentStage.getDad().playAnimation('cheer', true);
}
}
@ -1976,12 +2000,20 @@ class PlayState extends MusicBeatState implements IHook
override function dispatchEvent(event:ScriptEvent):Void
{
// ORDER: Module, Stage, Character, Song, 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 stage script.
ScriptEventDispatcher.callEvent(currentStage, event);
// TODO: Dispatch event to song script
// TODO: Dispatch events to character scripts
// Dispatch event to character script(s).
if (currentStage != null)
currentStage.dispatchToCharacters(event);
super.dispatchEvent(event);
// TODO: Dispatch event to song script
}
/**
@ -2045,11 +2077,17 @@ class PlayState extends MusicBeatState implements IHook
/**
* This function is called whenever Flixel switches switching to a new FlxState.
* @return Whether to actually switch to the new state.
*/
override function switchTo(nextState:FlxState):Bool
{
performCleanup();
var result = super.switchTo(nextState);
return super.switchTo(nextState);
if (result)
{
performCleanup();
}
return result;
}
}

View file

@ -0,0 +1,281 @@
package funkin.play.character;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.Note.NoteDir;
import funkin.modding.events.ScriptEvent.NoteScriptEvent;
import funkin.play.stage.Bopper;
using StringTools;
/**
* A Character is a stage prop which bops to the music as well as controlled by the strumlines.
*
* Remember: The character's origin is at its FEET. (horizontal center, vertical bottom)
*/
class BaseCharacter extends Bopper
{
// Metadata about a character.
public var characterId(default, null):String;
public var characterName(default, null):String;
/**
* Whether the player is an active character (Boyfriend) or not.
*/
public var characterType:CharacterType = OTHER;
final _data:CharacterData;
/**
* Tracks how long, in seconds, the character has been playing the current `sing` animation.
* This is used to ensure that characters play the `sing` animations for at least one beat,
* preventing them from reverting to the `idle` animation between notes.
*/
public var holdTimer:Float = 0;
final singTimeCrochet:Float;
public var isDead:Bool = false;
public function new(id:String)
{
super();
this.characterId = id;
_data = CharacterDataParser.fetchCharacterData(this.characterId);
if (_data == null)
{
throw 'Could not find character data for characterId: $characterId';
}
else
{
this.characterName = _data.name;
this.singTimeCrochet = _data.singTime;
}
}
public override function onUpdate(event:UpdateScriptEvent):Void
{
super.onUpdate(event);
// Reset hold timer for each note pressed.
if (justPressedNote())
{
holdTimer = 0;
}
if (isDead)
{
playDeathAnimation();
}
// Handle character note hold time.
if (getCurrentAnimation().startsWith("sing"))
{
holdTimer += event.elapsed;
var singTimeMs:Float = singTimeCrochet * (Conductor.crochet * 0.001); // x beats, to ms.
// Without this check here, the player character would only play the `sing` animation
// for one beat, as opposed to holding it as long as the player is holding the button.
var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true;
FlxG.watch.addQuick('singTimeMs-${characterId}', singTimeMs);
if (holdTimer > singTimeMs && shouldStopSinging)
{
trace('holdTimer reached ${holdTimer}sec (> ${singTimeMs}), stopping sing animation');
holdTimer = 0;
dance(true);
}
}
else
{
holdTimer = 0;
// super.onBeatHit handles the regular `dance()` calls.
}
FlxG.watch.addQuick('holdTimer-${characterId}', holdTimer);
}
/**
* Since no `onBeatHit` or `dance` calls happen in GameOverSubstate,
* this regularly gets called instead.
*/
public function playDeathAnimation(force:Bool = false):Void
{
if (force || (getCurrentAnimation().startsWith("firstDeath") && isAnimationFinished()))
{
playAnimation("deathLoop");
}
}
override function dance(force:Bool = false)
{
if (!force)
{
if (getCurrentAnimation().startsWith("sing"))
{
return;
}
if (["hey", "cheer"].contains(getCurrentAnimation()) && !isAnimationFinished())
{
return;
}
}
// Prevent dancing while another animation is playing.
if (!force && getCurrentAnimation().startsWith("sing"))
{
return;
}
// Otherwise, fallback to the super dance() method, which handles playing the idle animation.
super.dance();
}
/**
* Returns true if the player just pressed a note.
* Used when determing whether a the player character should revert to the `idle` animation.
* On non-player characters, this should be ignored.
*/
function justPressedNote(player:Int = 1):Bool
{
// Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held.
switch (player)
{
case 1:
return [
PlayerSettings.player1.controls.NOTE_LEFT_P,
PlayerSettings.player1.controls.NOTE_DOWN_P,
PlayerSettings.player1.controls.NOTE_UP_P,
PlayerSettings.player1.controls.NOTE_RIGHT_P,
].contains(true);
case 2:
return [
PlayerSettings.player2.controls.NOTE_LEFT_P,
PlayerSettings.player2.controls.NOTE_DOWN_P,
PlayerSettings.player2.controls.NOTE_UP_P,
PlayerSettings.player2.controls.NOTE_RIGHT_P,
].contains(true);
}
return false;
}
/**
* Returns true if the player is holding a note.
* Used when determing whether a the player character should revert to the `idle` animation.
* On non-player characters, this should be ignored.
*/
function isHoldingNote(player:Int = 1):Bool
{
// Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held.
switch (player)
{
case 1:
return [
PlayerSettings.player1.controls.NOTE_LEFT,
PlayerSettings.player1.controls.NOTE_DOWN,
PlayerSettings.player1.controls.NOTE_UP,
PlayerSettings.player1.controls.NOTE_RIGHT,
].contains(true);
case 2:
return [
PlayerSettings.player2.controls.NOTE_LEFT,
PlayerSettings.player2.controls.NOTE_DOWN,
PlayerSettings.player2.controls.NOTE_UP,
PlayerSettings.player2.controls.NOTE_RIGHT,
].contains(true);
}
return false;
}
/**
* Every time a note is hit, check if the note is from the same strumline.
* If it is, then play the sing animation.
*/
public override function onNoteHit(event:NoteScriptEvent)
{
super.onNoteHit(event);
trace('HIT NOTE: ${event.note.data.dir} : ${event.note.isSustainNote}');
holdTimer = 0;
if (event.note.mustPress && characterType == BF)
{
// If the note is from the same strumline, play the sing animation.
this.playSingAnimation(event.note.data.dir, false, event.note.data.altNote ? "alt" : null);
}
else if (!event.note.mustPress && characterType == DAD)
{
// If the note is from the same strumline, play the sing animation.
this.playSingAnimation(event.note.data.dir, false, event.note.data.altNote ? "alt" : null);
}
}
/**
* Every time a note is missed, check if the note is from the same strumline.
* If it is, then play the sing animation.
*/
public override function onNoteMiss(event:NoteScriptEvent)
{
super.onNoteMiss(event);
if (event.note.mustPress && characterType == BF)
{
// If the note is from the same strumline, play the sing animation.
this.playSingAnimation(event.note.data.dir, true, event.note.data.altNote ? "alt" : null);
}
else if (!event.note.mustPress && characterType == DAD)
{
// If the note is from the same strumline, play the sing animation.
this.playSingAnimation(event.note.data.dir, true, event.note.data.altNote ? "alt" : null);
}
}
/**
* Every time a wrong key is pressed, play the miss animation if we are Boyfriend.
*/
public override function onNoteGhostMiss(event:GhostMissNoteScriptEvent)
{
super.onNoteGhostMiss(event);
if (event.eventCanceled || !event.playAnim)
{
// Skipping...
return;
}
if (characterType == BF)
{
trace('Playing ghost miss animation...');
// If the note is from the same strumline, play the sing animation.
this.playSingAnimation(event.dir, true, null);
}
}
public override function onDestroy(event:ScriptEvent):Void
{
this.characterType = OTHER;
}
/**
* Play the appropriate singing animation, for the given note direction.
* @param dir The direction of the note.
* @param miss If true, play the miss animation instead of the sing animation.
* @param suffix A suffix to append to the animation name, like `alt`.
*/
public function playSingAnimation(dir:NoteDir, miss:Bool = false, suffix:String = ""):Void
{
var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != "" ? '-${suffix}' : ''}';
// restart even if already playing, because the character might sing the same note twice.
playAnimation(anim, true);
}
}
enum CharacterType
{
BF;
DAD;
GF;
OTHER;
}

View file

@ -1,140 +0,0 @@
package funkin.play.character;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.Note.NoteDir;
import funkin.modding.events.ScriptEvent.NoteScriptEvent;
import funkin.play.stage.Bopper;
/**
* A Character is a stage prop which bops to the music as well as controlled by the strumlines.
*
* Remember: The character's origin is at its FEET. (horizontal center, vertical bottom)
*/
class CharacterBase extends Bopper
{
public var characterId(default, null):String;
public var characterName(default, null):String;
/**
* Whether the player is an active character (Boyfriend) or not.
*/
public var characterType:CharacterType = OTHER;
public var attachedStrumlines(default, null):Array<Int>;
final _data:CharacterData;
/**
* Tracks how long, in seconds, the character has been playing the current `sing` animation.
* This is used to ensure that characters play the `sing` animations for at least one beat,
* preventing them from reverting to the `idle` animation between notes.
*/
public var holdTimer:Float = 0;
final singTimeCrochet:Float;
public function new(id:String)
{
super();
this.characterId = id;
this.attachedStrumlines = [];
_data = CharacterDataParser.parseCharacterData(this.characterId);
if (_data == null)
{
throw 'Could not find character data for characterId: $characterId';
}
else
{
this.characterName = _data.name;
this.singTimeCrochet = _data.singTime;
}
}
public override function onUpdate(event:UpdateScriptEvent):Void
{
super.onUpdate(event);
// Handle character note hold time.
holdTimer += event.elapsed;
var singTimeMs:Float = singTimeCrochet * Conductor.crochet;
// Without this check here, the player character would only play the `sing` animation
// for one beat, as opposed to holding it as long as the player is holding the button.
var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true;
if (holdTimer > singTimeMs && shouldStopSinging)
{
holdTimer = 0;
dance();
}
}
/**
* Returns true if the player is holding a note.
* Used when determing whether a the player character should revert to the `idle` animation.
* On non-player characters, this should be ignored.
*/
function isHoldingNote(player:Int = 1):Bool
{
// Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held.
switch (player)
{
case 1:
return [
PlayerSettings.player1.controls.NOTE_LEFT,
PlayerSettings.player1.controls.NOTE_DOWN,
PlayerSettings.player1.controls.NOTE_UP,
PlayerSettings.player1.controls.NOTE_RIGHT,
].contains(true);
case 2:
return [
PlayerSettings.player2.controls.NOTE_LEFT,
PlayerSettings.player2.controls.NOTE_DOWN,
PlayerSettings.player2.controls.NOTE_UP,
PlayerSettings.player2.controls.NOTE_RIGHT,
].contains(true);
}
return false;
}
/**
* Every time a note is hit, check if the note is from the same strumline.
* If it is, then play the sing animation.
*/
public override function onNoteHit(event:NoteScriptEvent)
{
super.onNoteHit(event);
// If event.note is from the same strumline as this character, then sing.
// if (this.attachedStrumlines.indexOf(event.note.strumline) != -1)
// {
// this.playSingAnimation(event.note.dir, false, note.alt);
// }
}
public override function onDestroy(event:ScriptEvent):Void
{
this.characterType = OTHER;
}
/**
* Play the appropriate singing animation, for the given note direction.
* @param dir The direction of the note.
* @param miss If true, play the miss animation instead of the sing animation.
* @param suffix A suffix to append to the animation name, like `alt`.
*/
function playSingAnimation(dir:NoteDir, miss:Bool = false, suffix:String = ""):Void
{
var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != "" ? '-${suffix}' : ''}';
playAnimation(anim, true);
}
}
enum CharacterType
{
BF;
DAD;
GF;
OTHER;
}

View file

@ -1,14 +1,20 @@
package funkin.play.character;
import openfl.Assets;
import haxe.Json;
import funkin.play.character.render.PackerCharacter;
import funkin.play.character.render.SparrowCharacter;
import funkin.util.assets.DataAssets;
import funkin.play.character.CharacterBase;
import funkin.play.character.ScriptedCharacter.ScriptedSparrowCharacter;
import funkin.play.character.ScriptedCharacter.ScriptedPackerCharacter;
import flixel.util.typeLimit.OneOfTwo;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.character.BaseCharacter;
import funkin.play.character.MultiSparrowCharacter;
import funkin.play.character.PackerCharacter;
import funkin.play.character.SparrowCharacter;
import funkin.play.character.ScriptedCharacter.ScriptedBaseCharacter;
import funkin.play.character.ScriptedCharacter.ScriptedMultiSparrowCharacter;
import funkin.play.character.ScriptedCharacter.ScriptedPackerCharacter;
import funkin.play.character.ScriptedCharacter.ScriptedSparrowCharacter;
import funkin.util.assets.DataAssets;
import funkin.util.VersionUtil;
import haxe.Json;
import openfl.utils.Assets;
using StringTools;
@ -19,9 +25,15 @@ class CharacterDataParser
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateStageData()` function.
*/
public static final CHARACTER_DATA_VERSION:String = "1.0";
public static final CHARACTER_DATA_VERSION:String = "1.0.0";
static final characterCache:Map<String, CharacterBase> = new Map<String, CharacterBase>();
/**
* The current version rule check for the stage data format.
*/
public static final CHARACTER_DATA_VERSION_RULE:String = "1.0.x";
static final characterCache:Map<String, CharacterData> = new Map<String, CharacterData>();
static final characterScriptedClass:Map<String, String> = new Map<String, String>();
static final DEFAULT_CHAR_ID:String = 'UNKNOWN';
@ -37,73 +49,23 @@ class CharacterDataParser
trace("[CHARDATA] Loading character cache...");
//
// SCRIPTED CHARACTERS
//
// Generic (Sparrow) characters
var scriptedCharClassNames:Array<String> = ScriptedCharacter.listScriptClasses();
trace(' Instantiating ${scriptedCharClassNames.length} scripted characters...');
for (charCls in scriptedCharClassNames)
{
_storeChar(ScriptedCharacter.init(charCls, DEFAULT_CHAR_ID), charCls);
}
// Sparrow characters
scriptedCharClassNames = ScriptedSparrowCharacter.listScriptClasses();
if (scriptedCharClassNames.length > 0)
{
trace(' Instantiating ${scriptedCharClassNames.length} scripted characters (SPARROW)...');
for (charCls in scriptedCharClassNames)
{
_storeChar(ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID), charCls);
}
}
// // Packer characters
// scriptedCharClassNames = ScriptedPackerCharacter.listScriptClasses();
// if (scriptedCharClassNames.length > 0)
// {
// trace(' Instantiating ${scriptedCharClassNames.length} scripted characters (PACKER)...');
// for (charCls in scriptedCharClassNames)
// {
// _storeChar(ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID), charCls);
// }
// }
// TODO: Add more character types.
//
// UNSCRIPTED STAGES
// UNSCRIPTED CHARACTERS
//
var charIdList:Array<String> = DataAssets.listDataFilesInPath('characters/');
var unscriptedCharIds:Array<String> = charIdList.filter(function(charId:String):Bool
{
return !characterCache.exists(charId);
});
trace(' Instantiating ${unscriptedCharIds.length} non-scripted characters...');
trace(' Fetching data for ${unscriptedCharIds.length} characters...');
for (charId in unscriptedCharIds)
{
var char:CharacterBase = null;
try
{
var charData:CharacterData = parseCharacterData(charId);
if (charData != null)
{
switch (charData.renderType)
{
case CharacterRenderType.PACKER:
char = new PackerCharacter(charId);
case CharacterRenderType.SPARROW:
// default
char = new SparrowCharacter(charId);
default:
trace(' Failed to instantiate character: ${charId} (Bad render type ${charData.renderType})');
}
}
if (char != null)
{
trace(' Loaded character data: ${char.characterName}');
characterCache.set(charId, char);
trace(' Loaded character data: ${charId}');
characterCache.set(charId, charData);
}
}
catch (e)
@ -113,39 +75,140 @@ class CharacterDataParser
}
}
//
// SCRIPTED CHARACTERS
//
// Fuck I wish scripted classes supported static functions.
var scriptedCharClassNames1:Array<String> = ScriptedSparrowCharacter.listScriptClasses();
if (scriptedCharClassNames1.length > 0)
{
trace(' Instantiating ${scriptedCharClassNames1.length} (Sparrow) scripted characters...');
for (charCls in scriptedCharClassNames1)
{
var character = ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID);
characterScriptedClass.set(character.characterId, charCls);
}
}
var scriptedCharClassNames2:Array<String> = ScriptedPackerCharacter.listScriptClasses();
if (scriptedCharClassNames2.length > 0)
{
trace(' Instantiating ${scriptedCharClassNames2.length} (Packer) scripted characters...');
for (charCls in scriptedCharClassNames2)
{
var character = ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID);
characterScriptedClass.set(character.characterId, charCls);
}
}
var scriptedCharClassNames3:Array<String> = ScriptedMultiSparrowCharacter.listScriptClasses();
trace(' Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...');
for (charCls in scriptedCharClassNames3)
{
var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID);
if (character == null)
{
trace(' Failed to instantiate scripted character: ${charCls}');
continue;
}
characterScriptedClass.set(character.characterId, charCls);
}
// NOTE: Only instantiate the ones not populated above.
// ScriptedBaseCharacter.listScriptClasses() will pick up scripts extending the other classes.
var scriptedCharClassNames:Array<String> = ScriptedBaseCharacter.listScriptClasses();
scriptedCharClassNames.filter(function(charCls:String):Bool
{
return !scriptedCharClassNames1.contains(charCls)
&& !scriptedCharClassNames2.contains(charCls)
&& !scriptedCharClassNames3.contains(charCls);
});
trace(' Instantiating ${scriptedCharClassNames.length} (Base) scripted characters...');
for (charCls in scriptedCharClassNames)
{
var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID);
if (character == null)
{
trace(' Failed to instantiate scripted character: ${charCls}');
continue;
}
characterScriptedClass.set(character.characterId, charCls);
}
trace(' Successfully loaded ${Lambda.count(characterCache)} stages.');
}
static function _storeChar(char:CharacterBase, charCls:String):Void
public static function fetchCharacter(charId:String):Null<BaseCharacter>
{
if (char != null)
if (charId == null || charId == '')
{
trace(' Loaded scripted character: ${char.characterName}');
// Disable the rendering logic for stage until it's loaded.
// Note that kill() =/= destroy()
char.kill();
// Gracefully handle songs that don't use this character.
return null;
}
// Then store it.
characterCache.set(char.characterId, char);
if (characterCache.exists(charId))
{
var charData:CharacterData = characterCache.get(charId);
var charScriptClass:String = characterScriptedClass.get(charId);
var char:BaseCharacter;
if (charScriptClass != null)
{
switch (charData.renderType)
{
case CharacterRenderType.MULTISPARROW:
char = ScriptedMultiSparrowCharacter.init(charScriptClass, charId);
case CharacterRenderType.SPARROW:
char = ScriptedSparrowCharacter.init(charScriptClass, charId);
case CharacterRenderType.PACKER:
char = ScriptedPackerCharacter.init(charScriptClass, charId);
default:
// We're going to assume that the script class does the rendering.
char = ScriptedBaseCharacter.init(charScriptClass, charId);
}
}
else
{
switch (charData.renderType)
{
case CharacterRenderType.MULTISPARROW:
char = new MultiSparrowCharacter(charId);
case CharacterRenderType.SPARROW:
char = new SparrowCharacter(charId);
case CharacterRenderType.PACKER:
char = new PackerCharacter(charId);
default:
trace('[WARN] Creating character with undefined renderType ${charData.renderType}');
char = new BaseCharacter(charId);
}
}
trace('[CHARDATA] Successfully instantiated character: ${charId}');
// Call onCreate only in the fetchCharacter() function, not at application initialization.
ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE));
return char;
}
else
{
trace(' Failed to instantiate scripted character class: ${charCls}');
trace('[CHARDATA] Failed to build character, not found in cache: ${charId}');
return null;
}
}
public static function fetchCharacter(charId:String):Null<CharacterBase>
public static function fetchCharacterData(charId:String):Null<CharacterData>
{
if (characterCache.exists(charId))
{
trace('[CHARDATA] Successfully fetch stage: ${charId}');
var character:CharacterBase = characterCache.get(charId);
character.revive();
return character;
return characterCache.get(charId);
}
else
{
trace('[CHARDATA] Failed to fetch character, not found in cache: ${charId}');
return null;
}
}
@ -154,12 +217,12 @@ class CharacterDataParser
{
if (characterCache != null)
{
for (char in characterCache)
{
char.destroy();
}
characterCache.clear();
}
if (characterScriptedClass != null)
{
characterScriptedClass.clear();
}
}
/**
@ -180,9 +243,9 @@ class CharacterDataParser
static function loadCharacterFile(charPath:String):String
{
var charFilePath:String = Paths.json('characters/${charPath}');
var rawJson = Assets.getText(charFilePath).trim();
var rawJson = StringTools.trim(Assets.getText(charFilePath));
while (!rawJson.endsWith("}"))
while (!StringTools.endsWith(rawJson, "}"))
{
rawJson = rawJson.substr(0, rawJson.length - 1);
}
@ -208,18 +271,26 @@ class CharacterDataParser
}
}
static final DEFAULT_NAME:String = "Untitled Character";
static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW;
static final DEFAULT_STARTINGANIM:String = "idle";
static final DEFAULT_SCROLL:Array<Float> = [0, 0];
static final DEFAULT_ISPIXEL:Bool = false;
/**
* The default time the character should sing for, in beats.
* Values that are too low will cause the character to stop singing between notes.
* Originally, this value was set to 1, but it was changed to 2 because that became
* too low after some other code changes.
*/
static final DEFAULT_SINGTIME:Float = 2.0;
static final DEFAULT_DANCEEVERY:Int = 1;
static final DEFAULT_FRAMERATE:Int = 24;
static final DEFAULT_FLIPX:Bool = false;
static final DEFAULT_SCALE:Float = 1;
static final DEFAULT_FLIPY:Bool = false;
static final DEFAULT_FRAMERATE:Int = 24;
static final DEFAULT_ISPIXEL:Bool = false;
static final DEFAULT_LOOP:Bool = false;
static final DEFAULT_FRAMEINDICES:Array<Int> = [];
static final DEFAULT_NAME:String = "Untitled Character";
static final DEFAULT_OFFSETS:Array<Int> = [0, 0];
static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW;
static final DEFAULT_SCALE:Float = 1;
static final DEFAULT_SCROLL:Array<Float> = [0, 0];
static final DEFAULT_STARTINGANIM:String = "idle";
/**
* Set unspecified parameters to their defaults.
@ -238,13 +309,13 @@ class CharacterDataParser
if (input.version == null)
{
trace('[CHARDATA] ERROR: Could not load character data for "$id": missing version');
return null;
trace('[CHARDATA] WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}');
input.version = CHARACTER_DATA_VERSION;
}
if (input.version == CHARACTER_DATA_VERSION)
if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE))
{
trace('[CHARDATA] ERROR: Could not load character data for "$id": bad/outdated version (got ${input.version}, expected ${CHARACTER_DATA_VERSION})');
trace('[CHARDATA] ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})');
return null;
}
@ -285,6 +356,11 @@ class CharacterDataParser
input.danceEvery = DEFAULT_DANCEEVERY;
}
if (input.singTime == null)
{
input.singTime = DEFAULT_SINGTIME;
}
if (input.animations == null || input.animations.length == 0)
{
trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animations');
@ -309,9 +385,9 @@ class CharacterDataParser
inputAnimation.frameRate = DEFAULT_FRAMERATE;
}
if (inputAnimation.frameIndices == null)
if (inputAnimation.offsets == null)
{
inputAnimation.frameIndices = DEFAULT_FRAMEINDICES;
inputAnimation.offsets = DEFAULT_OFFSETS;
}
if (inputAnimation.looped == null)
@ -339,15 +415,20 @@ enum abstract CharacterRenderType(String) from String to String
{
var SPARROW = 'sparrow';
var PACKER = 'packer';
// TODO: Aesprite?
var MULTISPARROW = 'multisparrow';
// TODO: FlxSpine?
// https://api.haxeflixel.com/flixel/addons/editors/spine/FlxSpine.html
// TODO: Aseprite?
// https://lib.haxe.org/p/openfl-aseprite/
// TODO: Animate?
// TODO: Experimental...
// https://lib.haxe.org/p/flxanimate
// TODO: REDACTED
}
typedef CharacterData =
{
/**
* The sematic version of the chart data format.
* The sematic version number of the character data JSON format.
*/
var version:String;

View file

@ -0,0 +1,217 @@
package funkin.play.character;
import funkin.modding.events.ScriptEvent;
import funkin.util.assets.FlxAnimationUtil;
import flixel.graphics.frames.FlxFramesCollection;
/**
* For some characters which use Sparrow atlases, the spritesheets need to be split
* into multiple files. This character renderer handles by showing the appropriate sprite.
*
* Examples in base game include BF Holding GF (most of the sprites are in one file
* but the death animation is in a separate file).
* Only example I can think of in mods is Tricky (which has a separate file for each animation).
*
* BaseCharacter has game logic, SparrowCharacter has only rendering logic.
* KEEP THEM SEPARATE!
*/
class MultiSparrowCharacter extends BaseCharacter
{
/**
* The actual group which holds all spritesheets this character uses.
*/
private var members:Map<String, FlxFramesCollection> = new Map<String, FlxFramesCollection>();
/**
* A map between animation names and what frame collection the animation should use.
*/
private var animAssetPath:Map<String, String> = new Map<String, String>();
/**
* The current frame collection being used.
*/
private var activeMember:String;
public function new(id:String)
{
super(id);
}
override function onCreate(event:ScriptEvent):Void
{
trace('Creating MULTI SPARROW CHARACTER: ' + this.characterId);
buildSprite();
playAnimation(_data.startingAnimation);
}
function buildSprite()
{
buildSpritesheets();
buildAnimations();
if (_data.isPixel)
{
this.antialiasing = false;
}
else
{
this.antialiasing = true;
}
if (_data.scale != null)
{
this.setGraphicSize(Std.int(this.width * this.scale.x));
this.updateHitbox();
}
}
function buildSpritesheets()
{
// Build the list of asset paths to use.
// Ignore nulls and duplicates.
var assetList = [_data.assetPath];
for (anim in _data.animations)
{
if (anim.assetPath != null && !assetList.contains(anim.assetPath))
{
assetList.push(anim.assetPath);
}
animAssetPath.set(anim.name, anim.assetPath);
}
// Load the Sparrow atlas for each path and store them in the members map.
for (asset in assetList)
{
var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared');
// If we don't do this, the unused textures will be removed as soon as they're loaded.
texture.parent.destroyOnNoUse = false;
if (texture == null)
{
trace('Multi-Sparrow atlas could not load texture: ${asset}');
}
else
{
trace('Adding multi-sparrow atlas: ${asset}');
members.set(asset, texture);
}
}
// Use the default frame collection to start.
loadFramesByAssetPath(_data.assetPath);
}
/**
* Replace this sprite's animation frames with the ones at this asset path.
*/
function loadFramesByAssetPath(assetPath:String):Void
{
if (_data.assetPath == null)
{
trace('[ERROR] Multi-Sparrow character has no default asset path!');
return;
}
if (assetPath == null)
{
trace('Asset path is null, falling back to default. This is normal!');
loadFramesByAssetPath(_data.assetPath);
return;
}
if (this.activeMember == assetPath)
{
trace('Already using this asset path: ${assetPath}');
return;
}
if (members.exists(assetPath))
{
trace('Loading frames from asset path: ${assetPath}');
this.frames = members.get(assetPath);
this.activeMember = assetPath;
}
else
{
trace('Multi-Sparrow could not find asset path: ${assetPath}');
}
}
/**
* Replace this sprite's animation frames with the ones needed to play this animation.
*/
function loadFramesByAnimName(animName)
{
if (animAssetPath.exists(animName))
{
loadFramesByAssetPath(animAssetPath.get(animName));
}
else
{
trace('Multi-Sparrow could not find animation: ${animName}');
}
}
function buildAnimations()
{
trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}');
// We need to swap to the proper frame collection before adding the animations, I think?
for (anim in _data.animations)
{
trace('Using frames: ${anim.name}');
loadFramesByAnimName(anim.name);
trace('Adding animation');
FlxAnimationUtil.addSparrowAnimation(this, anim);
if (anim.offsets == null)
{
setAnimationOffsets(anim.name, 0, 0);
}
else
{
setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
}
}
var animNames = this.animation.getNameList();
trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
}
public override function playAnimation(name:String, restart:Bool = false):Void
{
loadFramesByAnimName(name);
super.playAnimation(name, restart);
}
override function set_frames(value:FlxFramesCollection):FlxFramesCollection
{
// DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK
// WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM
// if (animation != null)
// {
// animation.destroyAnimations();
// }
if (value != null)
{
graphic = value.parent;
this.frames = value;
this.frame = value.getByIndex(0);
this.numFrames = value.numFrames;
resetHelpers();
this.bakedRotationAngle = 0;
this.animation.frameIndex = 0;
graphicLoaded();
}
else
{
this.frames = null;
this.frame = null;
this.graphic = null;
}
return this.frames;
}
}

View file

@ -1,15 +1,16 @@
package funkin.play.character.render;
package funkin.play.character;
import funkin.play.character.CharacterBase.CharacterType;
import funkin.play.character.BaseCharacter.CharacterType;
/**
* A PackerCharacter is a Character which is rendered by
* displaying an animation derived from a Packer spritesheet file.
*/
class PackerCharacter extends CharacterBase
class PackerCharacter extends BaseCharacter
{
public function new(id:String)
{
super(id);
}
// TODO: Put code here, dumbass!
}

View file

@ -1,14 +1,23 @@
package funkin.play.character;
import funkin.play.character.render.PackerCharacter;
import funkin.play.character.render.SparrowCharacter;
import funkin.play.character.PackerCharacter;
import funkin.play.character.SparrowCharacter;
import funkin.play.character.MultiSparrowCharacter;
import funkin.modding.IHook;
/**
* Note: Making a scripted class extending BaseCharacter is not recommended.
* Do so ONLY if are handling all the character rendering yourself,
* and can't use one of the built-in render modes.
*/
@:hscriptClass
class ScriptedCharacter extends SparrowCharacter implements IHook {}
class ScriptedBaseCharacter extends BaseCharacter implements IHook {}
@:hscriptClass
class ScriptedSparrowCharacter extends SparrowCharacter implements IHook {}
@:hscriptClass
class ScriptedMultiSparrowCharacter extends MultiSparrowCharacter implements IHook {}
@:hscriptClass
class ScriptedPackerCharacter extends PackerCharacter implements IHook {}

View file

@ -0,0 +1,81 @@
package funkin.play.character;
import funkin.modding.events.ScriptEvent;
import funkin.util.assets.FlxAnimationUtil;
import flixel.graphics.frames.FlxFramesCollection;
/**
* A SparrowCharacter is a Character which is rendered by
* displaying an animation derived from a SparrowV2 atlas spritesheet file.
*
* BaseCharacter has game logic, SparrowCharacter has only rendering logic.
* KEEP THEM SEPARATE!
*/
class SparrowCharacter extends BaseCharacter
{
public function new(id:String)
{
super(id);
}
override function onCreate(event:ScriptEvent):Void
{
trace('Creating SPARROW CHARACTER: ' + this.characterId);
loadSpritesheet();
loadAnimations();
playAnimation(_data.startingAnimation);
}
function loadSpritesheet()
{
trace('[SPARROWCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}');
var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath, 'shared');
if (tex == null)
{
trace('Could not load Sparrow sprite: ${_data.assetPath}');
return;
}
this.frames = tex;
if (_data.isPixel)
{
this.antialiasing = false;
}
else
{
this.antialiasing = true;
}
if (_data.scale != null)
{
this.setGraphicSize(Std.int(this.width * this.scale.x));
this.updateHitbox();
}
}
function loadAnimations()
{
trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}');
FlxAnimationUtil.addSparrowAnimations(this, _data.animations);
for (anim in _data.animations)
{
if (anim.offsets == null)
{
setAnimationOffsets(anim.name, 0, 0);
}
else
{
setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]);
}
}
var animNames = this.animation.getNameList();
trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
}
}

View file

@ -1,15 +0,0 @@
package funkin.play.character.render;
import funkin.play.character.CharacterBase.CharacterType;
/**
* A SparrowCharacter is a Character which is rendered by
* displaying an animation derived from a SparrowV2 atlas spritesheet file.
*/
class SparrowCharacter extends CharacterBase
{
public function new(id:String)
{
super(id);
}
}

View file

@ -30,24 +30,14 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
public var shouldAlternate:Null<Bool> = null;
/**
* Set this value to define an additional horizontal offset to this sprite's position.
* Offset the character's sprite by this much when playing each animation.
*/
public var xOffset(default, set):Float = 0;
override function set_x(value:Float):Float
{
this.x = this.xOffset + value;
return this.x;
}
function set_xOffset(value:Float):Float
{
var diff = value - this.xOffset;
this.xOffset = value;
this.x += diff;
return value;
}
var animationOffsets:Map<String, Array<Int>> = new Map<String, Array<Int>>();
/**
* Add a suffix to the `idle` animation (or `danceLeft` and `danceRight` animations)
* that this bopper will play.
*/
public var idleSuffix(default, set):String = "";
function set_idleSuffix(value:String):String
@ -110,7 +100,7 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
/**
* Called every `danceEvery` beats of the song.
*/
function dance():Void
function dance(force:Bool = false):Void
{
if (this.animation == null)
{
@ -142,23 +132,95 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
public function hasAnimation(id:String):Bool
{
if (this.animation == null)
return false;
return this.animation.getByName(id) != null;
}
/*
* @param AnimName The string name of the animation you want to play.
* @param Force Whether to force the animation to restart.
/**
* Ensure that a given animation exists before playing it.
* Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play.
* @param name
*/
public function playAnimation(name:String, force:Bool = false):Void
function correctAnimationName(name:String)
{
this.animation.play(name, force, false, 0);
// If the animation exists, we're good.
if (hasAnimation(name))
return name;
trace('[BOPPER] Animation "$name" does not exist!');
// Attempt to strip a `-alt` suffix, if it exists.
if (name.lastIndexOf('-') != -1)
{
var correctName = name.substring(0, name.lastIndexOf('-'));
trace('[BOPPER] Attempting to fallback to "$correctName"');
return correctAnimationName(correctName);
}
else
{
if (name != 'idle')
{
trace('[BOPPER] Attempting to fallback to "idle"');
return correctAnimationName('idle');
}
else
{
trace('[BOPPER] Failing animation playback.');
return null;
}
}
}
/**
* @param name The name of the animation to play.
* @param restart Whether to restart the animation if it is already playing.
*/
public function playAnimation(name:String, restart:Bool = false):Void
{
var correctName = correctAnimationName(name);
if (correctName == null)
return;
this.animation.play(correctName, restart, false, 0);
applyAnimationOffsets(correctName);
}
function applyAnimationOffsets(name:String)
{
var offsets = animationOffsets.get(name);
if (offsets != null)
{
this.offset.set(offsets[0], offsets[1]);
}
else
{
this.offset.set(0, 0);
}
}
public function isAnimationFinished():Bool
{
return this.animation.finished;
}
public function setAnimationOffsets(name:String, xOffset:Int, yOffset:Int):Void
{
animationOffsets.set(name, [xOffset, yOffset]);
applyAnimationOffsets(name);
}
/**
* Returns the name of the animation that is currently playing.
* If no animation is playing (usually this means the character is BROKEN!),
* returns an empty string to prevent NPEs.
*/
public function getCurrentAnimation():String
{
if (this.animation == null || this.animation.curAnim == null)
return "";
return this.animation.curAnim.name;
}
@ -178,16 +240,14 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
public function onSongEnd(event:ScriptEvent) {}
public function onSongReset(event:ScriptEvent) {}
public function onGameOver(event:ScriptEvent) {}
public function onGameRetry(event:ScriptEvent) {}
public function onNoteHit(event:NoteScriptEvent) {}
public function onNoteMiss(event:NoteScriptEvent) {}
public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
public function onStepHit(event:SongTimeScriptEvent) {}
public function onCountdownStart(event:CountdownScriptEvent) {}
@ -197,4 +257,6 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
public function onCountdownEnd(event:CountdownScriptEvent) {}
public function onSongLoaded(eent:SongLoadScriptEvent) {}
public function onSongRetry(event:ScriptEvent) {}
}

View file

@ -2,6 +2,7 @@ package funkin.play.stage;
import funkin.modding.IHook;
@:hscriptClass
@:keep
class ScriptedBopper extends Bopper implements IHook {}
//
// @:hscriptClass
// @:keep
// class ScriptedBopper extends Bopper implements IHook {}

View file

@ -1,6 +1,7 @@
package funkin.play.stage;
import funkin.play.character.CharacterBase;
import funkin.util.assets.FlxAnimationUtil;
import funkin.play.character.BaseCharacter;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEvent.CountdownScriptEvent;
@ -11,7 +12,7 @@ import flixel.group.FlxSpriteGroup;
import flixel.math.FlxPoint;
import flixel.util.FlxSort;
import funkin.modding.IHook;
import funkin.play.character.CharacterBase.CharacterType;
import funkin.play.character.BaseCharacter.CharacterType;
import funkin.play.stage.StageData.StageDataParser;
import funkin.util.SortUtil;
@ -30,7 +31,7 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
public var camZoom:Float = 1.0;
var namedProps:Map<String, FlxSprite> = new Map<String, FlxSprite>();
var characters:Map<String, CharacterBase> = new Map<String, CharacterBase>();
var characters:Map<String, BaseCharacter> = new Map<String, BaseCharacter>();
var charactersOld:Map<String, Character> = new Map<String, Character>();
var boppers:Array<Bopper> = new Array<Bopper>();
@ -145,21 +146,22 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
for (propAnim in dataProp.animations)
{
propSprite.animation.add(propAnim.name, propAnim.frameIndices);
if (Std.isOfType(propSprite, Bopper))
{
cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
}
}
default: // "sparrow"
for (propAnim in dataProp.animations)
{
if (propAnim.frameIndices.length == 0)
{
propSprite.animation.addByPrefix(propAnim.name, propAnim.prefix, propAnim.frameRate, propAnim.looped, propAnim.flipX,
propAnim.flipY);
}
else
{
propSprite.animation.addByIndices(propAnim.name, propAnim.prefix, propAnim.frameIndices, "", propAnim.frameRate, propAnim.looped,
propAnim.flipX, propAnim.flipY);
}
}
FlxAnimationUtil.addSparrowAnimations(propSprite, dataProp.animations);
}
if (Std.isOfType(propSprite, Bopper))
{
for (propAnim in dataProp.animations)
{
cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
}
}
if (dataProp.startingAnimation != null)
@ -236,8 +238,11 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
/**
* Used by the PlayState to add a character to the stage.
*/
public function addCharacter(character:CharacterBase, charType:CharacterType)
public function addCharacter(character:BaseCharacter, charType:CharacterType)
{
if (character == null)
return;
// Apply position and z-index.
switch (charType)
{
@ -264,59 +269,63 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
this.add(character);
}
/**
* Used by the PlayState to add a character to the stage.
*/
public function addCharacterOld(character:Character, charType:CharacterType)
public inline function getGirlfriendPosition():FlxPoint
{
// Apply position and z-index.
switch (charType)
{
case BF:
this.charactersOld.set("bf", character);
character.zIndex = _data.characters.bf.zIndex;
character.x = _data.characters.bf.position[0];
character.y = _data.characters.bf.position[1];
case GF:
this.charactersOld.set("gf", character);
character.zIndex = _data.characters.gf.zIndex;
character.x = _data.characters.gf.position[0];
character.y = _data.characters.gf.position[1];
case DAD:
this.charactersOld.set("dad", character);
character.zIndex = _data.characters.dad.zIndex;
character.x = _data.characters.dad.position[0];
character.y = _data.characters.dad.position[1];
default:
this.charactersOld.set(character.curCharacter, character);
}
return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]);
}
// Add the character to the scene.
this.add(character);
public inline function getBoyfriendPosition():FlxPoint
{
return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]);
}
public inline function getDadPosition():FlxPoint
{
return new FlxPoint(_data.characters.dad.position[0], _data.characters.dad.position[1]);
}
/**
* Retrieves a given character from the stage.
*/
public function getCharacter(id:String):CharacterBase
public function getCharacter(id:String):BaseCharacter
{
return this.characters.get(id);
}
public function getBoyfriend():CharacterBase
/**
* Retrieve the Boyfriend character.
* @param pop If true, the character will be removed from the stage as well.
*/
public function getBoyfriend(?pop:Bool = false):BaseCharacter
{
return getCharacter('bf');
if (pop)
{
var boyfriend:BaseCharacter = getCharacter("bf");
// Remove the character from the stage.
this.remove(boyfriend);
this.characters.remove("bf");
return boyfriend;
}
else
{
return getCharacter('bf');
}
// return this.charactersOld.get('bf');
}
public function getGirlfriend():Character
public function getGirlfriend():BaseCharacter
{
return this.charactersOld.get('gf');
return getCharacter('gf');
// return this.charactersOld.get('gf');
}
public function getDad():Character
public function getDad():BaseCharacter
{
return this.charactersOld.get('dad');
return getCharacter('dad');
// return this.charactersOld.get('dad');
}
/**
@ -345,6 +354,32 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
return result;
}
/**
* Dispatch an event to all the characters in the stage.
* @param event The script event to dispatch.
*/
public function dispatchToCharacters(event:ScriptEvent):Void
{
for (characterId in characters.keys())
{
dispatchToCharacter(characterId, event);
}
}
/**
* Dispatch an event to a specific character.
* @param characterId The ID of the character to dispatch to.
* @param event The script event to dispatch.
*/
public function dispatchToCharacter(characterId:String, event:ScriptEvent):Void
{
var character:BaseCharacter = getCharacter(characterId);
if (character != null)
{
ScriptEventDispatcher.callEvent(character, event);
}
}
/**
* onDestroy gets called when the player is leaving the PlayState,
* and is used to clean up any objects that need to be destroyed.
@ -419,15 +454,8 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
public function onSongEnd(event:ScriptEvent) {}
/**
* Resets the stage and its props.
*/
public function onSongReset(event:ScriptEvent) {}
public function onGameOver(event:ScriptEvent) {}
public function onGameRetry(event:ScriptEvent) {}
public function onCountdownStart(event:CountdownScriptEvent) {}
public function onCountdownStep(event:CountdownScriptEvent) {}
@ -443,5 +471,9 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
public function onNoteMiss(event:NoteScriptEvent) {}
public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
public function onSongLoaded(eent:SongLoadScriptEvent) {}
public function onSongRetry(event:ScriptEvent) {}
}

View file

@ -1,5 +1,6 @@
package funkin.play.stage;
import funkin.util.VersionUtil;
import flixel.util.typeLimit.OneOfTwo;
import funkin.util.assets.DataAssets;
import haxe.Json;
@ -17,7 +18,12 @@ class StageDataParser
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateStageData()` function.
*/
public static final STAGE_DATA_VERSION:String = "1.0";
public static final STAGE_DATA_VERSION:String = "1.0.0";
/**
* The current version rule check for the stage data format.
*/
public static final STAGE_DATA_VERSION_RULE:String = "1.0.x";
static final stageCache:Map<String, Stage> = new Map<String, Stage>();
@ -163,16 +169,16 @@ class StageDataParser
}
}
static final DEFAULT_NAME:String = "Untitled Stage";
static final DEFAULT_CAMERAZOOM:Float = 1.0;
static final DEFAULT_ZINDEX:Int = 0;
static final DEFAULT_DANCEEVERY:Int = 0;
static final DEFAULT_SCALE:Float = 1.0;
static final DEFAULT_ISPIXEL:Bool = false;
static final DEFAULT_POSITION:Array<Float> = [0, 0];
static final DEFAULT_SCROLL:Array<Float> = [0, 0];
static final DEFAULT_FRAMEINDICES:Array<Int> = [];
static final DEFAULT_ANIMTYPE:String = "sparrow";
static final DEFAULT_CAMERAZOOM:Float = 1.0;
static final DEFAULT_DANCEEVERY:Int = 0;
static final DEFAULT_ISPIXEL:Bool = false;
static final DEFAULT_NAME:String = "Untitled Stage";
static final DEFAULT_OFFSETS:Array<Int> = [0, 0];
static final DEFAULT_POSITION:Array<Float> = [0, 0];
static final DEFAULT_SCALE:Float = 1.0;
static final DEFAULT_SCROLL:Array<Float> = [0, 0];
static final DEFAULT_ZINDEX:Int = 0;
static final DEFAULT_CHARACTER_DATA:StageDataCharacter = {
zIndex: DEFAULT_ZINDEX,
@ -200,9 +206,9 @@ class StageDataParser
return null;
}
if (input.version != STAGE_DATA_VERSION)
if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE))
{
trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad/outdated version (got ${input.version}, expected ${STAGE_DATA_VERSION})');
trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})');
return null;
}
@ -302,9 +308,9 @@ class StageDataParser
inputAnimation.frameRate = 24;
}
if (inputAnimation.frameIndices == null)
if (inputAnimation.offsets == null)
{
inputAnimation.frameIndices = DEFAULT_FRAMEINDICES;
inputAnimation.offsets = DEFAULT_OFFSETS;
}
if (inputAnimation.looped == null)
@ -362,8 +368,12 @@ class StageDataParser
typedef StageData =
{
// Uses semantic versioning.
/**
* The sematic version number of the stage data JSON format.
* Supports fancy comparisons like NPM does it's neat.
*/
var version:String;
var name:String;
var cameraZoom:Null<Float>;
var props:Array<StageDataProp>;

View file

@ -15,7 +15,8 @@ abstract BoldText(AtlasText) from AtlasText to AtlasText
}
/**
* Alphabet.hx has a ton of bugs and does a bunch of stuff I don't need, fuck that class
* AtlasText is an improved version of Alphabet and FlxBitmapText.
* It supports animations on the letters, and is less buggy than Alphabet.
*/
class AtlasText extends FlxTypedSpriteGroup<AtlasChar>
{

View file

@ -5,7 +5,6 @@ import flixel.FlxSubState;
import flixel.addons.transition.FlxTransitionableState;
import flixel.group.FlxGroup;
import flixel.util.FlxSignal;
import funkin.i18n.FireTongueHandler.t;
// typedef OptionsState = OptionsMenu_old;
// class OptionsState_new extends MusicBeatState
@ -172,25 +171,25 @@ class OptionsMenu extends Page
super();
add(items = new TextMenuList());
createItem(t("PREFERENCES"), function() switchPage(Preferences));
createItem(t("CONTROLS"), function() switchPage(Controls));
// createItem(t("COLORS"), function() switchPage(Colors));
createItem(t("MODS"), function() switchPage(Mods));
createItem("PREFERENCES", function() switchPage(Preferences));
createItem("CONTROLS", function() switchPage(Controls));
// createItem("COLORS", function() switchPage(Colors));
createItem("MODS", function() switchPage(Mods));
#if CAN_OPEN_LINKS
if (showDonate)
{
var hasPopupBlocker = #if web true #else false #end;
createItem(t("DONATE"), selectDonate, hasPopupBlocker);
createItem("DONATE", selectDonate, hasPopupBlocker);
}
#end
#if newgrounds
if (NGio.isLoggedIn)
createItem(t("LOGOUT"), selectLogout);
createItem("LOGOUT", selectLogout);
else
createItem(t("LOGIN"), selectLogin);
createItem("LOGIN", selectLogin);
#end
createItem(t("EXIT"), exit);
createItem("EXIT", exit);
}
function createItem(name:String, callback:Void->Void, fireInstantly = false)

View file

@ -0,0 +1,31 @@
package funkin.util;
import thx.semver.VersionRule;
import thx.semver.Version;
/**
* Remember, increment the patch version (1.0.x) if you make a bugfix,
* increment the minor version (1.x.0) if you make a new feature (but previous content is still compatible),
* and increment the major version (x.0.0) if you make a breaking change (e.g. new API or reorganized file format).
*/
class VersionUtil
{
/**
* Checks that a given verison number satisisfies a given version rule.
* Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports.
*/
public static function validateVersion(version:String, versionRule:String):Bool
{
try
{
var v:Version = version; // Perform a cast.
var vr:VersionRule = versionRule; // Perform a cast.
return v.satisfies(vr);
}
catch (e)
{
trace('[VERSIONUTIL] Invalid semantic version: ${version}');
return false;
}
}
}

View file

@ -21,7 +21,10 @@ class DataAssets
{
var pathNoSuffix = textPath.substring(0, textPath.length - ext.length);
var pathNoPrefix = pathNoSuffix.substring(queryPath.length);
results.push(pathNoPrefix);
// No duplicates! Why does this happen?
if (!results.contains(pathNoPrefix))
results.push(pathNoPrefix);
}
}

View file

@ -0,0 +1,42 @@
package funkin.util.assets;
import funkin.play.AnimationData;
import flixel.FlxSprite;
class FlxAnimationUtil
{
/**
* Properly adds an animation to a sprite based on JSON data.
*/
public static function addSparrowAnimation(target:FlxSprite, anim:AnimationData)
{
var frameRate = anim.frameRate == null ? 24 : anim.frameRate;
var looped = anim.looped == null ? false : anim.looped;
var flipX = anim.flipX == null ? false : anim.flipX;
var flipY = anim.flipY == null ? false : anim.flipY;
if (anim.frameIndices != null && anim.frameIndices.length > 0)
{
// trace('addByIndices(${anim.name}, ${anim.prefix}, ${anim.frameIndices}, ${frameRate}, ${looped}, ${flipX}, ${flipY})');
target.animation.addByIndices(anim.name, anim.prefix, anim.frameIndices, "", frameRate, looped, flipX, flipY);
// trace('RESULT:${target.animation.getAnimationList()}');
}
else
{
// trace('addByPrefix(${anim.name}, ${anim.prefix}, ${frameRate}, ${looped}, ${flipX}, ${flipY})');
target.animation.addByPrefix(anim.name, anim.prefix, frameRate, looped, flipX, flipY);
// trace('RESULT:${target.animation.getAnimationList()}');
}
}
/**
* Properly adds multiple animations to a sprite based on JSON data.
*/
public static function addSparrowAnimations(target:FlxSprite, animations:Array<AnimationData>)
{
for (anim in animations)
{
addSparrowAnimation(target, anim);
}
}
}