Merge branch 'feature/2-chart-2-editor'

This commit is contained in:
EliteMasterEric 2023-01-02 17:43:19 -05:00
commit 7f9171203a
70 changed files with 3837 additions and 727 deletions

54
docs/style-guide.md Normal file
View file

@ -0,0 +1,54 @@
# Funkin' Repo Code Style Guide
This short document is designed to give a run-down on how code should be formatted to maintain a consistent style throughout, making the repo easier to maintain.
## Notes on IDEs and Extensions
The Visual Studio Code IDE is highly recommended, as this repo contains various configuration that works with VSCode extensions to automatically style certain things for you.
VSCode is also the only IDE that has any good tools for Haxe so yeah.
## Whitespace and Indentation
The Haxe extension of VSCode will use the repo's `hxformat.json` to tell VSCode how to format the code files of the repo. Enabling VSCode's "Format on Save" feature is recommended.
## Variable and Function Names
It is recommended to name variables and functions with descriptive titles, in lowerCamelCase. There is no penalty for giving a variable a longer name as long as it isn't excessive.
## Code Comments
The CodeDox extension for VSCode provides extensive support for JavaDoc-style code comments, and these should be used for public functions wherever possible to ensure the usage of each function is clear.
Example:
```
/**
* Finds the largest deviation from the desired time inside this VoicesGroup.
*
* @param targetTime The time to check against.
* If none is provided, it checks the time of all members against the first member of this VoicesGroup.
* @return The largest deviation from the target time found.
*/
public function checkSyncError(?targetTime:Float):Float
```
## License Headers
Do not include headers specifying code license on individual files in the repo, since the main `LICENSE.md` file covers all of them.
## Imports
Imports should be placed in a single group, in alphabetical order, at the top of the code file. The exception is conditional imports (using compile defines), which should be placed at the end of the list (and sorted alphabetically where possible).
Example:
```
import haxe.format.JsonParser;
import openfl.Assets;
import openfl.geom.Matrix;
import openfl.geom.Matrix3D;
#if sys
import funkin.io.FileUtil;
import sys.io.File;
#end
```

View file

