Merge branch 'master' into feature/split-vocals

This commit is contained in:
EliteMasterEric 2023-05-25 18:39:41 -04:00
commit 2af4a51b15
26 changed files with 1352 additions and 26 deletions

View file

@ -130,6 +130,7 @@
<haxelib name="polymod" /> <!-- Modding framework --> <haxelib name="polymod" /> <!-- Modding framework -->
<haxelib name="flxanimate" /> <!-- Texture atlas rendering --> <haxelib name="flxanimate" /> <!-- Texture atlas rendering -->
<!-- <haxelib name="hxcodec" /> Video playback --> <!-- <haxelib name="hxcodec" /> Video playback -->
<haxelib name="json2object" /> <!-- JSON parsing -->
<haxelib name="thx.semver" /> <haxelib name="thx.semver" />
@ -144,6 +145,9 @@
<!--Enable this for Nape release builds for a serious peformance improvement--> <!--Enable this for Nape release builds for a serious peformance improvement-->
<haxedef name="NAPE_RELEASE_BUILD" unless="debug" /> <haxedef name="NAPE_RELEASE_BUILD" unless="debug" />
<!-- TODO: REMOVE THIS!!!! -->
<haxeflag name="-w" value="-WDeprecated" />
<!-- _________________________________ Custom _______________________________ --> <!-- _________________________________ Custom _______________________________ -->
<!-- Disable trace() calls in release builds to bump up performance. --> <!-- Disable trace() calls in release builds to bump up performance. -->

View file

@ -11,7 +11,7 @@
"name": "flixel", "name": "flixel",
"type": "git", "type": "git",
"dir": null, "dir": null,
"ref": "d6100cc8", "ref": "32cee07",
"url": "https://github.com/EliteMasterEric/flixel" "url": "https://github.com/EliteMasterEric/flixel"
}, },
{ {
@ -42,14 +42,14 @@
"name": "haxeui-core", "name": "haxeui-core",
"type": "git", "type": "git",
"dir": null, "dir": null,
"ref": "59157d2", "ref": "08fbc9d",
"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": "d353389", "ref": "999fadd",
"url": "https://github.com/haxeui/haxeui-flixel" "url": "https://github.com/haxeui/haxeui-flixel"
}, },
{ {
@ -68,13 +68,13 @@
"name": "hxcodec", "name": "hxcodec",
"type": "git", "type": "git",
"dir": null, "dir": null,
"ref": "d74c2aa", "ref": "91adeec",
"url": "https://github.com/polybiusproxy/hxCodec" "url": "https://github.com/polybiusproxy/hxCodec"
}, },
{ {
"name": "hxcpp", "name": "hxcpp",
"type": "haxelib", "type": "haxelib",
"version": "4.2.1" "version": "4.3.2"
}, },
{ {
"name": "hxcpp-debug-server", "name": "hxcpp-debug-server",
@ -84,13 +84,18 @@
{ {
"name": "hxp", "name": "hxp",
"type": "haxelib", "type": "haxelib",
"version": "1.2.2"
},
{
"name": "json2object",
"type": "haxelib",
"version": null "version": null
}, },
{ {
"name": "lime", "name": "lime",
"type": "git", "type": "git",
"dir": null, "dir": null,
"ref": "afadf5f", "ref": "deecd6c",
"url": "https://github.com/openfl/lime" "url": "https://github.com/openfl/lime"
}, },
{ {

View file

@ -207,9 +207,15 @@ class Conductor
} }
// FlxSignals are really cool. // FlxSignals are really cool.
if (currentStep != oldStep) stepHit.dispatch(); if (currentStep != oldStep)
{
stepHit.dispatch();
}
if (currentBeat != oldBeat) beatHit.dispatch(); if (currentBeat != oldBeat)
{
beatHit.dispatch();
}
} }
@:deprecated // Switch to TimeChanges instead. @:deprecated // Switch to TimeChanges instead.

View file

@ -78,6 +78,7 @@ class InitState extends FlxTransitionableState
} }
}); });
#if FLX_DEBUG
FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() { FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() {
FlxG.game.debugger.vcr.onStep(); FlxG.game.debugger.vcr.onStep();
@ -90,6 +91,7 @@ class InitState extends FlxTransitionableState
FlxG.sound.music.pause(); FlxG.sound.music.pause();
FlxG.sound.music.time += FlxG.elapsed * 1000; FlxG.sound.music.time += FlxG.elapsed * 1000;
}); });
#end
FlxG.sound.muteKeys = [ZERO]; FlxG.sound.muteKeys = [ZERO];
FlxG.game.focusLostFramerate = 60; FlxG.game.focusLostFramerate = 60;
@ -153,6 +155,7 @@ class InitState extends FlxTransitionableState
// TODO: Register custom event callbacks here // TODO: Register custom event callbacks here
funkin.data.level.LevelRegistry.instance.loadEntries();
SongEventParser.loadEventCache(); SongEventParser.loadEventCache();
SongDataParser.loadSongCache(); SongDataParser.loadSongCache();
StageDataParser.loadStageCache(); StageDataParser.loadStageCache();

View file

@ -31,7 +31,7 @@ class LatencyState extends MusicBeatSubstate
var offsetsPerBeat:Array<Int> = []; var offsetsPerBeat:Array<Int> = [];
var swagSong:HomemadeMusic; var swagSong:HomemadeMusic;
#if debug #if FLX_DEBUG
var funnyStatsGraph:CoolStatsGraph; var funnyStatsGraph:CoolStatsGraph;
var realStats:CoolStatsGraph; var realStats:CoolStatsGraph;
#end #end
@ -44,7 +44,7 @@ class LatencyState extends MusicBeatSubstate
FlxG.sound.music = swagSong; FlxG.sound.music = swagSong;
FlxG.sound.music.play(); FlxG.sound.music.play();
#if debug #if FLX_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);
@ -170,7 +170,7 @@ class LatencyState extends MusicBeatSubstate
trace(FlxG.sound.music._channel.position); trace(FlxG.sound.music._channel.position);
*/ */
#if debug #if FLX_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 #end

View file

@ -21,6 +21,7 @@ import funkin.shaderslmfao.ScreenWipeShader;
import funkin.ui.AtlasMenuList; import funkin.ui.AtlasMenuList;
import funkin.ui.MenuList.MenuItem; import funkin.ui.MenuList.MenuItem;
import funkin.ui.MenuList; import funkin.ui.MenuList;
import funkin.ui.story.StoryMenuState;
import funkin.ui.OptionsState; import funkin.ui.OptionsState;
import funkin.ui.PreferencesMenu; import funkin.ui.PreferencesMenu;
import funkin.ui.Prompt; import funkin.ui.Prompt;

View file

@ -103,9 +103,9 @@ class Paths
return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT'; return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT';
} }
inline static public function inst(song:String) inline static public function inst(song:String, ?suffix:String)
{ {
return 'songs:assets/songs/${song.toLowerCase()}/Inst.$SOUND_EXT'; return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT';
} }
inline static public function image(key:String, ?library:String) inline static public function image(key:String, ?library:String)

View file

@ -122,10 +122,10 @@ class StoryMenuState extends MusicBeatState
persistentUpdate = persistentDraw = true; persistentUpdate = persistentDraw = true;
scoreText = new FlxText(10, 10, 0, "SCORE: 49324858", 36); scoreText = new FlxText(10, 10, 0, "SCORE: 49324858");
scoreText.setFormat("VCR OSD Mono", 32); scoreText.setFormat("VCR OSD Mono", 32);
txtWeekTitle = new FlxText(FlxG.width * 0.7, 10, 0, "", 32); txtWeekTitle = new FlxText(FlxG.width * 0.7, 10, 0, "");
txtWeekTitle.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT); txtWeekTitle.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT);
txtWeekTitle.alpha = 0.7; txtWeekTitle.alpha = 0.7;

View file

@ -0,0 +1,167 @@
package funkin.data;
import openfl.Assets;
import funkin.util.assets.DataAssets;
import haxe.Constraints.Constructible;
/**
* The entry's constructor function must take a single argument, the entry's ID.
*/
typedef EntryConstructorFunction = String->Void;
/**
* A base type for a Registry, which is an object which handles loading scriptable objects.
*
* @param T The type to construct. Must implement `IRegistryEntry`.
* @param J The type of the JSON data used when constructing.
*/
@:generic
abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructorFunction>), J>
{
public final registryId:String;
final dataFilePath:String;
final entries:Map<String, T>;
// public abstract static final instance:BaseRegistry<T, J> = new BaseRegistry<>();
/**
* @param registryId A readable ID for this registry, used when logging.
* @param dataFilePath The path (relative to `assets/data`) to search for JSON files.
*/
public function new(registryId:String, dataFilePath:String)
{
this.registryId = registryId;
this.dataFilePath = dataFilePath;
this.entries = new Map<String, T>();
}
public function loadEntries():Void
{
clearEntries();
//
// SCRIPTED ENTRIES
//
var scriptedEntryClassNames:Array<String> = getScriptedClassNames();
log('Registering ${scriptedEntryClassNames.length} scripted entries...');
for (entryCls in scriptedEntryClassNames)
{
var entry:T = createScriptedEntry(entryCls);
if (entry != null)
{
log('Successfully created scripted entry (${entryCls} = ${entry.id})');
entries.set(entry.id, entry);
}
else
{
log('Failed to create scripted entry (${entryCls})');
}
}
//
// UNSCRIPTED ENTRIES
//
var entryIdList:Array<String> = DataAssets.listDataFilesInPath('${dataFilePath}/');
var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool {
return !entries.exists(entryId);
});
log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
for (entryId in unscriptedEntryIds)
{
try
{
var entry:T = createEntry(entryId);
if (entry != null)
{
trace(' Loaded entry data: ${entry}');
entries.set(entry.id, entry);
}
}
catch (e:Dynamic)
{
trace(' Failed to load entry data: ${entryId}');
trace(e);
continue;
}
}
}
public function listEntryIds():Array<String>
{
return entries.keys().array();
}
public function countEntries():Int
{
return entries.size();
}
public function fetchEntry(id:String):Null<T>
{
return entries.get(id);
}
public function toString():String
{
return 'Registry(' + registryId + ', ${countEntries()} entries)';
}
function log(message:String):Void
{
trace('[' + registryId + '] ' + message);
}
function loadEntryFile(id:String):String
{
var entryFilePath:String = Paths.json('${dataFilePath}/${id}');
var rawJson:String = openfl.Assets.getText(entryFilePath).trim();
return rawJson;
}
function clearEntries():Void
{
for (entry in entries)
{
entry.destroy();
}
entries.clear();
}
//
// FUNCTIONS TO IMPLEMENT
//
/**
* Read, parse, and validate the JSON data and produce the corresponding data object.
*
* NOTE: Must be implemented on the implementation class annd
*/
public abstract function parseEntryData(id:String):Null<J>;
/**
* Retrieve the list of scripted class names to load.
* @return An array of scripted class names.
*/
abstract function getScriptedClassNames():Array<String>;
/**
* Create an entry from the given ID.
* @param id
*/
function createEntry(id:String):Null<T>
{
return new T(id);
}
/**
* Create a entry, attached to a scripted class, from the given class name.
* @param clsName
*/
abstract function createScriptedEntry(clsName:String):Null<T>;
}

View file

@ -0,0 +1,19 @@
package funkin.data;
/**
* An interface defining the necessary functions for a registry entry.
* A `String->Void` constructor is also mandatory, but enforced elsewhere.
* @param T The JSON data type of the registry entry.
*/
interface IRegistryEntry<T>
{
public final id:String;
// public function new(id:String):Void;
public function destroy():Void;
public function toString():String;
// Can't make an interface field private I guess.
public final _data:T;
public function _fetchData(id:String):Null<T>;
}