@ -1,5 +1,6 @@
{ {
"dependencies": [{ "dependencies": [
{
"name": "discord_rpc", "name": "discord_rpc",
"type": "git", "type": "git",
"dir": null, "dir": null,
@ -41,14 +42,14 @@
"name": "haxeui-core", "name": "haxeui-core",
"type": "git", "type": "git",
"dir": null, "dir": null,
"ref": "fc8d656b", "ref": "e5cf78d",
"url": "https://github.com/haxeui/haxeui-core/" "url": "https://github.com/haxeui/haxeui-core/"
}, },
{ {
"name": "haxeui-flixel", "name": "haxeui-flixel",
"type": "git", "type": "git",
"dir": null, "dir": null,
"ref": "80941a7", "ref": "f03bb6d",
"url": "https://github.com/haxeui/haxeui-flixel" "url": "https://github.com/haxeui/haxeui-flixel"
}, },
{ {
@ -74,12 +75,12 @@
{ {
"name": "hxp", "name": "hxp",
"type": "haxelib", "type": "haxelib",
"version": "1.2.2" "version": null
}, },
{ {
"name": "lime", "name": "lime",
"type": "haxelib", "type": "haxelib",
"version": "8.0.0" "version": null
}, },
{ {
"name": "openfl", "name": "openfl",

View file

@ -78,7 +78,13 @@ class Main extends Sprite
*/ */
#if !debug #if !debug
initialState = funkin.TitleState; /**
* Someone was like "hey let's make a state that only runs code on debug builds"
* then put essential initialization code in it.
* The easiest fix is to make it run in all builds.
* -Eric
*/
// initialState = funkin.TitleState;
#end #end
initHaxeUI(); initHaxeUI();

View file

@ -5,8 +5,6 @@ import flixel.group.FlxSpriteGroup;
import flixel.math.FlxMath; import flixel.math.FlxMath;
import flixel.util.FlxTimer; import flixel.util.FlxTimer;
using StringTools;
/** /**
* Loosley based on FlxTypeText lolol * Loosley based on FlxTypeText lolol
*/ */

View file

@ -63,13 +63,33 @@ class Conductor
} }
/** /**
* Duration of a step in milliseconds. Calculated based on bpm. * Duration of a step (quarter) in milliseconds. Calculated based on bpm.
*/ */
public static var stepCrochet(get, null):Float; public static var stepCrochet(get, null):Float;
static function get_stepCrochet():Float static function get_stepCrochet():Float
{ {
return crochet / 4; return crochet / timeSignatureNumerator;
}
public static var timeSignatureNumerator(get, null):Int;
static function get_timeSignatureNumerator():Int
{
if (currentTimeChange == null)
return 4;
return currentTimeChange.timeSignatureNum;
}
public static var timeSignatureDenominator(get, null):Int;
static function get_timeSignatureDenominator():Int
{
if (currentTimeChange == null)
return 4;
return currentTimeChange.timeSignatureDen;
} }
/** /**
@ -96,7 +116,20 @@ class Conductor
public static var offset:Float = 0; public static var offset:Float = 0;
// TODO: Add code to update this. // TODO: Add code to update this.
public static var beatsPerMeasure:Int = 4; public static var beatsPerMeasure(get, null):Int;
static function get_beatsPerMeasure():Int
{
return timeSignatureNumerator;
}
public static var stepsPerMeasure(get, null):Int;
static function get_stepsPerMeasure():Int
{
// Is this always x4?
return timeSignatureNumerator * 4;
}
private function new() private function new()
{ {
@ -124,11 +157,17 @@ class Conductor
* Forcibly defines the current BPM of the song. * Forcibly defines the current BPM of the song.
* Useful for things like the chart editor that need to manipulate BPM in real time. * Useful for things like the chart editor that need to manipulate BPM in real time.
* *
* Set to null to reset to the BPM defined by the timeChanges.
*
* WARNING: Avoid this for things like setting the BPM of the title screen music, * WARNING: Avoid this for things like setting the BPM of the title screen music,
* you should have a metadata file for it instead. * you should have a metadata file for it instead.
*/ */
public static function forceBPM(bpm:Float) public static function forceBPM(?bpm:Float = null)
{ {
if (bpm != null)
trace('[CONDUCTOR] Forcing BPM to ' + bpm);
else
trace('[CONDUCTOR] Resetting BPM to default');
Conductor.bpmOverride = bpm; Conductor.bpmOverride = bpm;
} }
@ -213,10 +252,8 @@ class Conductor
} }
} }
public static function mapTimeChanges(currentChart:SongDifficulty) public static function mapTimeChanges(songTimeChanges:Array<SongTimeChange>)
{ {
var songTimeChanges:Array<SongTimeChange> = currentChart.timeChanges;
timeChanges = []; timeChanges = [];
for (currentTimeChange in songTimeChanges) for (currentTimeChange in songTimeChanges)

View file

@ -18,8 +18,6 @@ import lime.math.Rectangle;
import lime.utils.Assets; import lime.utils.Assets;
import openfl.filters.ShaderFilter; import openfl.filters.ShaderFilter;
using StringTools;
class CoolUtil class CoolUtil
{ {
public static var difficultyArray:Array<String> = ['EASY', "NORMAL", "HARD"]; public static var difficultyArray:Array<String> = ['EASY', "NORMAL", "HARD"];

View file

@ -4,8 +4,6 @@ import flixel.FlxSprite;
import flixel.group.FlxGroup.FlxTypedGroup; import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.math.FlxPoint; import flixel.math.FlxPoint;
using StringTools;
class CutsceneCharacter extends FlxTypedGroup<FlxSprite> class CutsceneCharacter extends FlxTypedGroup<FlxSprite>
{ {
public var coolPos:FlxPoint = FlxPoint.get(); public var coolPos:FlxPoint = FlxPoint.get();

View file

@ -11,8 +11,6 @@ import flixel.util.FlxColor;
import flixel.util.FlxTimer; import flixel.util.FlxTimer;
import funkin.play.PlayState; import funkin.play.PlayState;
using StringTools;
class DialogueBox extends FlxSpriteGroup class DialogueBox extends FlxSpriteGroup
{ {
var box:FlxSprite; var box:FlxSprite;

View file

@ -1,9 +1,6 @@
package funkin; package funkin;
import Sys.sleep; import Sys.sleep;
using StringTools;
#if discord_rpc #if discord_rpc
import discord_rpc.DiscordRpc; import discord_rpc.DiscordRpc;
#end #end

View file

@ -36,8 +36,6 @@ import funkin.shaderslmfao.StrokeShader;
import lime.app.Future; import lime.app.Future;
import lime.utils.Assets; import lime.utils.Assets;
using StringTools;
class FreeplayState extends MusicBeatSubstate class FreeplayState extends MusicBeatSubstate
{ {
var songs:Array<SongMetadata> = []; var songs:Array<SongMetadata> = [];

View file

@ -1,5 +1,6 @@
package funkin; package funkin;
import flixel.system.debug.log.LogStyle;
import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond; import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
import flixel.addons.transition.FlxTransitionableState; import flixel.addons.transition.FlxTransitionableState;
import flixel.addons.transition.TransitionData; import flixel.addons.transition.TransitionData;
@ -15,10 +16,8 @@ import funkin.play.song.SongData.SongDataParser;
import funkin.play.stage.StageData; import funkin.play.stage.StageData;
import funkin.ui.PreferencesMenu; import funkin.ui.PreferencesMenu;
import funkin.util.macro.MacroUtil; import funkin.util.macro.MacroUtil;
import funkin.util.WindowUtil;
import openfl.display.BitmapData; import openfl.display.BitmapData;
using StringTools;
#if colyseus #if colyseus
import io.colyseus.Client; import io.colyseus.Client;
import io.colyseus.Room; import io.colyseus.Room;
@ -88,8 +87,16 @@ class InitState extends FlxTransitionableState
if (FlxG.save.data.mute != null) if (FlxG.save.data.mute != null)
FlxG.sound.muted = FlxG.save.data.mute; FlxG.sound.muted = FlxG.save.data.mute;
// Make errors and warnings less annoying.
LogStyle.ERROR.openConsole = false;
LogStyle.ERROR.errorSound = null;
LogStyle.WARNING.openConsole = false;
LogStyle.WARNING.errorSound = null;
// FlxG.save.close(); // FlxG.save.close();
// FlxG.sound.loadSavedPrefs(); // FlxG.sound.loadSavedPrefs();
WindowUtil.initWindowEvents();
PreferencesMenu.initPrefs(); PreferencesMenu.initPrefs();
PlayerSettings.init(); PlayerSettings.init();
Highscore.load(); Highscore.load();
@ -125,6 +132,8 @@ class InitState extends FlxTransitionableState
ModuleHandler.buildModuleCallbacks(); ModuleHandler.buildModuleCallbacks();
ModuleHandler.loadModuleCache(); ModuleHandler.loadModuleCache();
FlxG.debugger.toggleKeys = [F2];
#if song #if song
var song = getSong(); var song = getSong();

View file

@ -31,8 +31,10 @@ class LatencyState extends MusicBeatSubstate
var offsetsPerBeat:Array<Int> = []; var offsetsPerBeat:Array<Int> = [];
var swagSong:HomemadeMusic; var swagSong:HomemadeMusic;
#if debug
var funnyStatsGraph:CoolStatsGraph; var funnyStatsGraph:CoolStatsGraph;
var realStats:CoolStatsGraph; var realStats:CoolStatsGraph;
#end
override function create() override function create()
{ {
@ -42,11 +44,13 @@ class LatencyState extends MusicBeatSubstate
FlxG.sound.music = swagSong; FlxG.sound.music = swagSong;
FlxG.sound.music.play(); FlxG.sound.music.play();
#if debug
funnyStatsGraph = new CoolStatsGraph(0, Std.int(FlxG.height / 2), FlxG.width, Std.int(FlxG.height / 2), FlxColor.PINK, "time"); funnyStatsGraph = new CoolStatsGraph(0, Std.int(FlxG.height / 2), FlxG.width, Std.int(FlxG.height / 2), FlxColor.PINK, "time");
FlxG.addChildBelowMouse(funnyStatsGraph); FlxG.addChildBelowMouse(funnyStatsGraph);
realStats = new CoolStatsGraph(0, Std.int(FlxG.height / 2), FlxG.width, Std.int(FlxG.height / 2), FlxColor.YELLOW, "REAL"); realStats = new CoolStatsGraph(0, Std.int(FlxG.height / 2), FlxG.width, Std.int(FlxG.height / 2), FlxColor.YELLOW, "REAL");
FlxG.addChildBelowMouse(realStats); FlxG.addChildBelowMouse(realStats);
#end
FlxG.stage.addEventListener(KeyboardEvent.KEY_DOWN, key -> FlxG.stage.addEventListener(KeyboardEvent.KEY_DOWN, key ->
{ {
@ -170,8 +174,10 @@ class LatencyState extends MusicBeatSubstate
trace(FlxG.sound.music._channel.position); trace(FlxG.sound.music._channel.position);
*/ */
#if debug
funnyStatsGraph.update(FlxG.sound.music.time % 500); funnyStatsGraph.update(FlxG.sound.music.time % 500);
realStats.update(swagSong.getTimeWithDiff() % 500); realStats.update(swagSong.getTimeWithDiff() % 500);
#end
if (FlxG.keys.justPressed.S) if (FlxG.keys.justPressed.S)
{ {

View file

@ -188,10 +188,13 @@ class LoadingState extends MusicBeatState
{ {
Paths.setCurrentLevel('tutorial'); Paths.setCurrentLevel('tutorial');
} }
else if (PlayState.storyWeek == 8) { else if (PlayState.storyWeek == 8)
{
// TODO: Refactor this code. // TODO: Refactor this code.
Paths.setCurrentLevel("weekend1"); Paths.setCurrentLevel("weekend1");
} else { }
else
{
Paths.setCurrentLevel("week" + PlayState.storyWeek); Paths.setCurrentLevel("week" + PlayState.storyWeek);
} }
#if NO_PRELOAD_ALL #if NO_PRELOAD_ALL
@ -251,7 +254,7 @@ class LoadingState extends MusicBeatState
} }
else else
{ {
if (StringTools.endsWith(path, ".bundle")) if (path.endsWith(".bundle"))
{ {
rootPath = path; rootPath = path;
path += "/library.json"; path += "/library.json";
@ -351,5 +354,5 @@ class MultiCallback
return fired.copy(); return fired.copy();
public function getUnfired() public function getUnfired()
return [for (id in unfired.keys()) id]; return unfired.array();
} }

View file

@ -28,9 +28,6 @@ import funkin.util.Constants;
import funkin.util.WindowUtil; import funkin.util.WindowUtil;
import lime.app.Application; import lime.app.Application;
import openfl.filters.ShaderFilter; import openfl.filters.ShaderFilter;
using StringTools;
#if discord_rpc #if discord_rpc
import Discord.DiscordClient; import Discord.DiscordClient;
#end #end

View file

@ -60,6 +60,7 @@ class MusicBeatState extends FlxUIState
{ {
super.update(elapsed); super.update(elapsed);
// Emergency exit button.
if (FlxG.keys.justPressed.F4) if (FlxG.keys.justPressed.F4)
FlxG.switchState(new MainMenuState()); FlxG.switchState(new MainMenuState());
@ -67,7 +68,7 @@ class MusicBeatState extends FlxUIState
if (FlxG.keys.justPressed.F5) if (FlxG.keys.justPressed.F5)
debug_refreshModules(); debug_refreshModules();
// ` / ~ // ` / ~ to open the debug menu.
if (FlxG.keys.justPressed.GRAVEACCENT) if (FlxG.keys.justPressed.GRAVEACCENT)
{ {
// TODO: Does this break anything? // TODO: Does this break anything?
@ -76,7 +77,10 @@ class MusicBeatState extends FlxUIState
FlxG.state.openSubState(new DebugMenuSubState()); FlxG.state.openSubState(new DebugMenuSubState());
} }
// Display Conductor info in the watch window.
FlxG.watch.addQuick("songPos", Conductor.songPosition); FlxG.watch.addQuick("songPos", Conductor.songPosition);
FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime);
FlxG.watch.addQuick("bpm", Conductor.bpm);
dispatchEvent(new UpdateScriptEvent(elapsed)); dispatchEvent(new UpdateScriptEvent(elapsed));
} }

View file

@ -15,8 +15,6 @@ import io.newgrounds.objects.events.Result.GetCurrentVersionResult;
import io.newgrounds.objects.events.Result.GetVersionResult; import io.newgrounds.objects.events.Result.GetVersionResult;
import lime.app.Application; import lime.app.Application;
import openfl.display.Stage; import openfl.display.Stage;
using StringTools;
#end #end
/** /**

View file

@ -10,8 +10,6 @@ import funkin.shaderslmfao.ColorSwap;
import funkin.ui.PreferencesMenu; import funkin.ui.PreferencesMenu;
import funkin.util.Constants; import funkin.util.Constants;
using StringTools;
class Note extends FlxSprite class Note extends FlxSprite
{ {
public var data = new NoteData(); public var data = new NoteData();

View file

@ -6,8 +6,6 @@ import funkin.play.PlayState;
import haxe.Json; import haxe.Json;
import lime.utils.Assets; import lime.utils.Assets;
using StringTools;
typedef SwagSong = typedef SwagSong =
{ {
var song:String; var song:String;

View file

@ -15,9 +15,6 @@ import funkin.play.PlayState;
import funkin.play.song.SongData.SongDataParser; import funkin.play.song.SongData.SongDataParser;
import lime.net.curl.CURLCode; import lime.net.curl.CURLCode;
import openfl.Assets; import openfl.Assets;
using StringTools;
#if discord_rpc #if discord_rpc
import Discord.DiscordClient; import Discord.DiscordClient;
#end #end

View file

@ -23,8 +23,6 @@ import openfl.events.NetStatusEvent;
import openfl.media.Video; import openfl.media.Video;
import openfl.net.NetStream; import openfl.net.NetStream;
using StringTools;
#if desktop #if desktop
#end #end
class TitleState extends MusicBeatState class TitleState extends MusicBeatState

View file

@ -7,30 +7,38 @@ import flixel.system.FlxSound;
// when needed // when needed
class VoicesGroup extends FlxTypedGroup<FlxSound> class VoicesGroup extends FlxTypedGroup<FlxSound>
{ {
public var time(default, set):Float = 0; public var time(get, set):Float;
public var volume(default, set):Float = 1; public var volume(get, set):Float;
public var pitch(default, set):Float = 1; public var pitch(get, set):Float;
// make it a group that you add to? // make it a group that you add to?
public function new(song:String, ?files:Array<String> = null) public function new()
{ {
super(); super();
}
// TODO: Remove this.
public static function build(song:String, ?files:Array<String> = null):VoicesGroup
{
var result = new VoicesGroup();
if (files == null) if (files == null)
{ {
// Add an empty voice. // Add an empty voice.
add(new FlxSound()); result.add(new FlxSound());
return; return result;
} }
for (sndFile in files) for (sndFile in files)
{ {
var snd:FlxSound = new FlxSound().loadEmbedded(Paths.voices(song, '$sndFile')); var snd:FlxSound = new FlxSound().loadEmbedded(Paths.voices(song, '$sndFile'));
FlxG.sound.list.add(snd); // adds it to sound group for proper volumes FlxG.sound.list.add(snd); // adds it to sound group for proper volumes
add(snd); // adds it to main group for other shit result.add(snd); // adds it to main group for other shit
} }
return result;
} }
/** /**
@ -83,6 +91,14 @@ class VoicesGroup extends FlxTypedGroup<FlxSound>
}); });
} }
function get_time():Float
{
if (getFirstAlive() != null)
return getFirstAlive().time;
else
return 0;
}
function set_time(time:Float):Float function set_time(time:Float):Float
{ {
forEachAlive(function(snd) forEachAlive(function(snd)
@ -94,6 +110,14 @@ class VoicesGroup extends FlxTypedGroup<FlxSound>
return time; return time;
} }
function get_volume():Float
{
if (getFirstAlive() != null)
return getFirstAlive().volume;
else
return 1;
}
// in PlayState, adjust the code so that it only mutes the player1 vocal tracks? // in PlayState, adjust the code so that it only mutes the player1 vocal tracks?
function set_volume(volume:Float):Float function set_volume(volume:Float):Float
{ {
@ -105,9 +129,20 @@ class VoicesGroup extends FlxTypedGroup<FlxSound>
return volume; return volume;
} }
function get_pitch():Float
{
#if FLX_PITCH
if (getFirstAlive() != null)
return getFirstAlive().pitch;
else
#end
return 1;
}
function set_pitch(val:Float):Float function set_pitch(val:Float):Float
{ {
#if HAS_PITCH #if FLX_PITCH
trace('Setting audio pitch to ' + val);
forEachAlive(function(snd) forEachAlive(function(snd)
{ {
snd.pitch = val; snd.pitch = val;

View file

@ -17,8 +17,6 @@ import io.newgrounds.objects.events.Result.GetCurrentVersionResult;
import io.newgrounds.objects.events.Result.GetVersionResult; import io.newgrounds.objects.events.Result.GetVersionResult;
#end #end
using StringTools;
/** /**
* Contains any script functions which should be BLOCKED from use by modded scripts. * Contains any script functions which should be BLOCKED from use by modded scripts.
*/ */

View file

@ -17,8 +17,6 @@ import io.newgrounds.objects.events.Result.GetCurrentVersionResult;
import io.newgrounds.objects.events.Result.GetVersionResult; import io.newgrounds.objects.events.Result.GetVersionResult;
#end #end
using StringTools;
/** /**
* Contains any script functions which should be ALLOWD for use by modded scripts. * Contains any script functions which should be ALLOWD for use by modded scripts.
*/ */

View file

@ -0,0 +1,208 @@
package funkin.audio;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.system.FlxSound;
/**
* A group of FlxSounds which can be controlled as a whole.
*
* Add sounds to the group using `add()`, and then control them
* as a whole using the properties and methods of this class.
*
* It is assumed that all the sounds will play at the same time,
* and have the same duration.
*/
class FlxAudioGroup extends FlxTypedGroup<FlxSound>
{
/**
* The position in time of the sounds in the group.
* Measured in milliseconds.
*/
public var time(get, set):Float;
function get_time():Float
{
if (getFirstAlive() != null)
return getFirstAlive().time;
else
return 0;
}
function set_time(time:Float):Float
{
forEachAlive(function(sound:FlxSound)
{
// account for different offsets per sound?
sound.time = time;
});
return time;
}
/**
* The volume of the sounds in the group.
*/
public var volume(get, set):Float;
function get_volume():Float
{
if (getFirstAlive() != null)
return getFirstAlive().volume;
else
return 1.0;
}
function set_volume(volume:Float):Float
{
forEachAlive(function(sound:FlxSound)
{
sound.volume = volume;
});
return volume;
}
/**
* The pitch of the sounds in the group, as a multiplier of 1.0x.
* `2.0` would play the audio twice as fast with a higher pitch,
* and `0.5` would play the audio at half speed with a lower pitch.
*/
public var pitch(get, set):Float;
function get_pitch():Float
{
#if FLX_PITCH
if (getFirstAlive() != null)
return getFirstAlive().pitch;
else
#end
return 1;
}
function set_pitch(val:Float):Float
{
#if FLX_PITCH
trace('Setting audio pitch to ' + val);
forEachAlive(function(sound:FlxSound)
{
sound.pitch = val;
});
#end
return val;
}
/**
* Whether members of the group should be destroyed when they finish playing.
*/
public var autoDestroyMembers(default, set):Bool = false;
function set_autoDestroyMembers(value:Bool):Bool
{
autoDestroyMembers = value;
forEachAlive(function(sound:FlxSound)
{
sound.autoDestroy = value;
});
return value;
}
/**
* Add a sound to the group.
*/
public override function add(sound:FlxSound):FlxSound
{
var result:FlxSound = super.add(sound);
if (result == null)
return null;
// Apply parameters to the new sound.
result.autoDestroy = this.autoDestroyMembers;
result.pitch = this.pitch;
result.volume = this.volume;
// We have to play, then pause the sound to set the time,
// else the sound will restart immediately when played.
result.play(true, 0.0);
result.pause();
result.time = this.time;
return result;
}
/**
* Pause all the sounds in the group.
*/
public function pause()
{
forEachAlive(function(sound:FlxSound)
{
sound.pause();
});
}
/**
* Play all the sounds in the group.
*/
public function play(forceRestart:Bool = false, startTime:Float = 0.0, ?endTime:Float)
{
forEachAlive(function(sound:FlxSound)
{
sound.play(forceRestart, startTime, endTime);
});
}
/**
* Resume all the sounds in the group.
*/
public function resume()
{
forEachAlive(function(sound:FlxSound)
{
sound.resume();
});
}
/**
* Stop all the sounds in the group.
*/
public function stop()
{
forEachAlive(function(sound:FlxSound)
{
sound.stop();
});
}
public override function clear():Void {
this.stop();
super.clear();
}
/**
* Calculates the deviation of the sounds in the group from the target time.
*
* @param targetTime The time to compare the sounds to.
* If null, the current time of the first sound in the group is used.
* @return The largest deviation of the sounds in the group from the target time.
*/
public function calcDeviation(?targetTime:Float):Float
{
var deviation:Float = 0;
forEachAlive(function(sound:FlxSound)
{
if (targetTime == null)
targetTime = sound.time;
else
{
var diff:Float = sound.time - targetTime;
if (Math.abs(diff) > Math.abs(deviation))
deviation = diff;
}
});
return deviation;
}
}

View file

@ -0,0 +1,119 @@
package funkin.audio;
import flixel.system.FlxSound;
/**
* An audio group that allows for specific control of vocal tracks.
*/
class VocalGroup extends FlxAudioGroup
{
/**
* The player's vocal track.
*/
var playerVocals:FlxSound;
/**
* The opponent's vocal track.
*/
var opponentVocals:FlxSound;
/**
* The volume of the player's vocal track.
* Nore that this value is multiplied by the overall volume of the group.
*/
public var playerVolume(default, set):Float;
function set_playerVolume(value:Float):Float
{
playerVolume = value;
if (playerVocals != null)
{
// Make sure volume is capped at 1.0.
playerVocals.volume = Math.min(playerVolume * this.volume, 1.0);
}
return playerVolume;
}
/**
* The volume of the opponent's vocal track.
* Nore that this value is multiplied by the overall volume of the group.
*/
public var opponentVolume(default, set):Float;
function set_opponentVolume(value:Float):Float
{
opponentVolume = value;
if (opponentVocals != null)
{
// Make sure volume is capped at 1.0.
opponentVocals.volume = opponentVolume * this.volume;
}
return opponentVolume;
}
/**
* Sets up the player's vocal track.
* Stops and removes the existing player track if one exists.
*/
public function setPlayerVocals(sound:FlxSound):FlxSound
{
if (playerVocals != null)
{
playerVocals.stop();
remove(playerVocals);
playerVocals = null;
}
playerVocals = add(sound);
playerVocals.volume = this.playerVolume * this.volume;
return playerVocals;
}
/**
* Sets up the opponent's vocal track.
* Stops and removes the existing player track if one exists.
*/
public function setOpponentVocals(sound:FlxSound):FlxSound
{
if (opponentVocals != null)
{
opponentVocals.stop();
remove(opponentVocals);
opponentVocals = null;
}
opponentVocals = add(sound);
opponentVocals.volume = this.opponentVolume * this.volume;
return opponentVocals;
}
/**
* In this extension of FlxAudioGroup, there is a separate overall volume
* which affects all the members of the group.
*/
var _volume = 1.0;
override function get_volume():Float
{
return _volume;
}
override function set_volume(value:Float):Float
{
_volume = super.set_volume(value);
if (playerVocals != null)
{
playerVocals.volume = playerVolume * _volume;
}
if (opponentVocals != null)
{
opponentVocals.volume = opponentVolume * _volume;
}
return _volume;
}
}

View file

@ -34,7 +34,6 @@ import openfl.events.IOErrorEvent;
import openfl.net.FileReference; import openfl.net.FileReference;
using Lambda; using Lambda;
using StringTools;
using flixel.util.FlxSpriteUtil; // add in "compiler save" that saves the JSON directly to the debug json using File.write() stuff on windows / sys using flixel.util.FlxSpriteUtil; // add in "compiler save" that saves the JSON directly to the debug json using File.write() stuff on windows / sys
class ChartingState extends MusicBeatState class ChartingState extends MusicBeatState
@ -445,7 +444,7 @@ class ChartingState extends MusicBeatState
add(playheadTest); add(playheadTest);
// WONT WORK FOR TUTORIAL OR TEST SONG!!! REDO LATER // WONT WORK FOR TUTORIAL OR TEST SONG!!! REDO LATER
vocals = new VoicesGroup(daSong, _song.voiceList); vocals = VoicesGroup.build(daSong, _song.voiceList);
// vocals = new FlxSound().loadEmbedded(Paths.voices(daSong)); // vocals = new FlxSound().loadEmbedded(Paths.voices(daSong));
// FlxG.sound.list.add(vocals); // FlxG.sound.list.add(vocals);

View file

@ -3,8 +3,6 @@ package funkin.freeplayStuff;
import flixel.FlxSprite; import flixel.FlxSprite;
import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
using StringTools;
class FreeplayScore extends FlxTypedSpriteGroup<ScoreNum> class FreeplayScore extends FlxTypedSpriteGroup<ScoreNum>
{ {
public var scoreShit(default, set):Int = 0; public var scoreShit(default, set):Int = 0;

View file

@ -3,4 +3,11 @@
import funkin.Paths; import funkin.Paths;
import flixel.FlxG; // This one in particular causes a compile error if you're using macros. import flixel.FlxG; // This one in particular causes a compile error if you're using macros.
// These are great.
using Lambda;
using StringTools;
using funkin.util.tools.IteratorTools;
using funkin.util.tools.StringTools;
#end #end

View file

@ -0,0 +1,282 @@
package funkin.input;
import openfl.utils.Assets;
import lime.app.Future;
import openfl.display.BitmapData;
class Cursor
{
public static var cursorMode(default, set):CursorMode;
static final CURSOR_DEFAULT_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-default.png",
scale: 1.0,
offsetX: 0,
offsetY: 0,
};
static var assetCursorDefault:BitmapData = null;
static final CURSOR_CROSS_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-cross.png",
scale: 1.0,
offsetX: 0,
offsetY: 0,
};
static var assetCursorCross:BitmapData = null;
static final CURSOR_ERASER_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-eraser.png",
scale: 1.0,
offsetX: 0,
offsetY: 0,
};
static var assetCursorEraser:BitmapData = null;
static final CURSOR_GRABBING_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-grabbing.png",
scale: 1.0,
offsetX: 32,
offsetY: 0,
};
static var assetCursorGrabbing:BitmapData = null;
static final CURSOR_HOURGLASS_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-hourglass.png",
scale: 1.0,
offsetX: 0,
offsetY: 0,
};
static var assetCursorHourglass:BitmapData = null;
static final CURSOR_POINTER_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-pointer.png",
scale: 1.0,
offsetX: 8,
offsetY: 0,
};
static var assetCursorPointer:BitmapData = null;
static final CURSOR_TEXT_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-text.png",
scale: 1.0,
offsetX: 0,
offsetY: 0,
};
static var assetCursorText:BitmapData = null;
static final CURSOR_ZOOM_IN_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-zoom-in.png",
scale: 1.0,
offsetX: 0,
offsetY: 0,
};
static var assetCursorZoomIn:BitmapData = null;
static final CURSOR_ZOOM_OUT_PARAMS:CursorParams = {
graphic: "assets/images/cursor/cursor-zoom-out.png",
scale: 1.0,
offsetX: 0,
offsetY: 0,
};
static var assetCursorZoomOut:BitmapData = null;
static function set_cursorMode(value:CursorMode):CursorMode
{
if (cursorMode != value)
{
cursorMode = value;
setCursorGraphic(cursorMode);
}
return cursorMode;
}
public static inline function show():Void
{
FlxG.mouse.visible = true;
}
public static inline function hide():Void
{
FlxG.mouse.visible = false;
}
static function setCursorGraphic(?value:CursorMode = null):Void
{
if (value == null)
{
FlxG.mouse.unload();
return;
}
switch (value)
{
case Default:
if (assetCursorDefault == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_DEFAULT_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorDefault = bitmapData;
applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS);
});
}
else
{
applyCursorParams(assetCursorDefault, CURSOR_DEFAULT_PARAMS);
}
case Cross:
if (assetCursorCross == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_CROSS_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorCross = bitmapData;
applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS);
});
}
else
{
applyCursorParams(assetCursorCross, CURSOR_CROSS_PARAMS);
}
case Eraser:
if (assetCursorEraser == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_ERASER_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorEraser = bitmapData;
applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS);
});
}
else
{
applyCursorParams(assetCursorEraser, CURSOR_ERASER_PARAMS);
}
case Grabbing:
if (assetCursorGrabbing == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_GRABBING_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorGrabbing = bitmapData;
applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS);
});
}
else
{
applyCursorParams(assetCursorGrabbing, CURSOR_GRABBING_PARAMS);
}
case Hourglass:
if (assetCursorHourglass == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_HOURGLASS_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorHourglass = bitmapData;
applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS);
});
}
else
{
applyCursorParams(assetCursorHourglass, CURSOR_HOURGLASS_PARAMS);
}
case Pointer:
if (assetCursorPointer == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_POINTER_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorPointer = bitmapData;
applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS);
});
}
else
{
applyCursorParams(assetCursorPointer, CURSOR_POINTER_PARAMS);
}
case Text:
if (assetCursorText == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_TEXT_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorText = bitmapData;
applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS);
});
}
else
{
applyCursorParams(assetCursorText, CURSOR_TEXT_PARAMS);
}
case ZoomIn:
if (assetCursorZoomIn == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_ZOOM_IN_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorZoomIn = bitmapData;
applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS);
});
}
else
{
applyCursorParams(assetCursorZoomIn, CURSOR_ZOOM_IN_PARAMS);
}
case ZoomOut:
if (assetCursorZoomOut == null)
{
var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_ZOOM_OUT_PARAMS.graphic);
future.onComplete(function(bitmapData:BitmapData)
{
assetCursorZoomOut = bitmapData;
applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
});
}
else
{
applyCursorParams(assetCursorZoomOut, CURSOR_ZOOM_OUT_PARAMS);
}
default:
setCursorGraphic(null);
}
}
static inline function applyCursorParams(graphic:BitmapData, params:CursorParams):Void
{
FlxG.mouse.load(graphic, params.scale, params.offsetX, params.offsetY);
}
}
// https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
enum CursorMode
{
Default;
Cross;
Eraser;
Grabbing;
Hourglass;
Pointer;
Text;
ZoomIn;
ZoomOut;
}
/**
* Static data describing how a cursor should be rendered.
*/
typedef CursorParams =
{
graphic:String,
scale:Float,
offsetX:Int,
offsetY:Int,
}

View file

@ -7,6 +7,7 @@ import funkin.play.stage.StageData;
import polymod.Polymod; import polymod.Polymod;
import polymod.backends.PolymodAssets.PolymodAssetType; import polymod.backends.PolymodAssets.PolymodAssetType;
import polymod.format.ParseRules.TextFileFormat; import polymod.format.ParseRules.TextFileFormat;
import funkin.util.FileUtil;
class PolymodHandler class PolymodHandler
{ {
@ -25,10 +26,7 @@ class PolymodHandler
public static function createModRoot() public static function createModRoot()
{ {
if (!sys.FileSystem.exists(MOD_FOLDER)) FileUtil.createDirIfNotExists(MOD_FOLDER);
{
sys.FileSystem.createDirectory(MOD_FOLDER);
}
} }
/** /**
@ -183,7 +181,11 @@ class PolymodHandler
public static function getAllMods():Array<ModMetadata> public static function getAllMods():Array<ModMetadata>
{ {
trace('Scanning the mods folder...'); trace('Scanning the mods folder...');
var modMetadata = Polymod.scan(); var modMetadata = Polymod.scan({
modRoot: MOD_FOLDER,
apiVersionRule: API_VERSION,
errorCallback: PolymodErrorHandler.onPolymodError
});
trace('Found ${modMetadata.length} mods when scanning.'); trace('Found ${modMetadata.length} mods when scanning.');
return modMetadata; return modMetadata;
} }

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import flixel.addons.display.FlxRuntimeShader;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedFlxRuntimeShader extends FlxRuntimeShader implements HScriptedClass {} class ScriptedFlxRuntimeShader extends flixel.addons.display.FlxRuntimeShader implements HScriptedClass
{
}

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import flixel.FlxSprite;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedFlxSprite extends FlxSprite implements HScriptedClass {} class ScriptedFlxSprite extends flixel.FlxSprite implements HScriptedClass
{
}

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import flixel.group.FlxSpriteGroup;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedFlxSpriteGroup extends FlxSpriteGroup implements HScriptedClass {} class ScriptedFlxSpriteGroup extends flixel.group.FlxSpriteGroup implements HScriptedClass
{
}

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import flixel.FlxState;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedFlxState extends FlxState implements HScriptedClass {} class ScriptedFlxState extends flixel.FlxState implements HScriptedClass
{
}

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import flixel.FlxSubState;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedFlxSubState extends FlxSubState implements HScriptedClass {} class ScriptedFlxSubState extends flixel.FlxSubState implements HScriptedClass
{
}

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import flixel.addons.transition.FlxTransitionableState;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedFlxTransitionableState extends FlxTransitionableState implements HScriptedClass {} class ScriptedFlxTransitionableState extends flixel.addons.transition.FlxTransitionableState implements HScriptedClass
{
}

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import flixel.addons.ui.FlxUIState;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedFlxUIState extends FlxUIState implements HScriptedClass {} class ScriptedFlxUIState extends flixel.addons.ui.FlxUIState implements HScriptedClass
{
}

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import funkin.MusicBeatState;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedMusicBeatState extends MusicBeatState implements HScriptedClass {} class ScriptedMusicBeatState extends funkin.MusicBeatState implements HScriptedClass
{
}

View file

@ -1,7 +1,6 @@
package funkin.modding.base; package funkin.modding.base;
import funkin.MusicBeatSubstate;
import polymod.hscript.HScriptedClass;
@:hscriptClass @:hscriptClass
class ScriptedMusicBeatSubstate extends MusicBeatSubstate implements HScriptedClass {} class ScriptedMusicBeatSubstate extends funkin.MusicBeatSubstate implements HScriptedClass
{
}

View file

@ -0,0 +1 @@
import polymod.hscript.HScriptedClass;

View file

@ -6,8 +6,6 @@ import funkin.modding.events.ScriptEventDispatcher;
import funkin.modding.module.Module; import funkin.modding.module.Module;
import funkin.modding.module.ScriptedModule; import funkin.modding.module.ScriptedModule;
using funkin.util.IteratorTools;
/** /**
* Utility functions for loading and manipulating active modules. * Utility functions for loading and manipulating active modules.
*/ */

View file

@ -10,8 +10,6 @@ import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEvent.CountdownScriptEvent; import funkin.modding.events.ScriptEvent.CountdownScriptEvent;
import flixel.util.FlxTimer; import flixel.util.FlxTimer;
using StringTools;
class Countdown class Countdown
{ {
/** /**

View file

@ -11,8 +11,6 @@ import funkin.play.PlayState;
import funkin.play.character.BaseCharacter; import funkin.play.character.BaseCharacter;
import funkin.ui.PreferencesMenu; import funkin.ui.PreferencesMenu;
using StringTools;
/** /**
* A substate which renders over the PlayState when the player dies. * A substate which renders over the PlayState when the player dies.
* Displays the player death animation, plays the music, and handles restarting the song. * Displays the player death animation, plays the music, and handles restarting the song.

View file

@ -196,17 +196,11 @@ class HealthIcon extends FlxSprite
// Make the health icons bump (the update function causes them to lerp back down). // Make the health icons bump (the update function causes them to lerp back down).
if (this.width > this.height) if (this.width > this.height)
{ {
var targetSize = Std.int(CoolUtil.coolLerp(this.width + HEALTH_ICON_SIZE * 0.2, HEALTH_ICON_SIZE, 0.15)); setGraphicSize(Std.int(this.width + (HEALTH_ICON_SIZE * this.size.x * 0.2)), 0);
targetSize = Std.int(Math.min(targetSize, HEALTH_ICON_SIZE * 1.2));
setGraphicSize(targetSize, 0);
} }
else else
{ {
var targetSize = Std.int(CoolUtil.coolLerp(this.height + HEALTH_ICON_SIZE * 0.2, HEALTH_ICON_SIZE, 0.15)); setGraphicSize(0, Std.int(this.height + (HEALTH_ICON_SIZE * this.size.y * 0.2)));
targetSize = Std.int(Math.min(targetSize, HEALTH_ICON_SIZE * 1.2));
setGraphicSize(0, targetSize);
} }
this.updateHitbox(); this.updateHitbox();
} }

View file

@ -43,9 +43,6 @@ import funkin.ui.stageBuildShit.StageOffsetSubstate;
import funkin.util.Constants; import funkin.util.Constants;
import funkin.util.SortUtil; import funkin.util.SortUtil;
import lime.ui.Haptic; import lime.ui.Haptic;
using StringTools;
#if discord_rpc #if discord_rpc
import Discord.DiscordClient; import Discord.DiscordClient;
#end #end
@ -356,7 +353,7 @@ class PlayState extends MusicBeatState
if (currentSong_NEW != null) if (currentSong_NEW != null)
{ {
Conductor.mapTimeChanges(currentChart); Conductor.mapTimeChanges(currentChart.timeChanges);
// Conductor.bpm = currentChart.getStartingBPM(); // Conductor.bpm = currentChart.getStartingBPM();
// TODO: Support for dialog. // TODO: Support for dialog.
@ -1032,9 +1029,9 @@ class PlayState extends MusicBeatState
currentSong.song = currentSong.song; currentSong.song = currentSong.song;
if (currentSong.needsVoices) if (currentSong.needsVoices)
vocals = new VoicesGroup(currentSong.song, currentSong.voiceList); vocals = VoicesGroup.build(currentSong.song, currentSong.voiceList);
else else
vocals = new VoicesGroup(currentSong.song, null); vocals = VoicesGroup.build(currentSong.song, null);
vocals.members[0].onComplete = function() vocals.members[0].onComplete = function()
{ {

View file

@ -6,8 +6,6 @@ import funkin.noteStuff.NoteBasic.NoteDir;
import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.stage.Bopper; 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. * A Character is a stage prop which bops to the music as well as controlled by the strumlines.
* *
@ -96,8 +94,9 @@ class BaseCharacter extends Bopper
if (animOffsets == value) if (animOffsets == value)
return value; return value;
var xDiff = animOffsets[0] - value[0]; // Make sure animOffets are halved when scale is 0.5.
var yDiff = animOffsets[1] - value[1]; var xDiff = (animOffsets[0] * this.scale.x) - value[0];
var yDiff = (animOffsets[1] * this.scale.y) - value[1];
// Call the super function so that camera focus point is not affected. // Call the super function so that camera focus point is not affected.
super.set_x(this.x + xDiff); super.set_x(this.x + xDiff);

View file

@ -16,8 +16,6 @@ import funkin.util.assets.DataAssets;
import haxe.Json; import haxe.Json;
import openfl.utils.Assets; import openfl.utils.Assets;
using StringTools;
class CharacterDataParser class CharacterDataParser
{ {
/** /**
@ -225,7 +223,7 @@ class CharacterDataParser
public static function listCharacterIds():Array<String> public static function listCharacterIds():Array<String>
{ {
return [for (x in characterCache.keys()) x]; return characterCache.keys().array();
} }
static function clearCharacterCache():Void static function clearCharacterCache():Void
@ -258,7 +256,7 @@ class CharacterDataParser
static function loadCharacterFile(charPath:String):String static function loadCharacterFile(charPath:String):String
{ {
var charFilePath:String = Paths.json('characters/${charPath}'); var charFilePath:String = Paths.json('characters/${charPath}');
var rawJson = StringTools.trim(Assets.getText(charFilePath)); var rawJson = Assets.getText(charFilePath).trim();
while (!StringTools.endsWith(rawJson, "}")) while (!StringTools.endsWith(rawJson, "}"))
{ {

View file

@ -45,6 +45,11 @@ class Song // implements IPlayStateScriptedClass
populateFromMetadata(); populateFromMetadata();
} }
public function getRawMetadata():Array<SongMetadata>
{
return _metadata;
}
/** /**
* Populate the song data from the provided metadata, * Populate the song data from the provided metadata,
* including data from individual difficulties. Does not load chart data. * including data from individual difficulties. Does not load chart data.
@ -122,8 +127,11 @@ class Song // implements IPlayStateScriptedClass
/** /**
* Retrieve the metadata for a specific difficulty, including the chart if it is loaded. * Retrieve the metadata for a specific difficulty, including the chart if it is loaded.
*/ */
public inline function getDifficulty(diffId:String):SongDifficulty public inline function getDifficulty(?diffId:String):SongDifficulty
{ {
if (diffId == null)
diffId = difficulties.keys().array()[0];
return difficulties.get(diffId); return difficulties.get(diffId);
} }
@ -214,7 +222,7 @@ class SongDifficulty
public function getPlayableChars():Array<String> public function getPlayableChars():Array<String>
{ {
return [for (i in chars.keys()) i]; return chars.keys().array();
} }
public function getEvents():Array<SongEvent> public function getEvents():Array<SongEvent>
@ -246,7 +254,7 @@ class SongDifficulty
public function buildVocals(charId:String = "bf"):VoicesGroup public function buildVocals(charId:String = "bf"):VoicesGroup
{ {
var result:VoicesGroup = new VoicesGroup(this.song.songId, this.buildVoiceList()); var result:VoicesGroup = VoicesGroup.build(this.song.songId, this.buildVoiceList());
return result; return result;
} }
} }

View file

@ -8,8 +8,6 @@ import haxe.Json;
import openfl.utils.Assets; import openfl.utils.Assets;
import thx.semver.Version; import thx.semver.Version;
using StringTools;
/** /**
* Contains utilities for loading and parsing stage data. * Contains utilities for loading and parsing stage data.
*/ */
@ -96,12 +94,12 @@ class SongDataParser
if (songCache.exists(songId)) if (songCache.exists(songId))
{ {
var song:Song = songCache.get(songId); var song:Song = songCache.get(songId);
trace('[STAGEDATA] Successfully fetch song: ${songId}'); trace('[SONGDATA] Successfully fetch song: ${songId}');
return song; return song;
} }
else else
{ {
trace('[STAGEDATA] Failed to fetch song, not found in cache: ${songId}'); trace('[SONGDATA] Failed to fetch song, not found in cache: ${songId}');
return null; return null;
} }
} }
@ -116,7 +114,7 @@ class SongDataParser
public static function listSongIds():Array<String> public static function listSongIds():Array<String>
{ {
return [for (x in songCache.keys()) x]; return songCache.keys().array();
} }
public static function parseSongMetadata(songId:String):Array<SongMetadata> public static function parseSongMetadata(songId:String):Array<SongMetadata>
@ -261,9 +259,25 @@ abstract SongMetadata(RawSongMetadata)
noteSkin: 'Normal' noteSkin: 'Normal'
}, },
generatedBy: SongValidator.DEFAULT_GENERATEDBY, generatedBy: SongValidator.DEFAULT_GENERATEDBY,
// Variation ID.
variation: variation variation: variation
}; };
} }
public function clone(?newVariation:String = null):SongMetadata
{
var result = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
result.version = this.version;
result.timeFormat = this.timeFormat;
result.divisions = this.divisions;
result.timeChanges = this.timeChanges;
result.loop = this.loop;
result.playData = this.playData;
result.generatedBy = this.generatedBy;
return result;
}
} }
typedef SongPlayData = typedef SongPlayData =