View file

@ -0,0 +1,91 @@
package funkin.data.level;
import funkin.play.AnimationData;
/**
* A type definition for the data in a story mode level JSON file.
* @see https://lib.haxe.org/p/json2object/
*/
typedef LevelData =
{
/**
* The version number of the level data schema.
* When making changes to the level data format, this should be incremented,
* and a migration function should be added to LevelDataParser to handle old versions.
*/
@:default(funkin.data.level.LevelRegistry.LEVEL_DATA_VERSION)
var version:String;
/**
* The title of the week, as seen in the top corner.
*/
var name:String;
/**
* The graphic for the level, as seen in the scrolling list.
*/
var titleAsset:String;
@:default([])
var props:Array<LevelPropData>;
@:default(["bopeebo"])
var songs:Array<String>;
@:default("#F9CF51")
@:optional
var background:String;
}
typedef LevelPropData =
{
/**
* The image to use for the prop. May optionally be a sprite sheet.
*/
var assetPath:String;
/**
* The scale to render the prop at.
* @default 1.0
*/
@:default(1.0)
@:optional
var scale:Float;
/**
* The opacity to render the prop at.
* @default 1.0
*/
@:default(1.0)
@:optional
var alpha:Float;
/**
* If true, the prop is a pixel sprite, and will be rendered without smoothing.
*/
@:default(false)
@:optional
var isPixel:Bool;
/**
* The frequency to bop at, in beats.
* @default 1 = every beat, 2 = every other beat, etc.
*/
@:default(1)
@:optional
var danceEvery:Int;
/**
* The offset on the position to render the prop at.
* @default [0.0, 0.0]
*/
@:default([0, 0])
@:optional
var offsets:Array<Float>;
/**
* A set of animations to play on the prop.
* If default/empty, the prop will be static.
*/
@:default([])
@:optional
var animations:Array<AnimationData>;
}

View file

@ -0,0 +1,85 @@
package funkin.data.level;
import funkin.ui.story.Level;
import funkin.data.level.LevelData;
import funkin.ui.story.ScriptedLevel;
class LevelRegistry extends BaseRegistry<Level, LevelData>
{
/**
* The current version string for the stage data format.
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateStageData()` function.
*/
public static final LEVEL_DATA_VERSION:String = "1.0.0";
public static final instance:LevelRegistry = new LevelRegistry();
public function new()
{
super('LEVEL', 'levels');
}
/**
* Read, parse, and validate the JSON data and produce the corresponding data object.
*/
public function parseEntryData(id:String):Null<LevelData>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<LevelData>();
var jsonStr:String = loadEntryFile(id);
parser.fromJson(jsonStr);
if (parser.errors.length > 0)
{
trace('Failed to parse entry data: ${id}');
for (error in parser.errors)
{
trace(error);
}
return null;
}
return parser.value;
}
function createScriptedEntry(clsName:String):Level
{
return ScriptedLevel.init(clsName, "unknown");
}
function getScriptedClassNames():Array<String>
{
return ScriptedLevel.listScriptClasses();
}
/**
* A list of all the story weeks from the base game, in order.
* TODO: Should this be hardcoded?
*/
public function listBaseGameLevelIds():Array<String>
{
return [
"tutorial",
"week1",
"week2",
"week3",
"week4",
"week5",
"week6",
"week7",
"weekend1"
];
}
/**
* A list of all installed story weeks that are not from the base game.
*/
public function listModdedLevelIds():Array<String>
{
return listEntryIds().filter(function(id:String):Bool {
return listBaseGameLevelIds().indexOf(id) == -1;
});
}
}

View file

@ -277,6 +277,7 @@ class PolymodHandler
// TODO: Reload event callbacks // TODO: Reload event callbacks
funkin.data.level.LevelRegistry.instance.loadEntries();
SongDataParser.loadSongCache(); SongDataParser.loadSongCache();
StageDataParser.loadStageCache(); StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache(); CharacterDataParser.loadCharacterCache();

View file

@ -21,36 +21,48 @@ typedef AnimationData =
* ONLY for use by MultiSparrow characters. * ONLY for use by MultiSparrow characters.
* @default The assetPath of the parent sprite * @default The assetPath of the parent sprite
*/ */
@:default(null)
@:optional
var assetPath:Null<String>; var assetPath:Null<String>;
/** /**
* Offset the character's position by this amount when playing this animation. * Offset the character's position by this amount when playing this animation.
* @default [0, 0] * @default [0, 0]
*/ */
@:default([0, 0])
@:optional
var offsets:Null<Array<Float>>; var offsets:Null<Array<Float>>;
/** /**
* Whether the animation should loop when it finishes. * Whether the animation should loop when it finishes.
* @default false * @default false
*/ */
@:default(false)
@:optional
var looped:Null<Bool>; var looped:Null<Bool>;
/** /**
* Whether the animation's sprites should be flipped horizontally. * Whether the animation's sprites should be flipped horizontally.
* @default false * @default false
*/ */
@:default(false)
@:optional
var flipX:Null<Bool>; var flipX:Null<Bool>;
/** /**
* Whether the animation's sprites should be flipped vertically. * Whether the animation's sprites should be flipped vertically.
* @default false * @default false
*/ */
@:default(false)
@:optional
var flipY:Null<Bool>; var flipY:Null<Bool>;
/** /**
* The frame rate of the animation. * The frame rate of the animation.
* @default 24 * @default 24
*/ */
@:default(24)
@:optional
var frameRate:Null<Int>; var frameRate:Null<Int>;
/** /**
@ -59,5 +71,7 @@ typedef AnimationData =
* @example [0, 1, 2, 3] (use only the first four frames) * @example [0, 1, 2, 3] (use only the first four frames)
* @default [] (all frames) * @default [] (all frames)
*/ */
@:default([])
@:optional
var frameIndices:Null<Array<Int>>; var frameIndices:Null<Array<Int>>;
} }

View file