View file

@ -135,7 +135,7 @@ class SongDataUtils
trace('Read ' + notesString.length + ' characters from clipboard.'); trace('Read ' + notesString.length + ' characters from clipboard.');
var notes:Array<SongNoteData> = SerializerUtil.fromJSON(notesString); var notes:Array<SongNoteData> = notesString.parseJSON();
if (notes == null) if (notes == null)
{ {

View file

@ -25,7 +25,7 @@ class SongSerializer
if (fileData == null) if (fileData == null)
return null; return null;
var songChartData:SongChartData = SerializerUtil.fromJSON(fileData); var songChartData:SongChartData = fileData.parseJSON();
return songChartData; return songChartData;
} }
@ -41,7 +41,7 @@ class SongSerializer
if (fileData == null) if (fileData == null)
return null; return null;
var songMetadata:SongMetadata = SerializerUtil.fromJSON(fileData); var songMetadata:SongMetadata = fileData.parseJSON();
return songMetadata; return songMetadata;
} }
@ -59,7 +59,7 @@ class SongSerializer
if (data == null) if (data == null)
return; return;
var songChartData:SongChartData = SerializerUtil.fromJSON(data); var songChartData:SongChartData = data.parseJSON();
if (songChartData != null) if (songChartData != null)
callback(songChartData); callback(songChartData);
@ -79,7 +79,7 @@ class SongSerializer
if (data == null) if (data == null)
return; return;
var songMetadata:SongMetadata = SerializerUtil.fromJSON(data); var songMetadata:SongMetadata = data.parseJSON();
if (songMetadata != null) if (songMetadata != null)
callback(songMetadata); callback(songMetadata);

View file

@ -6,6 +6,9 @@ import flixel.util.FlxTimer;
import funkin.modding.IScriptedClass.IPlayStateScriptedClass; import funkin.modding.IScriptedClass.IPlayStateScriptedClass;
import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEvent;
typedef AnimationFrameCallback = String->Int->Int->Void;
typedef AnimationFinishedCallback = String->Void;
/** /**
* A Bopper is a stage prop which plays a dance animation. * A Bopper is a stage prop which plays a dance animation.
* Y'know, a thingie that bops. A bopper. * Y'know, a thingie that bops. A bopper.
@ -68,8 +71,8 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
this.x += xDiff; this.x += xDiff;
this.y += yDiff; this.y += yDiff;
return animOffsets = value; return animOffsets = value;
} }
private var animOffsets(default, set):Array<Float> = [0, 0]; private var animOffsets(default, set):Array<Float> = [0, 0];
@ -113,6 +116,7 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
*/ */
function onAnimationFinished(name:String) function onAnimationFinished(name:String)
{ {
// TODO: Can we make a system of like, animation priority or something?
if (!canPlayOtherAnims) if (!canPlayOtherAnims)
{ {
canPlayOtherAnims = true; canPlayOtherAnims = true;
@ -131,10 +135,10 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1) function onAnimationFrame(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1)
{ {
// Do nothing by default. // Do nothing by default.
// This can be overridden by, for example, scripted characters. // This can be overridden by, for example, scripted characters,
// Try not to do anything expensive here, it runs many times a second. // or by calling `animationFrame.add()`.
// Sometimes this gets called with empty values? IDK why but adding defaults keeps it from crashing. // Try not to do anything expensive here, it runs many times a second.
} }
/** /**

View file

@ -8,8 +8,6 @@ import funkin.util.assets.DataAssets;
import haxe.Json; import haxe.Json;
import openfl.Assets; import openfl.Assets;
using StringTools;
/** /**
* Contains utilities for loading and parsing stage data. * Contains utilities for loading and parsing stage data.
*/ */
@ -143,7 +141,7 @@ class StageDataParser
public static function listStageIds():Array<String> public static function listStageIds():Array<String>
{ {
return [for (x in stageCache.keys()) x]; return stageCache.keys().array();
} }
static function loadStageFile(stagePath:String):String static function loadStageFile(stagePath:String):String

View file

@ -6,8 +6,6 @@ import flixel.tweens.FlxTween;
import funkin.play.PlayState; import funkin.play.PlayState;
import funkin.util.Constants; import funkin.util.Constants;
using StringTools;
class PopUpStuff extends FlxTypedGroup<FlxSprite> class PopUpStuff extends FlxTypedGroup<FlxSprite>
{ {
override public function new() override public function new()

View file

@ -33,7 +33,6 @@ import openfl.net.URLLoader;
import openfl.net.URLRequest; import openfl.net.URLRequest;
import openfl.utils.ByteArray; import openfl.utils.ByteArray;
using StringTools;
using flixel.util.FlxSpriteUtil; using flixel.util.FlxSpriteUtil;
#if web #if web

View file

@ -64,6 +64,7 @@ class AddNotesCommand implements ChartEditorCommand
state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -76,6 +77,7 @@ class AddNotesCommand implements ChartEditorCommand
state.currentSelection = []; state.currentSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -109,6 +111,7 @@ class RemoveNotesCommand implements ChartEditorCommand
state.currentSelection = []; state.currentSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -124,6 +127,7 @@ class RemoveNotesCommand implements ChartEditorCommand
state.currentSelection = notes; state.currentSelection = notes;
state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -409,6 +413,7 @@ class CutNotesCommand implements ChartEditorCommand
// Delete the notes. // Delete the notes.
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
state.currentSelection = []; state.currentSelection = [];
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
state.sortChartData(); state.sortChartData();
@ -419,6 +424,7 @@ class CutNotesCommand implements ChartEditorCommand
state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
state.currentSelection = notes; state.currentSelection = notes;
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -453,6 +459,7 @@ class FlipNotesCommand implements ChartEditorCommand
state.currentSelection = flippedNotes; state.currentSelection = flippedNotes;
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
state.sortChartData(); state.sortChartData();
@ -465,6 +472,7 @@ class FlipNotesCommand implements ChartEditorCommand
state.currentSelection = notes; state.currentSelection = notes;
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -498,6 +506,7 @@ class PasteNotesCommand implements ChartEditorCommand
state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes);
state.currentSelection = addedNotes.copy(); state.currentSelection = addedNotes.copy();
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -509,6 +518,7 @@ class PasteNotesCommand implements ChartEditorCommand
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes); state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes);
state.currentSelection = []; state.currentSelection = [];
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -539,6 +549,7 @@ class AddEventsCommand implements ChartEditorCommand
// TODO: Allow selecting events. // TODO: Allow selecting events.
// state.currentSelection = events; // state.currentSelection = events;
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -551,6 +562,7 @@ class AddEventsCommand implements ChartEditorCommand
state.currentSelection = []; state.currentSelection = [];
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -581,6 +593,7 @@ class ExtendNoteLengthCommand implements ChartEditorCommand
{ {
note.length = newLength; note.length = newLength;
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -591,6 +604,7 @@ class ExtendNoteLengthCommand implements ChartEditorCommand
{ {
note.length = oldLength; note.length = oldLength;
state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;

View file

@ -1,5 +1,497 @@
package funkin.ui.debug.charting; package funkin.ui.debug.charting;
import flixel.FlxSprite;
import flixel.util.FlxTimer;
import funkin.input.Cursor;
import funkin.play.character.BaseCharacter;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.song.SongData.SongDataParser;
import funkin.play.song.SongData.SongPlayableChar;
import funkin.play.song.SongData.SongTimeChange;
import haxe.ui.components.Button;
import haxe.ui.components.DropDown;
import haxe.ui.components.Image;
import haxe.ui.components.Label;
import haxe.ui.components.Link;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.TextField;
import haxe.ui.containers.Box;
import haxe.ui.containers.dialogs.Dialog;
import haxe.ui.containers.dialogs.Dialogs;
import haxe.ui.containers.properties.Property;
import haxe.ui.containers.properties.PropertyGrid;
import haxe.ui.containers.properties.PropertyGroup;
import haxe.ui.containers.VBox;
import haxe.ui.events.MouseEvent;
import haxe.ui.events.UIEvent;
using Lambda;
class ChartEditorDialogHandler class ChartEditorDialogHandler
{ {
static final CHART_EDITOR_DIALOG_ABOUT_LAYOUT = Paths.ui('chart-editor/dialogs/about');
static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT = Paths.ui('chart-editor/dialogs/welcome');
static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT = Paths.ui('chart-editor/dialogs/upload-inst');
static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT = Paths.ui('chart-editor/dialogs/song-metadata');
static final CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT = Paths.ui('chart-editor/dialogs/song-metadata-chargroup');
static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT = Paths.ui('chart-editor/dialogs/upload-vocals');
static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT = Paths.ui('chart-editor/dialogs/upload-vocals-entry');
static final CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT = Paths.ui('chart-editor/dialogs/user-guide');
/**
*
*/
public static inline function openAboutDialog(state:ChartEditorState):Dialog
{
return openDialog(state, CHART_EDITOR_DIALOG_ABOUT_LAYOUT, true, true);
}
/**
* Builds and opens a dialog letting the user create a new chart, open a recent chart, or load from a template.
*/
public static function openWelcomeDialog(state:ChartEditorState, closable:Bool = true):Dialog
{
var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable);
// TODO: Add callbacks to the dialog buttons
// Switch the graphic for frames.
var bfSpritePlaceholder:Image = dialog.findComponent('bfSprite', Image);
// TODO: Replace this bullshit with a custom HaxeUI component that loads the sprite from the game's assets.
if (bfSpritePlaceholder != null)
{
var bfSprite:FlxSprite = new FlxSprite(0, 0);
bfSprite.visible = false;
var frames = Paths.getSparrowAtlas(bfSpritePlaceholder.resource);
bfSprite.frames = frames;
bfSprite.animation.addByPrefix('idle', 'Boyfriend DJ0', 24, true);
bfSprite.animation.play('idle');
bfSpritePlaceholder.rootComponent.add(bfSprite);
bfSpritePlaceholder.visible = false;
new FlxTimer().start(0.10, (_timer:FlxTimer) ->
{
bfSprite.x = bfSpritePlaceholder.screenLeft;
bfSprite.y = bfSpritePlaceholder.screenTop;
bfSprite.setGraphicSize(Std.int(bfSpritePlaceholder.width), Std.int(bfSpritePlaceholder.height));
bfSprite.visible = true;
});
}
// Add handlers to the "Create From Song" section.
var linkCreateBasic:Link = dialog.findComponent('splashCreateFromSongBasic', Link);
linkCreateBasic.onClick = (_event) ->
{
dialog.hideDialog(DialogButton.CANCEL);
// Create song wizard
var uploadInstDialog = openUploadInstDialog(state, false);
uploadInstDialog.onDialogClosed = (_event) ->
{
state.isHaxeUIDialogOpen = false;
if (_event.button == DialogButton.APPLY)
{
var songMetadataDialog = openSongMetadataDialog(state);
songMetadataDialog.onDialogClosed = (_event) ->
{
state.isHaxeUIDialogOpen = false;
if (_event.button == DialogButton.APPLY)
{
var uploadVocalsDialog = openUploadVocalsDialog(state);
}
};
}
};
}
// TODO: Get the list of songs and insert them as links into the "Create From Song" section.
/*
var linkTemplateDadBattle:Link = dialog.findComponent('splashTemplateDadBattle', Link);
linkTemplateDadBattle.onClick = (_event) ->
{
dialog.hideDialog(DialogButton.CANCEL);
// Load song from template
state.loadSongAsTemplate('dadbattle');
}
var linkTemplateBopeebo:Link = dialog.findComponent('splashTemplateBopeebo', Link);
linkTemplateBopeebo.onClick = (_event) ->
{
dialog.hideDialog(DialogButton.CANCEL);
// Load song from template
state.loadSongAsTemplate('bopeebo');
}
*/
var splashTemplateContainer:VBox = dialog.findComponent('splashTemplateContainer', VBox);
var songList:Array<String> = SongDataParser.listSongIds();
for (targetSongId in songList) {
var songData = SongDataParser.fetchSong(targetSongId);
if (songData == null)
continue;
var songName = songData.getDifficulty().songName;
var linkTemplateSong:Link = new Link();
linkTemplateSong.text = songName;
linkTemplateSong.onClick = (_event) ->
{
dialog.hideDialog(DialogButton.CANCEL);
// Load song from template
state.loadSongAsTemplate(targetSongId);
}
splashTemplateContainer.addComponent(linkTemplateSong);
}
return dialog;
}
public static function openUploadInstDialog(state:ChartEditorState, ?closable:Bool = true):Dialog
{
var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT, true, closable);
var instrumentalBox:Box = dialog.findComponent('instrumentalBox', Box);
instrumentalBox.onMouseOver = (_event) ->
{
instrumentalBox.swapClass('upload-bg', 'upload-bg-hover');
Cursor.cursorMode = Pointer;
}
instrumentalBox.onMouseOut = (_event) ->
{
instrumentalBox.swapClass('upload-bg-hover', 'upload-bg');
Cursor.cursorMode = Default;
}
var onDropFile:String->Void;
instrumentalBox.onClick = (_event) ->
{
Dialogs.openBinaryFile("Open Instrumental", [{label: "Audio File (.ogg)", extension: "ogg"}], function(selectedFile)
{
if (selectedFile != null)
{
trace('Selected file: ' + selectedFile);
state.loadInstrumentalFromBytes(selectedFile.bytes);
dialog.hideDialog(DialogButton.APPLY);
removeDropHandler(onDropFile);
}
});
}
onDropFile = (path:String) ->
{
trace('Dropped file: ' + path);
state.loadInstrumentalFromPath(path);
dialog.hideDialog(DialogButton.APPLY);
removeDropHandler(onDropFile);
};
addDropHandler(onDropFile);
return dialog;
}
static function addDropHandler(handler:String->Void)
{
#if desktop
FlxG.stage.window.onDropFile.add(handler);
#else
trace('addDropHandler not implemented for this platform');
#end
}
static function removeDropHandler(handler:String->Void)
{
#if desktop
FlxG.stage.window.onDropFile.remove(handler);
#end
}
public static function openSongMetadataDialog(state:ChartEditorState):Dialog
{
var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false);
var dialogSongName:TextField = dialog.findComponent('dialogSongName', TextField);
dialogSongName.onChange = (event:UIEvent) ->
{
var valid = event.target.text != null && event.target.text != "";
if (valid)
{
dialogSongName.removeClass('invalid-value');
state.currentSongMetadata.songName = event.target.text;
}
else
{
state.currentSongMetadata.songName = null;
}
};
state.currentSongMetadata.songName = null;
var dialogSongArtist:TextField = dialog.findComponent('dialogSongArtist', TextField);
dialogSongArtist.onChange = (event:UIEvent) ->
{
var valid = event.target.text != null && event.target.text != "";
if (valid)
{
dialogSongArtist.removeClass('invalid-value');
state.currentSongMetadata.artist = event.target.text;
}
else
{
state.currentSongMetadata.artist = null;
}
};
state.currentSongMetadata.artist = null;
var dialogStage:DropDown = dialog.findComponent('dialogStage', DropDown);
dialogStage.onChange = (event:UIEvent) ->
{
var valid = event.data != null && event.data.id != null;
if (event.data.id == null)
return;
state.currentSongMetadata.playData.stage = event.data.id;
};
state.currentSongMetadata.playData.stage = null;
var dialogNoteSkin:DropDown = dialog.findComponent('dialogNoteSkin', DropDown);
dialogNoteSkin.onChange = (event:UIEvent) ->
{
if (event.data.id == null)
return;
state.currentSongMetadata.playData.noteSkin = event.data.id;
};
state.currentSongMetadata.playData.noteSkin = null;
var dialogBPM:NumberStepper = dialog.findComponent('dialogBPM', NumberStepper);
dialogBPM.onChange = (event:UIEvent) ->
{
if (event.value == null || event.value <= 0)
return;
var timeChanges = state.currentSongMetadata.timeChanges;
if (timeChanges == null || timeChanges.length == 0)
{
timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])];
}
else
{
timeChanges[0].bpm = event.value;
}
Conductor.forceBPM(event.value);
state.currentSongMetadata.timeChanges = timeChanges;
};
var dialogCharGrid:PropertyGrid = dialog.findComponent('dialogCharGrid', PropertyGrid);
var dialogCharAdd:Button = dialog.findComponent('dialogCharAdd', Button);
dialogCharAdd.onClick = (_event) ->
{
var charGroup:PropertyGroup;
charGroup = buildCharGroup(state, null, () ->
{
dialogCharGrid.removeComponent(charGroup);
});
dialogCharGrid.addComponent(charGroup);
};
// Empty the character list.
state.currentSongMetadata.playData.playableChars = {};
// Add at least one character group with no Remove button.
dialogCharGrid.addComponent(buildCharGroup(state, 'bf', null));
var dialogContinue:Button = dialog.findComponent('dialogContinue', Button);
dialogContinue.onClick = (_event) ->
{
dialog.hideDialog(DialogButton.APPLY);
};
return dialog;
}
static function buildCharGroup(state:ChartEditorState, ?key:String = null, removeFunc:Void->Void):PropertyGroup
{
var groupKey = key;
var getCharData = () ->
{
if (groupKey == null)
groupKey = 'newChar${state.currentSongMetadata.playData.playableChars.keys().count()}';
var result = state.currentSongMetadata.playData.playableChars.get(groupKey);
if (result == null)
{
result = new SongPlayableChar('', 'dad');
state.currentSongMetadata.playData.playableChars.set(groupKey, result);
}
return result;
}
var moveCharGroup = (target:String) ->
{
var charData = getCharData();
state.currentSongMetadata.playData.playableChars.remove(groupKey);
state.currentSongMetadata.playData.playableChars.set(target, charData);
groupKey = target;
}
var removeGroup = () ->
{
state.currentSongMetadata.playData.playableChars.remove(groupKey);
removeFunc();
}
var charData = getCharData();
var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT);
var charGroupPlayer:DropDown = charGroup.findComponent('charGroupPlayer', DropDown);
charGroupPlayer.onChange = (event:UIEvent) ->
{
charGroup.text = event.data.text;
moveCharGroup(event.data.id);
};
if (key == null)
{
// Find the next available player character.
trace(charGroupPlayer.dataSource.data);
}
var charGroupOpponent:DropDown = charGroup.findComponent('charGroupOpponent', DropDown);
charGroupOpponent.onChange = (event:UIEvent) ->
{
charData.opponent = event.data.id;
};
charGroupOpponent.value = getCharData().opponent;
var charGroupGirlfriend:DropDown = charGroup.findComponent('charGroupGirlfriend', DropDown);
charGroupGirlfriend.onChange = (event:UIEvent) ->
{
charData.girlfriend = event.data.id;
};
charGroupGirlfriend.value = getCharData().girlfriend;
var charGroupRemove:Button = charGroup.findComponent('charGroupRemove', Button);
charGroupRemove.onClick = (_event:MouseEvent) ->
{
removeGroup();
};
if (removeFunc == null)
charGroupRemove.hidden = true;
return charGroup;
}
public static function openUploadVocalsDialog(state:ChartEditorState, ?closable:Bool = true):Dialog
{
var charIdsForVocals = [];
for (charKey in state.currentSongMetadata.playData.playableChars.keys())
{
var charData = state.currentSongMetadata.playData.playableChars.get(charKey);
charIdsForVocals.push(charKey);
if (charData.opponent != null)
charIdsForVocals.push(charData.opponent);
}
var dialog:Dialog = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable);
var dialogContainer = dialog.findComponent('vocalContainer');
var onDropFile:String->Void;
for (charKey in charIdsForVocals)
{
trace('Adding vocal upload for character ${charKey}');
var charMetadata:BaseCharacter = CharacterDataParser.fetchCharacter(charKey);
var charName:String = charMetadata.characterName;
var vocalsEntry = state.buildComponent(CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT);
var vocalsEntryLabel:Label = vocalsEntry.findComponent('vocalsEntryLabel', Label);
vocalsEntryLabel.text = 'Click to browse for a vocal track for $charName.';
vocalsEntry.onClick = (_event) ->
{
Dialogs.openBinaryFile('Open $charName Vocals', [{label: "Audio File (.ogg)", extension: "ogg"}], function(selectedFile)
{
if (selectedFile != null)
{
trace('Selected file: ' + selectedFile.name + "~" + selectedFile.fullPath);
vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
state.loadVocalsFromBytes(selectedFile.bytes);
removeDropHandler(onDropFile);
}
});
}
dialogContainer.addComponent(vocalsEntry);
}
var dialogContinue:Button = dialog.findComponent('dialogContinue', Button);
dialogContinue.onClick = (_event) ->
{
dialog.hideDialog(DialogButton.APPLY);
};
// TODO: Redo the logic for file drop handler to be more robust.
// We need to distinguish which component the mouse is over when the file is dropped.
onDropFile = (path:String) ->
{
trace('Dropped file: ' + path);
};
addDropHandler(onDropFile);
return dialog;
}
/**
* Builds and opens a dialog displaying the user guide, providing guidance and help on how to use the chart editor.
*/
public static inline function openUserGuideDialog(state:ChartEditorState):Dialog
{
return openDialog(state, CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT, true, true);
}
/**
* Builds and opens a dialog from a given layout path.
* @param modal Makes the background uninteractable while the dialog is open.
* @param closable Hides the close button on the dialog, preventing it from being closed unless the user interacts with the dialog.
*/
static function openDialog(state:ChartEditorState, key:String, modal:Bool = true, closable:Bool = true):Dialog
{
var dialog:Dialog = cast state.buildComponent(key);
dialog.destroyOnClose = true;
dialog.closable = closable;
dialog.showDialog(modal);
state.isHaxeUIDialogOpen = true;
dialog.onDialogClosed = (_event) ->
{
state.isHaxeUIDialogOpen = false;
};
return dialog;
}
} }

View file

@ -1,5 +1,7 @@
package funkin.ui.debug.charting; package funkin.ui.debug.charting;
import flixel.FlxObject;
import flixel.FlxBasic;
import flixel.FlxSprite; import flixel.FlxSprite;
import flixel.graphics.frames.FlxFramesCollection; import flixel.graphics.frames.FlxFramesCollection;
import flixel.graphics.frames.FlxTileFrames; import flixel.graphics.frames.FlxTileFrames;
@ -12,17 +14,14 @@ import funkin.play.song.SongData.SongNoteData;
*/ */
class ChartEditorNoteSprite extends FlxSprite class ChartEditorNoteSprite extends FlxSprite
{ {
public var parentState:ChartEditorState;
/** /**
* The note data that this sprite represents. * The note data that this sprite represents.
* You can set this to null to kill the sprite and flag it for recycling. * You can set this to null to kill the sprite and flag it for recycling.
*/ */
public var noteData(default, set):SongNoteData; public var noteData(default, set):SongNoteData;
/**
* The note skin that this sprite displays.
*/
public var noteSkin(default, set):String = 'Normal';
/** /**
* This note is the previous sprite in a sustain chain. * This note is the previous sprite in a sustain chain.
*/ */
@ -33,10 +32,12 @@ class ChartEditorNoteSprite extends FlxSprite
*/ */
public var childNoteSprite(default, set):ChartEditorNoteSprite = null; public var childNoteSprite(default, set):ChartEditorNoteSprite = null;
public function new() public function new(parent:ChartEditorState)
{ {
super(); super();
this.parentState = parent;
if (noteFrameCollection == null) if (noteFrameCollection == null)
{ {
initFrameCollection(); initFrameCollection();
@ -131,26 +132,12 @@ class ChartEditorNoteSprite extends FlxSprite
playNoteAnimation(); playNoteAnimation();
// Update the position to match the note data. // Update the position to match the note data.
setNotePosition(); updateNotePosition();
return this.noteData; return this.noteData;
} }
function set_noteSkin(value:String):String public function updateNotePosition(?origin:FlxObject)
{
// Don't update if the skin hasn't changed.
if (value == this.noteSkin)
return this.noteSkin;
this.noteSkin = value;
// Make sure to update the graphic to match the note skin.
playNoteAnimation();
return this.noteSkin;
}
function setNotePosition()
{ {
var cursorColumn:Int = this.noteData.data; var cursorColumn:Int = this.noteData.data;
@ -179,7 +166,13 @@ class ChartEditorNoteSprite extends FlxSprite
// Notes far in the song will start far down, but the group they belong to will have a high negative offset. // Notes far in the song will start far down, but the group they belong to will have a high negative offset.
// TODO: stepTime doesn't account for fluctuating BPMs. // TODO: stepTime doesn't account for fluctuating BPMs.
if (this.noteData.stepTime >= 0)
this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE; this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE;
if (origin != null) {
this.x += origin.x;
this.y += origin.y;
}
} }
else else
{ {
@ -214,7 +207,6 @@ class ChartEditorNoteSprite extends FlxSprite
if (this.parentNoteSprite != null) if (this.parentNoteSprite != null)
{ {
this.noteData = this.parentNoteSprite.noteData; this.noteData = this.parentNoteSprite.noteData;
this.noteSkin = this.parentNoteSprite.noteSkin;
} }
return this.parentNoteSprite; return this.parentNoteSprite;
@ -227,13 +219,12 @@ class ChartEditorNoteSprite extends FlxSprite
if (this.parentNoteSprite != null) if (this.parentNoteSprite != null)
{ {
this.noteData = this.parentNoteSprite.noteData; this.noteData = this.parentNoteSprite.noteData;
this.noteSkin = this.parentNoteSprite.noteSkin;
} }
return this.childNoteSprite; return this.childNoteSprite;
} }
function playNoteAnimation() public function playNoteAnimation()
{ {
// Decide whether to display a note or a sustain. // Decide whether to display a note or a sustain.
var baseAnimationName:String = 'tap'; var baseAnimationName:String = 'tap';
@ -241,7 +232,7 @@ class ChartEditorNoteSprite extends FlxSprite
baseAnimationName = (this.childNoteSprite != null) ? 'hold' : 'holdEnd'; baseAnimationName = (this.childNoteSprite != null) ? 'hold' : 'holdEnd';
// Play the appropriate animation for the type, direction, and skin. // Play the appropriate animation for the type, direction, and skin.
var animationName = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteSkin}'; var animationName = '${baseAnimationName}${this.noteData.getDirectionName()}${this.parentState.currentSongNoteSkin}';
this.animation.play(animationName); this.animation.play(animationName);
@ -266,7 +257,7 @@ class ChartEditorNoteSprite extends FlxSprite
this.updateHitbox(); this.updateHitbox();
// TODO: Make this an attribute of the note skin. // TODO: Make this an attribute of the note skin.
this.antialiasing = (noteSkin != 'Pixel'); this.antialiasing = (this.parentState.currentSongNoteSkin != 'Pixel');
} }
/** /**

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
package funkin.ui.debug.charting; package funkin.ui.debug.charting;
import flixel.FlxSprite;
import flixel.addons.display.FlxGridOverlay; import flixel.addons.display.FlxGridOverlay;
import flixel.addons.display.FlxSliceSprite; import flixel.addons.display.FlxSliceSprite;
import flixel.math.FlxRect; import flixel.math.FlxRect;
@ -32,18 +33,24 @@ class ChartEditorThemeHandler
static final GRID_COLOR_1_DARK:FlxColor = 0xFF181919; static final GRID_COLOR_1_DARK:FlxColor = 0xFF181919;
// Color 2 of the grid pattern. Alternates with Color 1. // Color 2 of the grid pattern. Alternates with Color 1.
static final GRID_COLOR_2_LIGHT:FlxColor = 0xFFD9D5D5; static final GRID_COLOR_2_LIGHT:FlxColor = 0xFFF8F8F8;
static final GRID_COLOR_2_DARK:FlxColor = 0xFF262A2A; static final GRID_COLOR_2_DARK:FlxColor = 0xFF202020;
// Color 3 of the grid pattern. Borders the other colors.
static final GRID_COLOR_3_LIGHT:FlxColor = 0xFFD9D5D5;
static final GRID_COLOR_3_DARK:FlxColor = 0xFF262A2A;
// Vertical divider between characters. // Vertical divider between characters.
static final GRID_STRUMLINE_DIVIDER_COLOR_LIGHT:FlxColor = 0xFF000000; static final GRID_STRUMLINE_DIVIDER_COLOR_LIGHT:FlxColor = 0xFF111111;
static final GRID_STRUMLINE_DIVIDER_COLOR_DARK:FlxColor = 0xFFC4C4C4; static final GRID_STRUMLINE_DIVIDER_COLOR_DARK:FlxColor = 0xFFC4C4C4;
static final GRID_STRUMLINE_DIVIDER_WIDTH:Float = 2; // static final GRID_STRUMLINE_DIVIDER_WIDTH:Float = 2;
static final GRID_STRUMLINE_DIVIDER_WIDTH:Float = ChartEditorState.GRID_SELECTION_BORDER_WIDTH;
// Horizontal divider between measures. // Horizontal divider between measures.
static final GRID_MEASURE_DIVIDER_COLOR_LIGHT:FlxColor = 0xFF000000; static final GRID_MEASURE_DIVIDER_COLOR_LIGHT:FlxColor = 0xFF111111;
static final GRID_MEASURE_DIVIDER_COLOR_DARK:FlxColor = 0xFFC4C4C4; static final GRID_MEASURE_DIVIDER_COLOR_DARK:FlxColor = 0xFFC4C4C4;
static final GRID_MEASURE_DIVIDER_WIDTH:Float = 2; // static final GRID_MEASURE_DIVIDER_WIDTH:Float = 2;
static final GRID_MEASURE_DIVIDER_WIDTH:Float = ChartEditorState.GRID_SELECTION_BORDER_WIDTH;
// Border on the square highlighting selected notes. // Border on the square highlighting selected notes.
static final SELECTION_SQUARE_BORDER_COLOR_LIGHT:FlxColor = 0xFF339933; static final SELECTION_SQUARE_BORDER_COLOR_LIGHT:FlxColor = 0xFF339933;
@ -55,9 +62,9 @@ class ChartEditorThemeHandler
static final SELECTION_SQUARE_FILL_COLOR_LIGHT:FlxColor = 0x4033FF33; static final SELECTION_SQUARE_FILL_COLOR_LIGHT:FlxColor = 0x4033FF33;
static final SELECTION_SQUARE_FILL_COLOR_DARK:FlxColor = 0x4033FF33; static final SELECTION_SQUARE_FILL_COLOR_DARK:FlxColor = 0x4033FF33;
// TODO: Un-hardcode these to be based on time signature. static final PLAYHEAD_BLOCK_BORDER_WIDTH:Int = 2;
static final STEPS_PER_BEAT:Int = 4; static final PLAYHEAD_BLOCK_BORDER_COLOR:FlxColor = 0xFF9D0011;
static final BEATS_PER_MEASURE:Int = 4; static final PLAYHEAD_BLOCK_FILL_COLOR:FlxColor = 0xFFBD0231;
public static function updateTheme(state:ChartEditorState):Void public static function updateTheme(state:ChartEditorState):Void
{ {
@ -100,11 +107,70 @@ class ChartEditorThemeHandler
// 2 * (Strumline Size) + 1 grid squares wide, by (4 * quarter notes per measure) grid squares tall. // 2 * (Strumline Size) + 1 grid squares wide, by (4 * quarter notes per measure) grid squares tall.
// This gets reused to fill the screen. // This gets reused to fill the screen.
var gridWidth = ChartEditorState.GRID_SIZE * (ChartEditorState.STRUMLINE_SIZE * 2 + 1); var gridWidth:Int = Std.int(ChartEditorState.GRID_SIZE * (ChartEditorState.STRUMLINE_SIZE * 2 + 1));
var gridHeight = ChartEditorState.GRID_SIZE * (STEPS_PER_BEAT * BEATS_PER_MEASURE); var gridHeight:Int = Std.int(ChartEditorState.GRID_SIZE * (Conductor.stepsPerMeasure));
state.gridBitmap = FlxGridOverlay.createGrid(ChartEditorState.GRID_SIZE, ChartEditorState.GRID_SIZE, gridWidth, gridHeight, true, gridColor1, state.gridBitmap = FlxGridOverlay.createGrid(ChartEditorState.GRID_SIZE, ChartEditorState.GRID_SIZE, gridWidth, gridHeight, true, gridColor1,
gridColor2); gridColor2);
// Selection borders
var selectionBorderColor:FlxColor = switch (state.currentTheme)
{
case Light: GRID_COLOR_3_LIGHT;
case Dark: GRID_COLOR_3_DARK;
default: GRID_COLOR_3_LIGHT;
};
// Selection border at top.
state.gridBitmap.fillRect(new Rectangle(0, -(ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), state.gridBitmap.width,
ChartEditorState.GRID_SELECTION_BORDER_WIDTH),
selectionBorderColor);
// Selection borders in the middle.
for (i in 1...(Conductor.stepsPerMeasure))
{
state.gridBitmap.fillRect(new Rectangle(0, (ChartEditorState.GRID_SIZE * i) - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2),
state.gridBitmap.width, ChartEditorState.GRID_SELECTION_BORDER_WIDTH),
selectionBorderColor);
}
// Selection border at bottom.
state.gridBitmap.fillRect(new Rectangle(0, state.gridBitmap.height - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), state.gridBitmap.width,
ChartEditorState.GRID_SELECTION_BORDER_WIDTH),
selectionBorderColor);
// Selection border at left.
state.gridBitmap.fillRect(new Rectangle(-(ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), 0, ChartEditorState.GRID_SELECTION_BORDER_WIDTH,
state.gridBitmap.height),
selectionBorderColor);
// Selection borders across the middle.
for (i in 1...(ChartEditorState.STRUMLINE_SIZE * 2 + 1))
{
state.gridBitmap.fillRect(new Rectangle((ChartEditorState.GRID_SIZE * i) - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), 0,
ChartEditorState.GRID_SELECTION_BORDER_WIDTH, state.gridBitmap.height),
selectionBorderColor);
}
// Selection border at right.
state.gridBitmap.fillRect(new Rectangle(state.gridBitmap.width - (ChartEditorState.GRID_SELECTION_BORDER_WIDTH / 2), 0,
ChartEditorState.GRID_SELECTION_BORDER_WIDTH, state.gridBitmap.height),
selectionBorderColor);
// Draw dividers between the measures.
var gridMeasureDividerColor:FlxColor = switch (state.currentTheme)
{
case Light: GRID_MEASURE_DIVIDER_COLOR_LIGHT;
case Dark: GRID_MEASURE_DIVIDER_COLOR_DARK;
default: GRID_MEASURE_DIVIDER_COLOR_LIGHT;
};
// Divider at top
state.gridBitmap.fillRect(new Rectangle(0, 0, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor);
// Divider at bottom
var dividerLineBY = state.gridBitmap.height - (GRID_MEASURE_DIVIDER_WIDTH / 2);
state.gridBitmap.fillRect(new Rectangle(0, dividerLineBY, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor);
// Draw dividers between the strumlines. // Draw dividers between the strumlines.
var gridStrumlineDividerColor:FlxColor = switch (state.currentTheme) var gridStrumlineDividerColor:FlxColor = switch (state.currentTheme)
@ -121,20 +187,10 @@ class ChartEditorThemeHandler
var dividerLineBX = ChartEditorState.GRID_SIZE * (ChartEditorState.STRUMLINE_SIZE * 2) - (GRID_STRUMLINE_DIVIDER_WIDTH / 2); var dividerLineBX = ChartEditorState.GRID_SIZE * (ChartEditorState.STRUMLINE_SIZE * 2) - (GRID_STRUMLINE_DIVIDER_WIDTH / 2);
state.gridBitmap.fillRect(new Rectangle(dividerLineBX, 0, GRID_STRUMLINE_DIVIDER_WIDTH, state.gridBitmap.height), gridStrumlineDividerColor); state.gridBitmap.fillRect(new Rectangle(dividerLineBX, 0, GRID_STRUMLINE_DIVIDER_WIDTH, state.gridBitmap.height), gridStrumlineDividerColor);
// Draw dividers between the measures. if (state.gridTiledSprite != null) {
state.gridTiledSprite.loadGraphic(state.gridBitmap);
var gridMeasureDividerColor:FlxColor = switch (state.currentTheme) }
{ // Else, gridTiledSprite will be built later.
case Light: GRID_MEASURE_DIVIDER_COLOR_LIGHT;
case Dark: GRID_MEASURE_DIVIDER_COLOR_DARK;
default: GRID_MEASURE_DIVIDER_COLOR_LIGHT;
};
// Divider at top
state.gridBitmap.fillRect(new Rectangle(0, 0, state.gridBitmap.width, GRID_MEASURE_DIVIDER_WIDTH / 2), gridMeasureDividerColor);
// Divider at bottom
var dividerLineBY = state.gridBitmap.height - (GRID_MEASURE_DIVIDER_WIDTH / 2);
state.gridBitmap.fillRect(new Rectangle(0, dividerLineBY, GRID_MEASURE_DIVIDER_WIDTH / 2, state.gridBitmap.height), gridMeasureDividerColor);
} }
static function updateSelectionSquare(state:ChartEditorState):Void static function updateSelectionSquare(state:ChartEditorState):Void
@ -169,4 +225,20 @@ class ChartEditorThemeHandler
- (2 * SELECTION_SQUARE_BORDER_WIDTH + 8)), - (2 * SELECTION_SQUARE_BORDER_WIDTH + 8)),
32, 32); 32, 32);
} }
public static function buildPlayheadBlock():FlxSprite
{
var playheadBlock:FlxSprite = new FlxSprite();
var playheadBlockBitmap:BitmapData = new BitmapData(ChartEditorState.PLAYHEAD_SCROLL_AREA_WIDTH, ChartEditorState.PLAYHEAD_HEIGHT * 2, true);
playheadBlockBitmap.fillRect(new Rectangle(0, 0, ChartEditorState.PLAYHEAD_SCROLL_AREA_WIDTH, ChartEditorState.PLAYHEAD_HEIGHT * 2),
PLAYHEAD_BLOCK_BORDER_COLOR);
playheadBlockBitmap.fillRect(new Rectangle(PLAYHEAD_BLOCK_BORDER_WIDTH, PLAYHEAD_BLOCK_BORDER_WIDTH,
ChartEditorState.PLAYHEAD_SCROLL_AREA_WIDTH - (2 * PLAYHEAD_BLOCK_BORDER_WIDTH),
ChartEditorState.PLAYHEAD_HEIGHT * 2 - (2 * PLAYHEAD_BLOCK_BORDER_WIDTH)),
PLAYHEAD_BLOCK_FILL_COLOR);
return playheadBlock.loadGraphic(playheadBlockBitmap);
}
} }

View file

@ -1,5 +1,14 @@
package funkin.ui.debug.charting; package funkin.ui.debug.charting;
import funkin.play.song.SongData.SongTimeChange;
import haxe.ui.components.Slider;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.TextField;
import funkin.play.character.BaseCharacter.CharacterType;
import funkin.ui.haxeui.components.CharacterPlayer;
import funkin.play.song.SongSerializer;
import haxe.ui.components.Button;
import haxe.ui.components.DropDown; import haxe.ui.components.DropDown;
import haxe.ui.containers.Group; import haxe.ui.containers.Group;
import haxe.ui.containers.dialogs.Dialog; import haxe.ui.containers.dialogs.Dialog;
@ -77,33 +86,58 @@ class ChartEditorToolboxHandler
toolbox = buildToolboxNoteDataLayout(state); toolbox = buildToolboxNoteDataLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT: case ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:
toolbox = buildToolboxEventDataLayout(state); toolbox = buildToolboxEventDataLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_SONGDATA_LAYOUT: case ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:
toolbox = buildToolboxSongDataLayout(state); toolbox = buildToolboxDifficultyLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
toolbox = buildToolboxMetadataLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
toolbox = buildToolboxCharactersLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
toolbox = buildToolboxPlayerPreviewLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
toolbox = buildToolboxOpponentPreviewLayout(state);
default: default:
trace('ChartEditorToolboxHandler.initToolbox() - Unknown toolbox ID: $id'); trace('ChartEditorToolboxHandler.initToolbox() - Unknown toolbox ID: $id');
toolbox = null; toolbox = null;
} }
// Make sure we can reuse the toolbox later.
toolbox.destroyOnClose = false;
state.activeToolboxes.set(id, toolbox); state.activeToolboxes.set(id, toolbox);
return toolbox; return toolbox;
} }
public static function getToolbox(state:ChartEditorState, id:String):Dialog
{
var toolbox:Dialog = state.activeToolboxes.get(id);
// Initialize the toolbox without showing it.
if (toolbox == null)
toolbox = initToolbox(state, id);
return toolbox;
}
static function buildToolboxToolsLayout(state:ChartEditorState):Dialog static function buildToolboxToolsLayout(state:ChartEditorState):Dialog
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT);
if (toolbox == null) return null;
// Starting position. // Starting position.
toolbox.x = 50; toolbox.x = 50;
toolbox.y = 50; toolbox.y = 50;
toolbox.onDialogClosed = (event:DialogEvent) -> toolbox.onDialogClosed = (event:DialogEvent) ->
{ {
state.setUISelected('menubarItemToggleToolboxTools', false); state.setUICheckboxSelected('menubarItemToggleToolboxTools', false);
} }
var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group); var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group);
if (toolsGroup == null) return null;
toolsGroup.onChange = (event:UIEvent) -> toolsGroup.onChange = (event:UIEvent) ->
{ {
switch (event.target.id) switch (event.target.id)
@ -124,13 +158,15 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT);
if (toolbox == null) return null;
// Starting position. // Starting position.
toolbox.x = 75; toolbox.x = 75;
toolbox.y = 100; toolbox.y = 100;
toolbox.onDialogClosed = (event:DialogEvent) -> toolbox.onDialogClosed = (event:DialogEvent) ->
{ {
state.setUISelected('menubarItemToggleToolboxNotes', false); state.setUICheckboxSelected('menubarItemToggleToolboxNotes', false);
} }
var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown); var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown);
@ -147,38 +183,251 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT);
if (toolbox == null) return null;
// Starting position. // Starting position.
toolbox.x = 100; toolbox.x = 100;
toolbox.y = 150; toolbox.y = 150;
toolbox.onDialogClosed = (event:DialogEvent) -> toolbox.onDialogClosed = (event:DialogEvent) ->
{ {
state.setUISelected('menubarItemToggleToolboxEvents', false); state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false);
} }
return toolbox; return toolbox;
} }
static function buildToolboxSongDataLayout(state:ChartEditorState):Dialog static function buildToolboxDifficultyLayout(state:ChartEditorState):Dialog
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_SONGDATA_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
if (toolbox == null) return null;
// Starting position. // Starting position.
toolbox.x = 950; toolbox.x = 125;
toolbox.y = 50; toolbox.y = 200;
toolbox.onDialogClosed = (event:DialogEvent) -> toolbox.onDialogClosed = (event:DialogEvent) ->
{ {
state.setUISelected('menubarItemToggleToolboxSong', false); state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false);
}
var difficultyToolboxSaveMetadata:Button = toolbox.findComponent("difficultyToolboxSaveMetadata", Button);
var difficultyToolboxSaveChart:Button = toolbox.findComponent("difficultyToolboxSaveChart", Button);
var difficultyToolboxSaveAll:Button = toolbox.findComponent("difficultyToolboxSaveAll", Button);
var difficultyToolboxLoadMetadata:Button = toolbox.findComponent("difficultyToolboxLoadMetadata", Button);
var difficultyToolboxLoadChart:Button = toolbox.findComponent("difficultyToolboxLoadChart", Button);
difficultyToolboxSaveMetadata.onClick = (event:UIEvent) ->
{
SongSerializer.exportSongMetadata(state.currentSongMetadata);
};
difficultyToolboxSaveChart.onClick = (event:UIEvent) ->
{
SongSerializer.exportSongChartData(state.currentSongChartData);
};
difficultyToolboxSaveAll.onClick = (event:UIEvent) ->
{
state.exportAllSongData();
};
difficultyToolboxLoadMetadata.onClick = (event:UIEvent) ->
{
// Replace metadata for current variation.
SongSerializer.importSongMetadataAsync(function(songMetadata)
{
state.currentSongMetadata = songMetadata;
});
};
difficultyToolboxLoadChart.onClick = (event:UIEvent) ->
{
// Replace chart data for current variation.
SongSerializer.importSongChartDataAsync(function(songChartData)
{
state.currentSongChartData = songChartData;
state.noteDisplayDirty = true;
});
};
state.difficultySelectDirty = true;
return toolbox;
}
static function buildToolboxMetadataLayout(state:ChartEditorState):Dialog
{
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
if (toolbox == null) return null;
// Starting position.
toolbox.x = 150;
toolbox.y = 250;
toolbox.onDialogClosed = (event:DialogEvent) ->
{
state.setUICheckboxSelected('menubarItemToggleToolboxMetadata', false);
}
var inputSongName:TextField = toolbox.findComponent('inputSongName', TextField);
inputSongName.onChange = (event:UIEvent) ->
{
var valid = event.target.text != null && event.target.text != "";
if (valid)
{
inputSongName.removeClass('invalid-value');
state.currentSongMetadata.songName = event.target.text;
}
else
{
state.currentSongMetadata.songName = null;
}
};
var inputSongArtist:TextField = toolbox.findComponent('inputSongArtist', TextField);
inputSongArtist.onChange = (event:UIEvent) ->
{
var valid = event.target.text != null && event.target.text != "";
if (valid)
{
inputSongArtist.removeClass('invalid-value');
state.currentSongMetadata.artist = event.target.text;
}
else
{
state.currentSongMetadata.artist = null;
}
};
var inputStage:DropDown = toolbox.findComponent('inputStage', DropDown);
inputStage.onChange = (event:UIEvent) ->
{
var valid = event.data != null && event.data.id != null;
if (valid) {
state.currentSongMetadata.playData.stage = event.data.id;
}
};
var inputNoteSkin:DropDown = toolbox.findComponent('inputNoteSkin', DropDown);
inputNoteSkin.onChange = (event:UIEvent) ->
{
if (event.data.id == null)
return;
state.currentSongMetadata.playData.noteSkin = event.data.id;
};
var inputBPM:NumberStepper = toolbox.findComponent('inputBPM', NumberStepper);
inputBPM.onChange = (event:UIEvent) ->
{
if (event.value == null || event.value <= 0)
return;
var timeChanges = state.currentSongMetadata.timeChanges;
if (timeChanges == null || timeChanges.length == 0)
{
timeChanges = [new SongTimeChange(-1, 0, event.value, 4, 4, [4, 4, 4, 4])];
}
else
{
timeChanges[0].bpm = event.value;
}
Conductor.forceBPM(event.value);
state.currentSongMetadata.timeChanges = timeChanges;
};
var inputScrollSpeed:Slider = toolbox.findComponent('inputScrollSpeed', Slider);
inputScrollSpeed.onChange = (event:UIEvent) ->
{
var valid = event.target.value != null && event.target.value > 0;
if (valid)
{
inputScrollSpeed.removeClass('invalid-value');
state.currentSongChartData.scrollSpeed = event.target.value;
}
else
{
state.currentSongChartData.scrollSpeed = null;
}
};
return toolbox;
}
static function buildToolboxCharactersLayout(state:ChartEditorState):Dialog
{
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT);
if (toolbox == null) return null;
// Starting position.
toolbox.x = 175;
toolbox.y = 300;
toolbox.onDialogClosed = (event:DialogEvent) ->
{
state.setUICheckboxSelected('menubarItemToggleToolboxCharacters', false);
} }
return toolbox; return toolbox;
} }
static function buildDialog(state:ChartEditorState, id:String):Dialog static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Dialog
{ {
var dialog:Dialog = cast state.buildComponent(id); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
dialog.destroyOnClose = false;
return dialog; if (toolbox == null) return null;
// Starting position.
toolbox.x = 200;
toolbox.y = 350;
toolbox.onDialogClosed = (event:DialogEvent) ->
{
state.setUICheckboxSelected('menubarItemToggleToolboxPlayerPreview', false);
}
var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer');
// TODO: We need to implement character swapping in ChartEditorState.
charPlayer.loadCharacter('bf');
//charPlayer.setScale(0.5);
charPlayer.setCharacterType(CharacterType.BF);
charPlayer.flip = true;
return toolbox;
}
static function buildToolboxOpponentPreviewLayout(state:ChartEditorState):Dialog
{
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
if (toolbox == null) return null;
// Starting position.
toolbox.x = 200;
toolbox.y = 350;
toolbox.onDialogClosed = (event:DialogEvent) ->
{
state.setUICheckboxSelected('menubarItemToggleToolboxOpponentPreview', false);
}
var charPlayer:CharacterPlayer = toolbox.findComponent('charPlayer');
// TODO: We need to implement character swapping in ChartEditorState.
charPlayer.loadCharacter('dad');
// charPlayer.setScale(0.5);
charPlayer.setCharacterType(CharacterType.DAD);
charPlayer.flip = false;
return toolbox;
} }
} }

View file

@ -1,5 +1,10 @@
package funkin.ui.haxeui; package funkin.ui.haxeui;
import haxe.ui.containers.menus.MenuCheckBox;
import haxe.ui.components.CheckBox;
import haxe.ui.events.DragEvent;
import haxe.ui.events.MouseEvent;
import haxe.ui.events.UIEvent;
import haxe.ui.RuntimeComponentBuilder; import haxe.ui.RuntimeComponentBuilder;
import haxe.ui.core.Component; import haxe.ui.core.Component;
import haxe.ui.core.Screen; import haxe.ui.core.Screen;
@ -93,6 +98,83 @@ class HaxeUIState extends MusicBeatState
} }
} }
/**
* Add an onClick listener to a HaxeUI menu bar item.
*/
function addUIClickListener(key:String, callback:MouseEvent->Void)
{
var target:Component = findComponent(key);
if (target == null)
{
// Gracefully handle the case where the item can't be located.
trace('WARN: Could not locate menu item: $key');
}
else
{
target.onClick = callback;
}
}
/**
* Add an onChange listener to a HaxeUI input component such as a slider or text field.
*/
function addUIChangeListener(key:String, callback:UIEvent->Void)
{
var target:Component = findComponent(key);
if (target == null)
{
// Gracefully handle the case where the item can't be located.
trace('WARN: Could not locate menu item: $key');
}
else
{
target.onChange = callback;
}
}
/**
* Set the value of a HaxeUI component.
* Usually modifies the text of a label or value of a text field.
*/
function setUIValue<T>(key:String, value:T):T
{
var target:Component = findComponent(key);
if (target == null)
{
// Gracefully handle the case where the item can't be located.
trace('WARN: Could not locate menu item: $key');
return value;
}
else
{
return target.value = value;
}
}
/**
* Set the value of a HaxeUI checkbox,
* since that's on 'selected' instead of 'value'.
*/
public function setUICheckboxSelected<T>(key:String, value:Bool):Bool
{
var targetA:CheckBox = findComponent(key, CheckBox);
if (targetA != null)
{
return targetA.selected = value;
}
var targetB:MenuCheckBox = findComponent(key, MenuCheckBox);
if (targetB != null)
{
return targetB.selected = value;
}
// Gracefully handle the case where the item can't be located.
trace('WARN: Could not locate check box: $key');
return value;
}
public function findComponent<T:Component>(criteria:String = null, type:Class<T> = null, recursive:Null<Bool> = null, searchType:String = "id"):Null<T> public function findComponent<T:Component>(criteria:String = null, type:Class<T> = null, recursive:Null<Bool> = null, searchType:String = "id"):Null<T>
{ {
if (component == null) if (component == null)

View file

@ -0,0 +1,276 @@
package funkin.ui.haxeui.components;
import flixel.FlxSprite;
import flixel.graphics.frames.FlxAtlasFrames;
import flixel.graphics.frames.FlxFramesCollection;
import flixel.math.FlxRect;
import funkin.modding.events.ScriptEvent;
import funkin.modding.IScriptedClass.IPlayStateScriptedClass;
import funkin.play.character.BaseCharacter;
import funkin.play.character.CharacterData.CharacterDataParser;
import haxe.ui.containers.Box;
import haxe.ui.core.Component;
import haxe.ui.core.IDataComponent;
import haxe.ui.data.DataSource;
import haxe.ui.events.AnimationEvent;
import haxe.ui.events.UIEvent;
import haxe.ui.geom.Size;
import haxe.ui.layouts.DefaultLayout;
import haxe.ui.styles.Style;
import openfl.Assets;
private typedef AnimationInfo =
{
var name:String;
var prefix:String;
var frameRate:Null<Int>; // default 30
var looped:Null<Bool>; // default true
var flipX:Null<Bool>; // default false
var flipY:Null<Bool>; // default false
}
@:composite(Layout)
class CharacterPlayer extends Box
{
private var character:BaseCharacter;
public function new(?defaultToBf:Bool = true)
{
super();
this._overrideSkipTransformChildren = false;
if (defaultToBf)
{
loadCharacter('bf');
}
}
private var _charId:String;
public var charId(get, set):String;
private function get_charId():String
{
return _charId;
}
private function set_charId(value:String):String
{
_charId = value;
loadCharacter(_charId);
return value;
}
private var _redispatchLoaded:Bool = false; // possible haxeui bug: if listener is added after event is dispatched, event is "lost"... needs thinking about, is it smart to "collect and redispatch"? Not sure
private var _redispatchStart:Bool = false; // possible haxeui bug: if listener is added after event is dispatched, event is "lost"... needs thinking about, is it smart to "collect and redispatch"? Not sure
public override function onReady()
{
super.onReady();
invalidateComponentLayout();
if (_redispatchLoaded)
{
_redispatchLoaded = false;
dispatch(new AnimationEvent(AnimationEvent.LOADED));
}
if (_redispatchStart)
{
_redispatchStart = false;
dispatch(new AnimationEvent(AnimationEvent.START));
}
parentComponent._overrideSkipTransformChildren = false;
}
public function loadCharacter(id:String)
{
if (id == null)
{
return;
}
if (character != null)
{
remove(character);
character.destroy();
character = null;
}
var newCharacter:BaseCharacter = CharacterDataParser.fetchCharacter(id);
if (newCharacter == null)
{
return;
}
character = newCharacter;
if (_characterType != null)
{
character.characterType = _characterType;
}
if (flip)
{
character.flipX = !character.flipX;
}
character.scale.x *= _scale;
character.scale.y *= _scale;
character.animation.callback = function(name:String = "", frameNumber:Int = -1, frameIndex:Int = -1)
{
@:privateAccess
character.onAnimationFrame(name, frameNumber, frameIndex);
dispatch(new AnimationEvent(AnimationEvent.FRAME));
};
character.animation.finishCallback = function(name:String = "")
{
@:privateAccess
character.onAnimationFinished(name);
dispatch(new AnimationEvent(AnimationEvent.END));
};
add(character);
invalidateComponentLayout();
if (hasEvent(AnimationEvent.LOADED))
{
dispatch(new AnimationEvent(AnimationEvent.LOADED));
}
else
{
_redispatchLoaded = true;
}
}
private override function repositionChildren()
{
super.repositionChildren();
@:privateAccess
var animOffsets = character.animOffsets;
character.x = this.screenX + ((this.width / 2) - (character.frameWidth / 2));
character.x -= animOffsets[0];
character.y = this.screenY + ((this.height / 2) - (character.frameHeight / 2));
character.y -= animOffsets[1];
}
private var _characterType:CharacterType;
public function setCharacterType(value:CharacterType)
{
_characterType = value;
if (character != null)
{
character.characterType = value;
}
}
public var flip(default, set):Bool;
private function set_flip(value:Bool):Bool
{
if (value == flip)
return value;
if (character != null)
{
character.flipX = !character.flipX;
}
return flip = value;
}
var _scale:Float = 1.0;
public function setScale(value)
{
_scale = value;
if (character != null)
{
character.scale.x *= _scale;
character.scale.y *= _scale;
}
}
public function onUpdate(event:UpdateScriptEvent)
{
if (character != null)
character.onUpdate(event);
}
public function onBeatHit(event:SongTimeScriptEvent):Void
{
if (character != null)
character.onBeatHit(event);
this.repositionChildren();
}
public function onStepHit(event:SongTimeScriptEvent):Void
{
if (character != null)
character.onStepHit(event);
}
public function onNoteHit(event:NoteScriptEvent):Void
{
if (character != null)
character.onNoteHit(event);
this.repositionChildren();
}
public function onNoteMiss(event:NoteScriptEvent):Void
{
if (character != null)
character.onNoteMiss(event);
this.repositionChildren();
}
public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void
{
if (character != null)
character.onNoteGhostMiss(event);
this.repositionChildren();
}
}
@:access(funkin.ui.haxeui.components.CharacterPlayer)
private class Layout extends DefaultLayout
{
public override function repositionChildren()
{
var player = cast(_component, CharacterPlayer);
var sprite:BaseCharacter = player.character;
if (sprite == null)
{
return super.repositionChildren();
}
@:privateAccess
var animOffsets = sprite.animOffsets;
sprite.x = _component.screenLeft + ((_component.width / 2) - (sprite.frameWidth / 2));
sprite.x += animOffsets[0];
sprite.y = _component.screenTop + ((_component.height / 2) - (sprite.frameHeight / 2));
sprite.y += animOffsets[1];
}
public override function calcAutoSize(exclusions:Array<Component> = null):Size
{
var player = cast(_component, CharacterPlayer);
var sprite = player.character;
if (sprite == null)
{
return super.calcAutoSize(exclusions);
}
var size = new Size();
size.width = sprite.frameWidth + paddingLeft + paddingRight;
size.height = sprite.frameHeight + paddingTop + paddingBottom;
return size;
}
}

View file

@ -0,0 +1,11 @@
package funkin.util;
class DateUtil
{
public static function generateTimestamp():String
{
var date = Date.now();
return
'${date.getFullYear()}-${Std.string(date.getMonth() + 1).lpad('0', 2)}-${Std.string(date.getDate()).lpad('0', 2)}-${Std.string(date.getHours()).lpad('0', 2)}-${Std.string(date.getMinutes()).lpad('0', 2)}-${Std.string(date.getSeconds()).lpad('0', 2)}';
}
}

View file

@ -0,0 +1,524 @@
package funkin.util;
import cpp.abi.Abi;
import haxe.zip.Entry;
import lime.utils.Bytes;
import lime.ui.FileDialog;
import openfl.net.FileFilter;
import haxe.io.Path;
import lime.utils.Resource;
/**
* Utilities for reading and writing files on various platforms.
*/
class FileUtil
{
/**
* Browses for a single file, then calls `onSelect(path)` when a path chosen.
* Note that on HTML5 this will immediately fail, you should call `openFile(onOpen:Resource->Void)` instead.
*
* @param typeFilter Filters what kinds of files can be selected.
* @return Whether the file dialog was opened successfully.
*/
public static function browseForFile(?typeFilter:Array<FileFilter>, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String,
?dialogTitle:String):Bool
{
#if desktop
var filter = convertTypeFilter(typeFilter);
var fileDialog = new FileDialog();
if (onSelect != null)
fileDialog.onSelect.add(onSelect);
if (onCancel != null)
fileDialog.onCancel.add(onCancel);
fileDialog.browse(OPEN, filter, defaultPath, dialogTitle);
return true;
#elseif html5
onCancel();
return false;
#else
onCancel();
return false;
#end
}
/**
* Browses for a directory, then calls `onSelect(path)` when a path chosen.
* Note that on HTML5 this will immediately fail.
*
* @param typeFilter TODO What does this do?
* @return Whether the file dialog was opened successfully.
*/
public static function browseForDirectory(?typeFilter:Array<FileFilter>, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String,
?dialogTitle:String):Bool
{
#if desktop
var filter = convertTypeFilter(typeFilter);
var fileDialog = new FileDialog();
if (onSelect != null)
fileDialog.onSelect.add(onSelect);
if (onCancel != null)
fileDialog.onCancel.add(onCancel);
fileDialog.browse(OPEN_DIRECTORY, filter, defaultPath, dialogTitle);
return true;
#elseif html5
onCancel();
return false;
#else
onCancel();
return false;
#end
}
/**
* Browses for multiple file, then calls `onSelect(paths)` when a path chosen.
* Note that on HTML5 this will immediately fail.
*
* @return Whether the file dialog was opened successfully.
*/
public static function browseForMultipleFiles(?typeFilter:Array<FileFilter>, ?onSelect:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String,
?dialogTitle:String):Bool
{
#if desktop
var filter = convertTypeFilter(typeFilter);
var fileDialog = new FileDialog();
if (onSelect != null)
fileDialog.onSelectMultiple.add(onSelect);
if (onCancel != null)
fileDialog.onCancel.add(onCancel);
fileDialog.browse(OPEN_MULTIPLE, filter, defaultPath, dialogTitle);
return true;
#elseif html5
onCancel();
return false;
#else
onCancel();
return false;
#end
}
/**
* Browses for a file location to save to, then calls `onSelect(path)` when a path chosen.
* Note that on HTML5 you can't do much with this, you should call `saveFile(resource:haxe.io.Bytes)` instead.
*
* @param typeFilter TODO What does this do?
* @return Whether the file dialog was opened successfully.
*/
public static function browseForSaveFile(?typeFilter:Array<FileFilter>, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String,
?dialogTitle:String):Bool
{
#if desktop
var filter = convertTypeFilter(typeFilter);
var fileDialog = new FileDialog();
if (onSelect != null)
fileDialog.onSelect.add(onSelect);
if (onCancel != null)
fileDialog.onCancel.add(onCancel);
fileDialog.browse(SAVE, filter, defaultPath, dialogTitle);
return true;
#elseif html5
onCancel();
return false;
#else
onCancel();
return false;
#end
}
/**
* Browses for a single file location, then reads it and passes it to `onOpen(resource:haxe.io.Bytes)`.
* Works great on desktop and HTML5.
*
* @param typeFilter TODO What does this do?
* @return Whether the file dialog was opened successfully.
*/
public static function openFile(?typeFilter:Array<FileFilter>, ?onOpen:Bytes->Void, ?onCancel:Void->Void, ?defaultPath:String, ?dialogTitle:String):Bool
{
#if desktop
var filter = convertTypeFilter(typeFilter);
var fileDialog = new FileDialog();
if (onOpen != null)
fileDialog.onOpen.add(onOpen);
if (onCancel != null)
fileDialog.onCancel.add(onCancel);
fileDialog.open(filter, defaultPath, dialogTitle);
return true;
#elseif html5
var filter = convertTypeFilter(typeFilter);
var onFileLoaded = function(event)
{
var loadedFileRef:FileReference = event.target;
trace('Loaded file: ' + loadedFileRef.name);
onOpen(loadedFileRef.data);
}
var onFileSelected = function(event)
{
var selectedFileRef:FileReference = event.target;
trace('Selected file: ' + selectedFileRef.name);
selectedFileRef.addEventListener(Event.COMPLETE, onFileLoaded);
selectedFileRef.load();
}
var fileRef = new FileReference();
file.addEventListener(Event.SELECT, onFileSelected);
file.open(filter, defaultPath, dialogTitle);
#else
onCancel();
return false;
#end
}
/**
* Browses for a single file location, then writes the provided `haxe.io.Bytes` data and calls `onSave(path)` when done.
* Works great on desktop and HTML5.
*
* @param typeFilter TODO What does this do?
* @return Whether the file dialog was opened successfully.
*/
public static function saveFile(data:Bytes, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String, ?dialogTitle:String):Bool
{
#if desktop
var filter = defaultFileName != null ? Path.extension(defaultFileName) : null;
var fileDialog = new FileDialog();
if (onSave != null)
fileDialog.onSelect.add(onSave);
if (onCancel != null)
fileDialog.onCancel.add(onCancel);
fileDialog.save(data, filter, defaultFileName, dialogTitle);
return true;
#elseif html5
var filter = defaultFileName != null ? Path.extension(defaultFileName) : null;
var fileDialog = new FileDialog();
if (onSave != null)
fileDialog.onSave.add(onSave);
if (onCancel != null)
fileDialog.onCancel.add(onCancel);
fileDialog.save(data, filter, defaultFileName, dialogTitle);
#else
onCancel();
return false;
#end
}
/**
* Prompts the user to save multiple files.
* On desktop, this will prompt the user for a directory, then write all of the files to there.
* On HTML5, this will zip the files up and prompt the user to save that.
*
* @param typeFilter TODO What does this do?
* @return Whether the file dialog was opened successfully.
*/
public static function saveMultipleFiles(resources:Array<Entry>, ?onSaveAll:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String,
?force:Bool = false):Bool
{
#if desktop
// Prompt the user for a directory, then write all of the files to there.
var onSelectDir = function(targetPath:String)
{
var paths:Array<String> = [];
for (resource in resources)
{
var filePath = haxe.io.Path.join([targetPath, resource.fileName]);
try
{
if (resource.data == null)
{
trace('WARNING: File $filePath has no data or content. Skipping.');
continue;
}
else
{
writeBytesToPath(filePath, resource.data, force ? Force : Skip);
}
}
catch (e:Dynamic)
{
trace('Failed to write file (probably already exists): $filePath' + filePath);
continue;
}
paths.push(filePath);
}
onSaveAll(paths);
}
browseForDirectory(null, onSelectDir, onCancel, defaultPath, "Choose directory to save all files to...");
return true;
#elseif html5
saveFilesAsZIP(resources, onSaveAll, onCancel, defaultPath, force);
return true;
#else
onCancel();
return false;
#end
}
/**
* Takes an array of file entries and prompts the user to save them as a ZIP file.
*/
public static function saveFilesAsZIP(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String,
?force:Bool = false):Bool
{
// Create a ZIP file.
var zipBytes = createZIPFromEntries(resources);
var onSave = function(path:String)
{
onSave([path]);
};
// Prompt the user to save the ZIP file.
saveFile(zipBytes, onSave, onCancel, defaultPath, "Save files as ZIP...");
return true;
}
/**
* Takes an array of file entries and forcibly writes a ZIP to the given path.
* Only works on desktop, because HTML5 doesn't allow you to write files to arbitrary paths.
* Use `saveFilesAsZIP` instead.
* @param force Whether to force overwrite an existing file.
*/
public static function saveFilesAsZIPToPath(resources:Array<Entry>, path:String, ?force:Bool = false):Bool
{
#if desktop
// Create a ZIP file.
var zipBytes = createZIPFromEntries(resources);
// Write the ZIP.
writeBytesToPath(path, zipBytes, force ? Force : Skip);
return true;
#else
return false;
#end
}
/**
* Write string file contents directly to a given path.
* Only works on desktop.
*
* @param mode Whether to Force, Skip, or Ask to overwrite an existing file.
*/
public static function writeStringToPath(path:String, data:String, mode:FileWriteMode = Skip)
{
#if sys
createDirIfNotExists(Path.directory(path));
switch (mode)
{
case Force:
sys.io.File.saveContent(path, data);
case Skip:
if (!sys.FileSystem.exists(path))
{
sys.io.File.saveContent(path, data);
}
else
{
throw 'File already exists: $path';
}
case Ask:
if (sys.FileSystem.exists(path))
{
// TODO: We don't have the technology to use native popups yet.
}
else
{
sys.io.File.saveContent(path, data);
}
}
#else
throw 'Direct file writing by path not supported on this platform.';
#end
}
/**
* Write byte file contents directly to a given path.
* Only works on desktop.
*
* @param mode Whether to Force, Skip, or Ask to overwrite an existing file.
*/
public static function writeBytesToPath(path:String, data:Bytes, mode:FileWriteMode = Skip)
{
#if sys
createDirIfNotExists(Path.directory(path));
switch (mode)
{
case Force:
sys.io.File.saveBytes(path, data);
case Skip:
if (!sys.FileSystem.exists(path))
{
sys.io.File.saveBytes(path, data);
}
else
{
throw 'File already exists: $path';
}
case Ask:
if (sys.FileSystem.exists(path))
{
// TODO: We don't have the technology to use native popups yet.
}
else
{
sys.io.File.saveBytes(path, data);
}
}
#else
throw 'Direct file writing by path not supported on this platform.';
#end
}
/**
* Write string file contents directly to the end of a file at the given path.
* Only works on desktop.
*/
public static function appendStringToPath(path:String, data:String)
{
sys.io.File.append(path, false).writeString(data);
}
/**
* Create a directory if it doesn't already exist.
* Only works on desktop.
*/
public static function createDirIfNotExists(dir:String)
{
#if sys
if (!sys.FileSystem.exists(dir))
{
sys.FileSystem.createDirectory(dir);
}
#end
}
static var tempDir:String = null;
static final TEMP_ENV_VARS:Array<String> = ['TEMP', 'TMPDIR', 'TEMPDIR', 'TMP'];
/**
* Get the path to a temporary directory we can use for writing files.
* Only works on desktop.
*/
public static function getTempDir():String
{
if (tempDir != null)
return tempDir;
#if sys
#if windows
var path:String = null;
for (envName in TEMP_ENV_VARS)
{
path = Sys.getEnv(envName);
if (path == "")
path = null;
if (path != null)
break;
}
return tempDir = Path.join([path, 'funkin/']);
#else
return tempDir = '/tmp/funkin/';
#end
#else
return null;
#end
}
/**
* Create a Bytes object containing a ZIP file, containing the provided entries.
*
* @param entries The entries to add to the ZIP file.
* @return The ZIP file as a Bytes object.
*/
public static function createZIPFromEntries(entries:Array<Entry>):Bytes
{
var o = new haxe.io.BytesOutput();
var zipWriter = new haxe.zip.Writer(o);
zipWriter.write(entries.list());
return o.getBytes();
}
/**
* Create a ZIP file entry from a file name and its string contents.
*
* @param name The name of the file. You can use slashes to create subdirectories.
* @param content The string contents of the file.
* @return The resulting entry.
*/
public static function makeZIPEntry(name:String, content:String):Entry
{
var data = haxe.io.Bytes.ofString(content, UTF8);
return {
fileName: name,
fileSize: data.length,
data: data,
dataSize: data.length,
compressed: false,
fileTime: Date.now(),
crc32: null,
extraFields: null,
};
}
static function convertTypeFilter(typeFilter:Array<FileFilter>):String
{
var filter = null;
if (typeFilter != null)
{
var filters = [];
for (type in typeFilter)
{
filters.push(StringTools.replace(StringTools.replace(type.extension, "*.", ""), ";", ","));
}
filter = filters.join(";");
}
return filter;
}
}
enum FileWriteMode
{
/**
* Forcibly overwrite the file if it already exists.
*/
Force;
/**
* Ask the user if they want to overwrite the file if it already exists.
*/
Ask;
/**
* Skip the file if it already exists.
*/
Skip;
}

View file

@ -0,0 +1,2 @@
package funkin.util;

View file

@ -1,5 +1,7 @@
package funkin.util; package funkin.util;
import flixel.util.FlxSignal.FlxTypedSignal;
class WindowUtil class WindowUtil
{ {
public static function openURL(targetUrl:String) public static function openURL(targetUrl:String)
@ -12,7 +14,23 @@ class WindowUtil
FlxG.openURL(targetUrl); FlxG.openURL(targetUrl);
#end #end
#else #else
trace('Cannot open') trace('Cannot open');
#end #end
} }
/**
* Dispatched when the game window is closed.
*/
public static final windowExit:FlxTypedSignal<Int->Void> = new FlxTypedSignal<Int->Void>();
public static function initWindowEvents()
{
// onUpdate is called every frame just before rendering.
// onExit is called when the game window is closed.
openfl.Lib.current.stage.application.onExit.add(function(exitCode:Int)
{
windowExit.dispatch(exitCode);
});
}
} }

View file

@ -1,7 +1,5 @@
package funkin.util.assets; package funkin.util.assets;
using StringTools;
class DataAssets class DataAssets
{ {
static function buildDataPath(path:String):String static function buildDataPath(path:String):String

View file

@ -1,4 +1,4 @@
package funkin.util; package funkin.util.tools;
/** /**
* A static extension which provides utility functions for Iterators. * A static extension which provides utility functions for Iterators.

View file

@ -0,0 +1,59 @@
package funkin.util.tools;
/**
* A static extension which provides utility functions for Strings.
*/
class StringTools
{
/**
* Converts a string to title case. For example, "hello world" becomes "Hello World".
*
* @param value The string to convert.
* @return The converted string.
*/
public static function toTitleCase(value:String):String
{
var words:Array<String> = value.split(" ");
var result:String = "";
for (i in 0...words.length)
{
var word:String = words[i];
result += word.charAt(0).toUpperCase() + word.substr(1).toLowerCase();
if (i < words.length - 1)
{
result += " ";
}
}
return result;
}
/**
* Converts a string to lower kebab case. For example, "Hello World" becomes "hello-world".
*
* @param value The string to convert.
* @return The converted string.
*/
public static function toLowerKebabCase(value:String):String {
return value.toLowerCase().replace(' ', "-");
}
/**
* Converts a string to upper kebab case, aka screaming kebab case. For example, "Hello World" becomes "HELLO-WORLD".
*
* @param value The string to convert.
* @return The converted string.
*/
public static function toUpperKebabCase(value:String):String {
return value.toUpperCase().replace(' ', "-");
}
/**
* Parses the string data as JSON and returns the resulting object.
*
* @return The parsed object.
*/
public static function parseJSON(value:String):Dynamic
{
return SerializerUtil.fromJSON(value);
}
}