@ -2,7 +2,8 @@ package funkin.play;
import flixel.FlxObject; import flixel.FlxObject;
import flixel.FlxSprite; import flixel.FlxSprite;
import flixel.sound.FlxSound; import flixel.system.FlxSound;
import funkin.ui.story.StoryMenuState;
import flixel.util.FlxColor; import flixel.util.FlxColor;
import flixel.util.FlxTimer; import flixel.util.FlxTimer;
import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEvent;

View file

@ -5,6 +5,7 @@ import flixel.sound.FlxSound;
import flixel.addons.transition.FlxTransitionableState; import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxCamera; import flixel.FlxCamera;
import flixel.FlxObject; import flixel.FlxObject;
import funkin.ui.story.StoryMenuState;
import flixel.FlxSprite; import flixel.FlxSprite;
import flixel.FlxState; import flixel.FlxState;
import flixel.FlxSubState; import flixel.FlxSubState;

View file

@ -37,11 +37,14 @@ class Song implements IPlayStateScriptedClass
*/ */
public var validScore:Bool = true; public var validScore:Bool = true;
var difficultyIds:Array<String>;
public function new(id:String) public function new(id:String)
{ {
this.songId = id; this.songId = id;
variations = []; variations = [];
difficultyIds = [];
difficulties = new Map<String, SongDifficulty>(); difficulties = new Map<String, SongDifficulty>();
_metadata = SongDataParser.parseSongMetadata(songId); _metadata = SongDataParser.parseSongMetadata(songId);
@ -72,6 +75,8 @@ class Song implements IPlayStateScriptedClass
// but all the difficulties in the metadata must be in the chart file. // but all the difficulties in the metadata must be in the chart file.
for (diffId in metadata.playData.difficulties) for (diffId in metadata.playData.difficulties)
{ {
difficultyIds.push(diffId);
var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation); var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation);
variations.push(metadata.variation); variations.push(metadata.variation);
@ -148,6 +153,16 @@ class Song implements IPlayStateScriptedClass
return difficulties.get(diffId); return difficulties.get(diffId);
} }
public function listDifficulties():Array<String>
{
return difficultyIds;
}
public function hasDifficulty(diffId:String):Bool
{
return difficulties.exists(diffId);
}
/** /**
* Purge the cached chart data for each difficulty of this song. * Purge the cached chart data for each difficulty of this song.
*/ */
@ -290,7 +305,8 @@ class SongDifficulty
public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void
{ {
FlxG.sound.playMusic(Paths.inst(this.song.songId), volume, looped); var suffix:String = variation == null ? null : '-$variation';
FlxG.sound.playMusic(Paths.inst(this.song.songId, suffix), volume, looped);
} }
/** /**
@ -320,28 +336,30 @@ class SongDifficulty
return []; return [];
} }
var suffix:String = variation != null ? '-$variation' : '';
// Automatically resolve voices by removing suffixes. // Automatically resolve voices by removing suffixes.
// For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`. // For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`.
var playerId:String = id; var playerId:String = id;
var voicePlayer:String = Paths.voices(this.song.songId, '-$id'); var voicePlayer:String = Paths.voices(this.song.songId, '-$id$suffix');
while (voicePlayer != null && !Assets.exists(voicePlayer)) while (voicePlayer != null && !Assets.exists(voicePlayer))
{ {
// Remove the last suffix. // Remove the last suffix.
// For example, bf-car becomes bf. // For example, bf-car becomes bf.
playerId = playerId.split('-').slice(0, -1).join('-'); playerId = playerId.split('-').slice(0, -1).join('-');
// Try again. // Try again.
voicePlayer = playerId == '' ? null : Paths.voices(this.song.songId, '-${playerId}'); voicePlayer = playerId == '' ? null : Paths.voices(this.song.songId, '-${playerId}$suffix');
} }
var opponentId:String = playableCharData.opponent; var opponentId:String = playableCharData.opponent;
var voiceOpponent:String = Paths.voices(this.song.songId, '-${opponentId}'); var voiceOpponent:String = Paths.voices(this.song.songId, '-${opponentId}$suffix');
while (voiceOpponent != null && !Assets.exists(voiceOpponent)) while (voiceOpponent != null && !Assets.exists(voiceOpponent))
{ {
// Remove the last suffix. // Remove the last suffix.
opponentId = opponentId.split('-').slice(0, -1).join('-'); opponentId = opponentId.split('-').slice(0, -1).join('-');
// Try again. // Try again.
voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.songId, '-${opponentId}'); voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.songId, '-${opponentId}$suffix');
} }
var result:Array<String> = []; var result:Array<String> = [];
@ -350,7 +368,7 @@ class SongDifficulty
if (voicePlayer == null && voiceOpponent == null) if (voicePlayer == null && voiceOpponent == null)
{ {
// Try to use `Voices.ogg` if no other voices are found. // Try to use `Voices.ogg` if no other voices are found.
if (Assets.exists(Paths.voices(this.song.songId, ''))) result.push(Paths.voices(this.song.songId, '')); if (Assets.exists(Paths.voices(this.song.songId, ''))) result.push(Paths.voices(this.song.songId, '$suffix'));
} }
return result; return result;
} }

View file

@ -143,9 +143,15 @@ class SongDataParser
for (variation in variations) for (variation in variations)
{ {
var variationRawJson:String = loadSongMetadataFile(songId, variation); var variationJsonStr:String = loadSongMetadataFile(songId, variation);
var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationRawJson, '${songId}_${variation}'); var variationJsonData:Dynamic = null;
variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}_${variation}'); try
{
variationJsonData = Json.parse(variationJsonStr);
}
catch (e) {}
var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationJsonData, '${songId}-${variation}');
variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}-${variation}');
if (variationSongMetadata != null) if (variationSongMetadata != null)
{ {
variationSongMetadata.variation = variation; variationSongMetadata.variation = variation;

View file

@ -256,6 +256,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
var correctName = correctAnimationName(name); var correctName = correctAnimationName(name);
if (correctName == null) return; if (correctName == null) return;
this.animation.paused = false;
this.animation.play(correctName, restart, false, 0); this.animation.play(correctName, restart, false, 0);
if (ignoreOther) if (ignoreOther)

View file

@ -4,6 +4,7 @@ import flixel.FlxSprite;
import haxe.Json; import haxe.Json;
import lime.utils.Assets; import lime.utils.Assets;
// import flxtyped group // import flxtyped group
import funkin.ui.story.StoryMenuState;
import flixel.group.FlxGroup.FlxTypedGroup; import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.util.FlxTimer; import flixel.util.FlxTimer;
import flixel.FlxG; import flixel.FlxG;

View file

@ -0,0 +1,177 @@
package funkin.ui.story;
import flixel.FlxSprite;
import flixel.util.FlxColor;
import funkin.play.song.Song;
import funkin.data.IRegistryEntry;
import funkin.data.level.LevelRegistry;
import funkin.data.level.LevelData;
/**
* An object used to retrieve data about a story mode level (also known as "weeks").
* Can be scripted to override each function, for custom behavior.
*/
class Level implements IRegistryEntry<LevelData>
{
/**
* The ID of the story mode level.
*/
public final id:String;
/**
* Level data as parsed from the JSON file.
*/
public final _data:LevelData;
/**
* @param id The ID of the JSON file to parse.
*/
public function new(id:String)
{
this.id = id;
_data = _fetchData(id);
if (_data == null)
{
throw 'Could not parse level data for id: $id';
}
}
/**
* Get the list of songs in this level, as an array of IDs.
* @return Array<String>
*/
public function getSongs():Array<String>
{
return _data.songs;
}
/**
* Retrieve the title of the level for display on the menu.
*/
public function getTitle():String
{
// TODO: Maybe add localization support?
return _data.name;
}
public function buildTitleGraphic():FlxSprite
{
var result = new FlxSprite().loadGraphic(Paths.image(_data.titleAsset));
return result;
}
/**
* Get the list of songs in this level, as an array of names, for display on the menu.
* @return Array<String>
*/
public function getSongDisplayNames(difficulty:String):Array<String>
{
var songList:Array<String> = getSongs() ?? [];
var songNameList:Array<String> = songList.map(function(songId) {
return funkin.play.song.SongData.SongDataParser.fetchSong(songId) ?.getDifficulty(difficulty) ?.songName ?? 'Unknown';
});
return songNameList;
}
/**
* Whether this level is unlocked. If not, it will be greyed out on the menu and have a lock icon.
* TODO: Change this behavior in a later release.
*/
public function isUnlocked():Bool
{
return true;
}
/**
* Whether this level is visible. If not, it will not be shown on the menu at all.
*/
public function isVisible():Bool
{
return true;
}
public function buildBackground():FlxSprite
{
if (_data.background.startsWith('#'))
{
// Color specified
var color:FlxColor = FlxColor.fromString(_data.background);
return new FlxSprite().makeGraphic(FlxG.width, 400, color);
}
else
{
// Image specified
return new FlxSprite().loadGraphic(Paths.image(_data.background));
}
}
public function getDifficulties():Array<String>
{
var difficulties:Array<String> = [];
var songList = getSongs();
var firstSongId:String = songList[0];
var firstSong:Song = funkin.play.song.SongData.SongDataParser.fetchSong(firstSongId);
if (firstSong != null)
{
for (difficulty in firstSong.listDifficulties())
{
difficulties.push(difficulty);
}
}
// Filter to only include difficulties that are present in all songs
for (songIndex in 1...songList.length)
{
var songId:String = songList[songIndex];
var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
if (song == null) continue;
for (difficulty in difficulties)
{
if (!song.hasDifficulty(difficulty))
{
difficulties.remove(difficulty);
}
}
}
if (difficulties.length == 0) difficulties = ['normal'];
return difficulties;
}
public function buildProps():Array<LevelProp>
{
var props:Array<LevelProp> = [];
if (_data.props.length == 0) return props;
for (propIndex in 0..._data.props.length)
{
var propData = _data.props[propIndex];
var propSprite:LevelProp = LevelProp.build(propData);
propSprite.x += FlxG.width * 0.25 * propIndex;
props.push(propSprite);
}
return props;
}
public function destroy():Void {}
public function toString():String
{
return 'Level($id)';
}
public function _fetchData(id:String):Null<LevelData>
{
return LevelRegistry.instance.parseEntryData(id);
}
}

View file

@ -0,0 +1,63 @@
package funkin.ui.story;
import funkin.play.stage.Bopper;
import funkin.util.assets.FlxAnimationUtil;
import funkin.data.level.LevelData;
class LevelProp extends Bopper
{
public function new(danceEvery:Int)
{
super(danceEvery);
}
public function playConfirm():Void
{
playAnimation('confirm', true, true);
}
public static function build(propData:LevelPropData):Null<LevelProp>
{
var isAnimated:Bool = propData.animations.length > 0;
var prop:LevelProp = new LevelProp(propData.danceEvery);
if (isAnimated)
{
// Initalize sprite frames.
// Sparrow atlas only LEL.
prop.frames = Paths.getSparrowAtlas(propData.assetPath);
}
else
{
// Initalize static sprite.
prop.loadGraphic(Paths.image(propData.assetPath));
// Disables calls to update() for a performance boost.
prop.active = false;
}
if (prop.frames == null || prop.frames.numFrames == 0)
{
trace('ERROR: Could not build texture for level prop (${propData.assetPath}).');
return null;
}
var scale:Float = propData.scale * (propData.isPixel ? 6 : 1);
prop.scale.set(scale, scale);
prop.antialiasing = !propData.isPixel;
prop.alpha = propData.alpha;
prop.x = propData.offsets[0];
prop.y = propData.offsets[1];
FlxAnimationUtil.addAtlasAnimations(prop, propData.animations);
for (propAnim in propData.animations)
{
prop.setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
}
prop.dance();
prop.animation.paused = true;
return prop;
}
}

View file

@ -0,0 +1,90 @@
package funkin.ui.story;
import flixel.FlxSprite;
import flixel.graphics.frames.FlxAtlasFrames;
import flixel.group.FlxSpriteGroup;
import flixel.util.FlxColor;
import funkin.CoolUtil;
class LevelTitle extends FlxSpriteGroup
{
static final LOCK_PAD:Int = 4;
public final level:Level;
public var targetY:Float;
public var isFlashing:Bool = false;
var title:FlxSprite;
var lock:FlxSprite;
var flashingInt:Int = 0;
public function new(x:Int, y:Int, level:Level)
{
super(x, y);
this.level = level;
if (this.level == null) throw "Level cannot be null!";
buildLevelTitle();
buildLevelLock();
}
override function get_width():Float
{
if (length == 0) return 0;
if (lock.visible)
{
return title.width + lock.width + LOCK_PAD;
}
else
{
return title.width;
}
}
// if it runs at 60fps, fake framerate will be 6
// if it runs at 144 fps, fake framerate will be like 14, and will update the graphic every 0.016666 * 3 seconds still???
// so it runs basically every so many seconds, not dependant on framerate??
// I'm still learning how math works thanks whoever is reading this lol
var fakeFramerate:Int = Math.round((1 / FlxG.elapsed) / 10);
public override function update(elapsed:Float):Void
{
this.y = CoolUtil.coolLerp(y, targetY, 0.17);
if (isFlashing) flashingInt += 1;
if (flashingInt % fakeFramerate >= Math.floor(fakeFramerate / 2)) title.color = 0xFF33ffff;
else
title.color = FlxColor.WHITE;
}
public function showLock():Void
{
lock.visible = true;
this.x -= (lock.width + LOCK_PAD) / 2;
}
public function hideLock():Void
{
lock.visible = false;
this.x += (lock.width + LOCK_PAD) / 2;
}
function buildLevelTitle():Void
{
title = level.buildTitleGraphic();
add(title);
}
function buildLevelLock():Void
{
lock = new FlxSprite(0, 0).loadGraphic(Paths.image('storymenu/ui/lock'));
lock.x = title.x + title.width + LOCK_PAD;
lock.visible = false;
add(lock);
}
}

View file

@ -0,0 +1,9 @@
package funkin.ui.story;
/**
* A script that can be tied to a Level, which persists across states.
* Create a scripted class that extends Level to use this.
* This allows you to customize how a specific level appears.
*/
@:hscriptClass
class ScriptedLevel extends funkin.ui.story.Level implements polymod.hscript.HScriptedClass {}

View file

@ -0,0 +1,549 @@
package funkin.ui.story;
import openfl.utils.Assets;
import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxSprite;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.text.FlxText;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.util.FlxColor;
import flixel.util.FlxTimer;
import funkin.data.level.LevelRegistry;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.PlayState;
import funkin.play.song.SongData.SongDataParser;
import funkin.util.Constants;
class StoryMenuState extends MusicBeatState
{
static final DEFAULT_BACKGROUND_COLOR:FlxColor = FlxColor.fromString("#F9CF51");
static final BACKGROUND_HEIGHT:Int = 400;
var currentDifficultyId:String = 'normal';
var currentLevelId:String = 'tutorial';
var currentLevel:Level;
var isLevelUnlocked:Bool;
var currentLevelTitle:LevelTitle;
var highScore:Int = 42069420;
var highScoreLerp:Int = 12345678;
var exitingMenu:Bool = false;
var selectedLevel:Bool = false;
var displayingModdedLevels:Bool = false;
//
// RENDER OBJECTS
//
/**
* The title of the level at the top.
*/
var levelTitleText:FlxText;
/**
* The score text at the top.
*/
var scoreText:FlxText;
/**
* The list of songs on the left.
*/
var tracklistText:FlxText;
/**
* The titles of the levels in the middle.
*/
var levelTitles:FlxTypedGroup<LevelTitle>;
/**
* The props in the center.
*/
var levelProps:FlxTypedGroup<LevelProp>;
/**
* The background behind the props.
*/
var levelBackground:FlxSprite;
/**
* The left arrow of the difficulty selector.
*/
var leftDifficultyArrow:FlxSprite;
/**
* The right arrow of the difficulty selector.
*/
var rightDifficultyArrow:FlxSprite;
/**
* The text of the difficulty selector.
*/
var difficultySprite:FlxSprite;
var difficultySprites:Map<String, FlxSprite>;
var stickerSubState:StickerSubState;
public function new(?stickers:StickerSubState = null)
{
super();
if (stickers != null)
{
stickerSubState = stickers;
}
}
override function create():Void
{
super.create();
difficultySprites = new Map<String, FlxSprite>();
transIn = FlxTransitionableState.defaultTransIn;
transOut = FlxTransitionableState.defaultTransOut;
if (!FlxG.sound.music.playing)
{
FlxG.sound.playMusic(Paths.music('freakyMenu'));
FlxG.sound.music.fadeIn(4, 0, 0.7);
Conductor.forceBPM(Constants.FREAKY_MENU_BPM);
}
if (stickerSubState != null)
{
this.persistentUpdate = true;
this.persistentDraw = true;
openSubState(stickerSubState);
stickerSubState.degenStickers();
// resetSubState();
}
persistentUpdate = persistentDraw = true;
updateData();
// Explicitly define the background color.
this.bgColor = FlxColor.BLACK;
levelTitles = new FlxTypedGroup<LevelTitle>();
add(levelTitles);
updateBackground();
levelProps = new FlxTypedGroup<LevelProp>();
levelProps.zIndex = 1000;
add(levelProps);
updateProps();
scoreText = new FlxText(10, 10, 0, 'HIGH SCORE: 42069420');
scoreText.setFormat("VCR OSD Mono", 32);
add(scoreText);
tracklistText = new FlxText(FlxG.width * 0.05, levelBackground.x + levelBackground.height + 100, 0, "Tracks", 32);
tracklistText.setFormat("VCR OSD Mono", 32);
tracklistText.alignment = CENTER;
tracklistText.color = 0xFFe55777;
add(tracklistText);
levelTitleText = new FlxText(FlxG.width * 0.7, 10, 0, 'LEVEL 1');
levelTitleText.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT);
levelTitleText.alpha = 0.7;
add(levelTitleText);
buildLevelTitles();
leftDifficultyArrow = new FlxSprite(levelTitles.members[0].x + levelTitles.members[0].width + 10, levelTitles.members[0].y + 10);
leftDifficultyArrow.frames = Paths.getSparrowAtlas('storymenu/ui/arrows');
leftDifficultyArrow.animation.addByPrefix('idle', 'leftIdle0');
leftDifficultyArrow.animation.addByPrefix('press', 'leftConfirm0');
leftDifficultyArrow.animation.play('idle');
add(leftDifficultyArrow);
buildDifficultySprite();
rightDifficultyArrow = new FlxSprite(difficultySprite.x + difficultySprite.width + 10, leftDifficultyArrow.y);
rightDifficultyArrow.frames = leftDifficultyArrow.frames;
rightDifficultyArrow.animation.addByPrefix('idle', 'rightIdle0');
rightDifficultyArrow.animation.addByPrefix('press', 'rightConfirm0');
rightDifficultyArrow.animation.play('idle');
add(rightDifficultyArrow);
add(difficultySprite);
updateText();
changeDifficulty();
changeLevel();
refresh();
#if discord_rpc
// Updating Discord Rich Presence
DiscordClient.changePresence("In the Menus", null);
#end
}
function updateData():Void
{
currentLevel = LevelRegistry.instance.fetchEntry(currentLevelId);
isLevelUnlocked = currentLevel == null ? false : currentLevel.isUnlocked();
}
function buildDifficultySprite():Void
{
remove(difficultySprite);
difficultySprite = difficultySprites.get(currentDifficultyId);
if (difficultySprite == null)
{
difficultySprite = new FlxSprite(leftDifficultyArrow.x + leftDifficultyArrow.width + 10, leftDifficultyArrow.y);
if (Assets.exists(Paths.file('images/storymenu/difficulties/${currentDifficultyId}.xml')))
{
difficultySprite.frames = Paths.getSparrowAtlas('storymenu/difficulties/${currentDifficultyId}');
difficultySprite.animation.addByPrefix('idle', 'idle0', 24, true);
difficultySprite.animation.play('idle');
}
else
{
difficultySprite.loadGraphic(Paths.image('storymenu/difficulties/${currentDifficultyId}'));
}
difficultySprites.set(currentDifficultyId, difficultySprite);
difficultySprite.x += (difficultySprites.get('normal').width - difficultySprite.width) / 2;
}
difficultySprite.alpha = 0;
difficultySprite.y = leftDifficultyArrow.y - 15;
var targetY:Float = leftDifficultyArrow.y + 10;
targetY -= (difficultySprite.height - difficultySprites.get('normal').height) / 2;
FlxTween.tween(difficultySprite, {y: targetY, alpha: 1}, 0.07);
add(difficultySprite);
}
function buildLevelTitles():Void
{
levelTitles.clear();
var levelIds:Array<String> = displayingModdedLevels ? LevelRegistry.instance.listModdedLevelIds() : LevelRegistry.instance.listBaseGameLevelIds();
if (levelIds.length == 0) levelIds = ['tutorial']; // Make sure there's at least one level to display.
for (levelIndex in 0...levelIds.length)
{
var levelId:String = levelIds[levelIndex];
var level:Level = LevelRegistry.instance.fetchEntry(levelId);
if (level == null) continue;
var levelTitleItem:LevelTitle = new LevelTitle(0, Std.int(levelBackground.y + levelBackground.height + 10), level);
levelTitleItem.targetY = ((levelTitleItem.height + 20) * levelIndex);
levelTitleItem.screenCenter(X);
levelTitles.add(levelTitleItem);
}
}
function switchMode(moddedLevels:Bool):Void
{
displayingModdedLevels = moddedLevels;
buildLevelTitles();
changeLevel(0);
changeDifficulty(0);
}
override function update(elapsed:Float)
{
Conductor.update();
highScoreLerp = Std.int(CoolUtil.coolLerp(highScoreLerp, highScore, 0.5));
scoreText.text = 'LEVEL SCORE: ${Math.round(highScoreLerp)}';
levelTitleText.text = currentLevel.getTitle();
levelTitleText.x = FlxG.width - (levelTitleText.width + 10); // Right align.
handleKeyPresses();
super.update(elapsed);
}
function handleKeyPresses():Void
{
if (!exitingMenu)
{
if (!selectedLevel)
{
if (controls.UI_UP_P)
{
changeLevel(-1);
changeDifficulty(0);
}
if (controls.UI_DOWN_P)
{
changeLevel(1);
changeDifficulty(0);
}
if (controls.UI_RIGHT)
{
rightDifficultyArrow.animation.play('press');
}
else
{
rightDifficultyArrow.animation.play('idle');
}
if (controls.UI_LEFT)
{
leftDifficultyArrow.animation.play('press');
}
else
{
leftDifficultyArrow.animation.play('idle');
}
if (controls.UI_RIGHT_P)
{
changeDifficulty(1);
}
if (controls.UI_LEFT_P)
{
changeDifficulty(-1);
}
if (FlxG.keys.justPressed.TAB)
{
switchMode(!displayingModdedLevels);
}
}
if (controls.ACCEPT)
{
selectLevel();
}
}
if (controls.BACK && !exitingMenu && !selectedLevel)
{
FlxG.sound.play(Paths.sound('cancelMenu'));
exitingMenu = true;
FlxG.switchState(new MainMenuState());
}
}
/**
* Changes the selected level.
* @param change +1 (down), -1 (up)
*/
function changeLevel(change:Int = 0):Void
{
var levelList:Array<String> = displayingModdedLevels ? LevelRegistry.instance.listModdedLevelIds() : LevelRegistry.instance.listBaseGameLevelIds();
if (levelList.length == 0) levelList = ['tutorial'];
var currentIndex:Int = levelList.indexOf(currentLevelId);
currentIndex += change;
// Wrap around
if (currentIndex < 0) currentIndex = levelList.length - 1;
if (currentIndex >= levelList.length) currentIndex = 0;
currentLevelId = levelList[currentIndex];
updateData();
for (index in 0...levelTitles.members.length)
{
var item:LevelTitle = levelTitles.members[index];
item.targetY = (index - currentIndex) * 120 + 480;
if (index == currentIndex)
{
currentLevelTitle = item;
item.alpha = 1.0;
}
else if (index > currentIndex)
{
item.alpha = 0.6;
}
else
{
item.alpha = 0.0;
}
}
updateText();
updateBackground();
updateProps();
refresh();
}
/**
* Changes the selected difficulty.
* @param change +1 (right) to increase difficulty, -1 (left) to decrease difficulty
*/
function changeDifficulty(change:Int = 0):Void
{
var difficultyList:Array<String> = currentLevel.getDifficulties();
var currentIndex:Int = difficultyList.indexOf(currentDifficultyId);
currentIndex += change;
// Wrap around
if (currentIndex < 0) currentIndex = difficultyList.length - 1;
if (currentIndex >= difficultyList.length) currentIndex = 0;
var hasChanged:Bool = currentDifficultyId != difficultyList[currentIndex];
currentDifficultyId = difficultyList[currentIndex];
if (difficultyList.length <= 1)
{
leftDifficultyArrow.visible = false;
rightDifficultyArrow.visible = false;
}
else
{
leftDifficultyArrow.visible = true;
rightDifficultyArrow.visible = true;
}
if (hasChanged)
{
buildDifficultySprite();
funnyMusicThing();
}
}
final FADE_OUT_TIME:Float = 1.5;
function funnyMusicThing():Void
{
if (currentDifficultyId == "nightmare")
{
FlxG.sound.music.fadeOut(FADE_OUT_TIME, 0.0);
}
else
{
FlxG.sound.music.fadeOut(FADE_OUT_TIME, 1.0);
}
}
override function dispatchEvent(event:ScriptEvent):Void
{
// super.dispatchEvent(event) dispatches event to module scripts.
super.dispatchEvent(event);
if ((levelProps?.length ?? 0) > 0)
{
// Dispatch event to props.
for (prop in levelProps)
{
ScriptEventDispatcher.callEvent(prop, event);
}
}
}
function selectLevel()
{
if (!currentLevel.isUnlocked())
{
FlxG.sound.play(Paths.sound('cancelMenu'));
return;
}
if (selectedLevel) return;
selectedLevel = true;
FlxG.sound.play(Paths.sound('confirmMenu'));
currentLevelTitle.isFlashing = true;
for (prop in levelProps.members)
{
prop.playConfirm();
}
PlayState.storyPlaylist = currentLevel.getSongs();
PlayState.isStoryMode = true;
PlayState.currentSong = SongLoad.loadFromJson(PlayState.storyPlaylist[0].toLowerCase(), PlayState.storyPlaylist[0].toLowerCase());
PlayState.currentSong_NEW = SongDataParser.fetchSong(PlayState.storyPlaylist[0].toLowerCase());
// TODO: Fix this.
PlayState.storyWeek = 0;
PlayState.campaignScore = 0;
// TODO: Fix this.
PlayState.storyDifficulty = 0;
PlayState.storyDifficulty_NEW = currentDifficultyId;
SongLoad.curDiff = PlayState.storyDifficulty_NEW;
new FlxTimer().start(1, function(tmr:FlxTimer) {
LoadingState.loadAndSwitchState(new PlayState(), true);
});
}
function updateBackground():Void
{
if (levelBackground != null)
{
var oldBackground:FlxSprite = levelBackground;
FlxTween.tween(oldBackground, {alpha: 0.0}, 0.6,
{
ease: FlxEase.linear,
onComplete: function(_) {
remove(oldBackground);
}
});
}
levelBackground = currentLevel.buildBackground();
levelBackground.x = 0;
levelBackground.y = 56;
levelBackground.alpha = 0.0;
levelBackground.zIndex = 100;
add(levelBackground);
FlxTween.tween(levelBackground, {alpha: 1.0}, 0.6,
{
ease: FlxEase.linear
});
}
function updateProps():Void
{
levelProps.clear();
for (prop in currentLevel.buildProps())
{
prop.zIndex = 1000;
levelProps.add(prop);
}
refresh();
}
function updateText():Void
{
tracklistText.text = 'TRACKS\n\n';
tracklistText.text += currentLevel.getSongDisplayNames(currentDifficultyId).join('\n');
tracklistText.screenCenter(X);
tracklistText.x -= FlxG.width * 0.35;
// TODO: Fix this.
highScore = Highscore.getWeekScore(0, 0);
}
}

View file

@ -9,13 +9,27 @@ package funkin.util.tools;
*/ */
class MapTools class MapTools
{ {
/**
* Return the quantity of keys in the map.
*/
public static function size<K, T>(map:Map<K, T>):Int
{
return map.keys().array().length;
}
/**
* Return a list of values from the map, as an array.
*/
public static function values<K, T>(map:Map<K, T>):Array<T> public static function values<K, T>(map:Map<K, T>):Array<T>
{ {
return [for (i in map.iterator()) i]; return [for (i in map.iterator()) i];
} }
/**
* Return a list of keys from the map (as an array, rather than an iterator).
*/
public static function keyValues<K, T>(map:Map<K, T>):Array<K> public static function keyValues<K, T>(map:Map<K, T>):Array<K>
{ {
return [for (i in map.keys()) i]; return map.keys().array();
} }
} }