diff --git a/README.md b/README.md index e00a10f..ffc8a97 100644 --- a/README.md +++ b/README.md @@ -31,21 +31,22 @@ var meta:String = vsliceChart.meta; // String containing the FNF (V-Slice) meta ## Available formats | Format | File Extension | |----------------------|----------------------| -| [FNF (Legacy)](https://github.com/FunkinCrew/Funkin/tree/v0.2.7.1) | json | -| [FNF (Psych Engine)](https://github.com/ShadowMario/FNF-PsychEngine) | json | -| [FNF (Troll Engine)](https://github.com/riconuts/FNF-Troll-Engine) | json | -| [FNF (FPS +)](https://github.com/ThatRozebudDude/FPS-Plus-Public) | json | -| [FNF (Kade Engine)](https://github.com/Kade-github/Kade-Engine) | json | -| [FNF (Maru)](https://github.com/MaybeMaru/Maru-Funkin) | json | -| [FNF (Codename)](https://github.com/FNF-CNE-Devs/CodenameEngine) | json | -| [FNF (Ludum Dare)](https://github.com/FunkinCrew/Funkin/tree/1.0.0) | json / png | -| [FNF (V-Slice)](https://github.com/FunkinCrew/Funkin) | json | -| [Guitar Hero](https://clonehero.net/) | chart | -| [Osu! Mania](https://osu.ppy.sh/) | osu | -| [Quaver](https://quavergame.com/) | qua | -| [StepMania](https://www.stepmania.com/) | sm | -| [StepManiaShark](https://www.stepmania.com/) | ssc | -| [Midi](https://youtu.be/2SNTJurmfD0) | mid | +| [FNF (Legacy)](https://github.com/FunkinCrew/Funkin/tree/v0.2.7.1) | json | +| [FNF (Psych Engine)](https://github.com/ShadowMario/FNF-PsychEngine) | json | +| [FNF (Troll Engine)](https://github.com/riconuts/FNF-Troll-Engine) | json | +| [FNF (FPS +)](https://github.com/ThatRozebudDude/FPS-Plus-Public) | json | +| [FNF (Kade Engine)](https://github.com/Kade-github/Kade-Engine) | json | +| [FNF (Maru)](https://github.com/MaybeMaru/Maru-Funkin) | json | +| [FNF (Codename)](https://github.com/FNF-CNE-Devs/CodenameEngine) | json | +| [FNF (Imaginative)](https://github.com/Funkin-Imaginative/imaginative.engine) | json | +| [FNF (Ludum Dare)](https://github.com/FunkinCrew/Funkin/tree/1.0.0) | json / png | +| [FNF (V-Slice)](https://github.com/FunkinCrew/Funkin) | json | +| [Guitar Hero](https://clonehero.net/) | chart | +| [Osu! Mania](https://osu.ppy.sh/) | osu | +| [Quaver](https://quavergame.com/) | qua | +| [StepMania](https://www.stepmania.com/) | sm | +| [StepManiaShark](https://www.stepmania.com/) | ssc | +| [Midi](https://youtu.be/2SNTJurmfD0) | mid | ## Encountered a problem? If you discover a bug or run into any other issue while using the library, please don't hesitate to open a [GitHub issue](https://github.com/MaybeMaru/moonchart/issues).
diff --git a/src/moonchart/backend/FormatData.hx b/src/moonchart/backend/FormatData.hx index 07706e1..ac98c8b 100644 --- a/src/moonchart/backend/FormatData.hx +++ b/src/moonchart/backend/FormatData.hx @@ -21,6 +21,7 @@ enum abstract Format(String) from String to String var FNF_KADE; var FNF_MARU; var FNF_CODENAME; + var FNF_IMAGINATIVE; var FNF_LUDUM_DARE; var FNF_VSLICE; var GUITAR_HERO; @@ -44,6 +45,7 @@ enum abstract Format(String) from String to String FNFKade.__getFormat(), FNFMaru.__getFormat(), FNFCodename.__getFormat(), + FNFImaginative.__getFormat(), FNFLudumDare.__getFormat(), FNFVSlice.__getFormat(), GuitarHero.__getFormat(), diff --git a/src/moonchart/formats/fnf/FNFImaginative.hx b/src/moonchart/formats/fnf/FNFImaginative.hx new file mode 100644 index 0000000..c0a9670 --- /dev/null +++ b/src/moonchart/formats/fnf/FNFImaginative.hx @@ -0,0 +1,575 @@ +package moonchart.formats.fnf; + +import haxe.io.Path; +import flixel.util.FlxColor; +import flixel.util.typeLimit.OneOfFour; +import moonchart.backend.FormatData; +import moonchart.backend.Optimizer; +import moonchart.backend.Timing; +import moonchart.backend.Util; +import moonchart.formats.BasicFormat; +import moonchart.formats.fnf.FNFGlobal; +import moonchart.formats.fnf.legacy.FNFLegacy; + +// Chart +typedef FNFImaginativeNote = { + /** + * The note direction id. + */ + var id:Int; + // NOTE: As of rn this is actually in milliseconds!!!!! + /** + * The length of a sustain in steps. + */ + @:default(0) var length:Float; + // NOTE: As of rn this is actually in milliseconds!!!!! + /** + * The note position in steps. + */ + var time:Float; + /** + * Characters this note will mess with instead of the fields main ones. + */ + var ?characters:Array; + /** + * The note type. + */ + var type:String; +} + +typedef FNFImaginativeArrowField = { + /** + * The arrow field tag name. + */ + var tag:String; + /** + * Characters to be assigned as singers for this field. + */ + var characters:Array; + /** + * Array of notes to load. + */ + var notes:Array; + /** + * The independent field scroll speed. + */ + var ?speed:Float; + /** + * The starting strum count of the field. + */ + @:default('4') var ?startCount:Int; +} + +typedef FNFImaginativeCharacter = { + /** + * The character tag name. + */ + var tag:String; + /** + * The character to load. + */ + @:default('boyfriend') var name:String; + /** + * The location the character will be placed. + */ + var position:String; + /** + * The character's vocal suffix override. + */ + var ?vocals:String; +} + +typedef FNFImaginativeFieldSettings = { + /** + * The starting camera target + */ + var ?cameraTarget:String; + /** + * The arrow field order. + */ + var order:Array; + /** + * The enemy field. + */ + var enemy:String; + /** + * The player field. + */ + var player:String; +} + +typedef FNFImaginativeEvent = { + // NOTE: As of rn this is actually in milliseconds!!!!! + /** + * The event position in steps. + */ + var time:Float; + /** + * Each event to trigger. + */ + var data:Array; +} +typedef FNFImaginativeSubEvent = { + /** + * The event name. + */ + var name:String; + /** + * The event parameters. + */ + var params:Array; +} + +typedef FNFImaginativeChart = { + /** + * The song scroll speed. + */ + @:default(2.6) var speed:Float; + /** + * The stage this song will take place. + */ + @:default('void') var stage:String; + /** + * Array of arrow fields to load. + */ + var fields:Array; + /** + * Array of characters to load. + */ + var characters:Array; + /** + * Field settings. + */ + var fieldSettings:FNFImaginativeFieldSettings; + /** + * Chart specific events. + */ + var ?events:Array; +} + +// Meta +typedef FNFImaginativeCheckpoint = { // used for bpm changes + /** + * The position of the song in milliseconds. + */ + var time:Float; + /** + * The "beats per minute" at that point. + */ + var bpm:Float; + /** + * The time signature at that point. + */ + var signature:Array; +} + +typedef FNFImaginativeAllowedModes = { + /** + * If true, this song allows you to play as the enemy. + */ + @:default(false) var playAsEnemy:Bool; + /** + * If true, this song allows you to go against another player. + */ + @:default(false) var p2AsEnemy:Bool; +} + +typedef FNFImaginativeAudioMeta = { + /** + * The composer of the song. + */ + @:default('Unassigned') var artist:String; + /** + * The display name of the song. + */ + var name:String; + /** + * The bpm at the start of the song. + */ + @:default(100) var bpm:Float; + /** + * The time signature at the start of the song. + */ + @:default([4, 4]) var signature:Array; + /** + * The audio offset. + */ + @:default(0) var ?offset:Float; + /** + * Contains all known bpm changes. + */ + var checkpoints:Array; +} + +typedef FNFImaginativeSongMeta = { + /** + * The song display name. + */ + var name:String; + /** + * The song folder name. + */ + var folder:String; + /** + * The song icon. + */ + var icon:String; + /** + * The starting difficulty. + */ + var startingDiff:Int; + /** + * The difficulties listing. + */ + var difficulties:Array; + /** + * The variations listing. + */ + var variants:Array; + /** + * The song color. + */ + var ?color:FlxColor; + /** + * Allowed modes for the song. + */ + var allowedModes:FNFImaginativeAllowedModes; +} + +enum abstract FNFImaginativeNoteType(String) from String to String { + var IMAG_ALT_ANIM = "Alt Animation"; + var IMAG_NO_ANIM = "No Animation"; +} + +class FNFImaginative extends BasicJsonFormat { + public static function __getFormat():FormatData { + return { + ID: FNF_IMAGINATIVE, + name: 'FNF (Imaginative)', + description: 'A unique format for adding characters, strumlines and vocal instances.', + extension: 'json', + hasMetaFile: TRUE, + metaFileExtension: 'json', + specialValues: ['"speed":', '?"stage":', '_"fields":', '_"characters":', '_"fieldSettings":'], + formatFile: FNFMaru.formatFile, + handler: FNFImaginative + } + } + + public var noteTypeResolver(default, null):FNFNoteTypeResolver; + + public function new(?data:FNFImaginativeChart, ?meta:FNFImaginativeAudioMeta) { + // NOTE: will be in STEPS but idk how to fully do that as of rn + super({timeFormat: MILLISECONDS, supportsDiffs: false, supportsEvents: true}); + this.data = data; + this.meta = meta; + beautify = true; + + noteTypeResolver = FNFGlobal.createNoteTypeResolver(); + noteTypeResolver.register(FNFImaginativeNoteType.IMAG_ALT_ANIM, BasicFNFNoteType.ALT_ANIM); + noteTypeResolver.register(FNFImaginativeNoteType.IMAG_NO_ANIM, BasicFNFNoteType.NO_ANIM); + } + + public static function formatTitle(title:String):String + return Path.normalize(title); + + inline static var _UNKNOWN_:String = '[unknown]'; + override function fromBasicFormat(chart:BasicChart, ?diff:FormatDifficulty):FNFImaginative { + var chartResolve:DiffNotesOutput = resolveDiffsNotes(chart, diff); + var diffId:String = chartResolve.diffs[0]; + var basicMeta:BasicMetaData = chart.meta; + + var characters:Array = Util.makeArray(0); + var charCap:Int = basicMeta.extraData.exists(FNFLegacyMetaValues.PLAYER_3) ? 3 : (basicMeta.extraData.get(FNFLegacyMetaValues.PLAYER_3) == null ? 2 : 3); + for (i in 0...charCap) { + characters.push({ + tag: switch (i) { + case 0: 'enemy'; + case 1: 'player'; + case 2: 'spectator'; + default: _UNKNOWN_; + }, + name: switch (i) { + case 0: basicMeta.extraData.get(FNFLegacyMetaValues.PLAYER_1) ?? 'dad'; + case 1: basicMeta.extraData.get(FNFLegacyMetaValues.PLAYER_2) ?? 'boyfriend'; + case 2: basicMeta.extraData.get(FNFLegacyMetaValues.PLAYER_3) ?? 'gf'; + default: ''; + }, + position: switch (i) { + case 0: 'enemy'; + case 1: 'player'; + case 2: 'spectator'; + default: _UNKNOWN_; + }, + }); + } + + var fields:Array = Util.makeArray(0); + for (i in 0...2) { + fields.push({ + tag: characters[i].tag, + characters: [characters[i].tag], + notes: Util.makeArray(0) + }); + } + + var basicNotes:Array = Timing.sortNotes(chartResolve.notes.get(diffId)); + for (note in basicNotes) { + var field:FNFImaginativeArrowField = fields[Std.int(note.lane / 4)]; + if (field == null) continue; + field.notes.push({ + id: note.lane % 4, + length: note.length, + time: note.time, + type: note.type + }); + } + for (field in fields) field.notes.sort((a, b) -> return Util.sortValues(a.time, b.time)); + + var events:Array = Util.makeArray(0); + var basicEvents = /* Timing.sortEvents */(chart.data.events); + // trace(haxe.Json.stringify(basicEvents, '\t')); + for (i => event in basicEvents) { + // helper for making events for imaginative + inline function makeEvent(name:String, params:Array):Void { + if (i - 1 > -1 && event.time == events[i - 1].time) { + // doing psychs event stacking method + events[i - 1].data.push({name: name, params: params}); + } else { + events.push({ + time: event.time, + data: [ + {name: name, params: params} + ] + }); + } + } + + // vslice conversion process + if (basicMeta.inputFormats.contains(FNF_VSLICE)) { + switch (event.name) { + case 'FocusCamera': + var target:Int = event.data?.char ?? 0; + var x:Float = event.data?.x ?? 0; + var y:Float = event.data?.y ?? 0; + var duration:Float = event.data?.duration ?? 4; + var ease:String = event.data?.ease ?? '[none]'; + if (ease == 'INSTANT') ease = '[instant]'; + if (ease == 'CLASSIC') ease = '[none]'; + + if (target == -1) + makeEvent('Focus Camera To Custom Position', [x, y, duration, ease, /* _UNKNOWN_, false, */ 'disable']); + else + makeEvent('Focus Camera To Character', [ + 'character', + switch (target) { + case 0: 'player'; + case 1: 'enemy'; + case 2: 'spectator'; + default: _UNKNOWN_; + }, + x, y, duration, ease, + // _UNKNOWN_, false, // idr wtf these where 😭 + 'disable' // how camera displacement should act when tweening if its enabled + ]); + + case 'PlayAnimation': + var target:String = event.data?.target ?? 'player'; + target = switch (target) { + case 'boyfriend' | 'bf': 'player'; + case 'dad' | 'opponent': 'enemy'; + case 'girlfriend' | 'gf': 'spectator'; + default: target; + } + makeEvent('Play Sprite Animation', [ + target == 'enemy' || target == 'player' || target == 'spectator' ? 'character' : 'sprite', + target, + event.data?.anim ?? _UNKNOWN_, + 'Unclear', // animation context + event.data?.force ?? false, + false, // reversed + 0 // starting frame + ]); + + case 'ScrollSpeed': + var target:String = switch (event.data?.strumline) { + case 'opponent': 'enemy'; + case 'player': 'player'; + default: '[global]'; + } + var ease:String = event.data?.ease ?? 'linear'; + if (ease == 'INSTANT') ease = '[instant]'; + makeEvent('Manage Scroll Speed', [ + target, + event.data?.scroll ?? 1, + event.data?.duration ?? 4, + ease, + event.data?.absolute ?? false, + ]); + + case 'SetCameraBop': + // TODO: Write this. + + // case 'SetCharacter': + // TODO: Write this. + + case 'SetHealthIcon': + var target:Int = event.data?.char ?? 0; + var iconId:String = event.data?.id ?? 'boyfriend'; + // MAYBE: Write this? + + // case 'SetStage': + // TODO: Write this. + + case 'ZoomCamera': + var ease:String = event.data?.ease ?? 'linear'; + if (ease == 'INSTANT') ease = '[instant]'; + // sets the default zoom and lerps handle the rest + // if (ease == 'CLASSIC') ease = '[none]'; + makeEvent('Manage Camera Zoom', [ + event.data?.zoom ?? 1, + event.data?.duration ?? 4, + ease, + (event.data?.mode ?? 'stage') == 'stage' + ]); + default: + // UNKNOWN + } + } + // psych conversion process + if (basicMeta.inputFormats.contains(FNF_LEGACY_PSYCH)) { + switch (event.name) { + case 'Play Animation': + /* makeEvent('Play Sprite Animation', [ + // + ]); */ + default: + // UNKNOWN + } + // TODO: Write this. + } + if (basicMeta.inputFormats.contains(FNF_CODENAME)) { + // codename conversion process + // TODO: Write this. + } + // jic + if (basicMeta.inputFormats.contains(FNF_IMAGINATIVE)) { + // TODO: Write this. + } + } + events.sort((a, b) -> return Util.sortValues(a.time, b.time)); + // trace(haxe.Json.stringify(events, '\t')); + + data = { + speed: basicMeta.scrollSpeeds.get(diffId) ?? Util.mapFirst(basicMeta.scrollSpeeds) ?? 2.6, + stage: basicMeta.extraData.get(FNFLegacyMetaValues.STAGE) ?? 'void', + fields: fields, + characters: characters, + fieldSettings: { + cameraTarget: 'enemy', + order: ['enemy', 'player'], + enemy: 'enemy', + player: 'player' + }, + events: events + } + + var bpmChanges:Array = basicMeta.bpmChanges; + var initChange:BasicBPMChange = bpmChanges.shift(); + meta = { + artist: basicMeta.extraData.get(SONG_ARTIST) ?? Moonchart.DEFAULT_ARTIST, + name: basicMeta.title, + bpm: initChange.bpm, + signature: [Std.int(initChange.stepsPerBeat), Std.int(initChange.beatsPerMeasure)], + offset: basicMeta.offset, + checkpoints: [ + for (change in bpmChanges) { + { + time: change.time, + bpm: change.bpm, + signature: [Std.int(change.stepsPerBeat), Std.int(change.beatsPerMeasure)] + } + } + ] + } + + return this; + } + + override function getNotes(?diff:String):Array { + var notes:Array = Util.makeArray(0); + for (field in data.fields) + for (note in field.notes) + notes.push({ + time: note.time, + lane: note.id, + length: note.length, + type: note.type + }); + Timing.sortNotes(notes); + return notes; + } + + override function getEvents():Array { + var events:Array = Util.makeArray(0); + for (event in data.events) + for (data in event.data) + events.push(Util.makeArrayEvent(event.time, data.name, data.params)); + Timing.sortEvents(events); + return events; + } + + function getArrowField(tags:Array):FNFImaginativeArrowField { + for (field in data.fields) + if (tags.contains(field.tag)) + return field; + return null; + } + + override function getChartMeta():BasicMetaData { + var bpmChanges:Array = [ + { + time: 0, + bpm: meta.bpm, + stepsPerBeat: meta.signature[0], + beatsPerMeasure: meta.signature[1] + } + ]; + for (checkpoint in meta.checkpoints) + bpmChanges.push({ + time: checkpoint.time, + bpm: checkpoint.bpm, + stepsPerBeat: checkpoint.signature[0], + beatsPerMeasure: checkpoint.signature[1] + }); + Timing.sortBPMChanges(bpmChanges); + return { + title: meta.name, + bpmChanges: bpmChanges, + offset: 0, + scrollSpeeds: [diffs[0] => data.speed], + extraData: [ + PLAYER_1 => getArrowField(['player', 'boyfriend', 'bf'])?.characters[0] ?? 'boyfriend', + PLAYER_2 => getArrowField(['enemy', 'opponent', 'dad'])?.characters[0] ?? 'dad', + PLAYER_3 => getArrowField(['spectator', 'gf', 'girlfriend'])?.characters[0] ?? 'gf', + SONG_ARTIST => meta.artist ?? Moonchart.DEFAULT_ARTIST, + SONG_CHARTER => Moonchart.DEFAULT_CHARTER, // no variable for this yet + STAGE => data.stage + ] + } + } + + override function fromFile(path:String, ?meta:StringInput, ?diff:FormatDifficulty):FNFImaginative { + return fromJson(Util.getText(path), Util.getText(meta), diff); + } + + override function fromJson(data:String, ?meta:StringInput, ?diff:FormatDifficulty):FNFImaginative { + super.fromJson(data, meta, diff); + Optimizer.addDefaultValues(this.data, { + fields: [for (i in 0...2) {tag: i == 0 ? 'enemy' : 'player', characters: [i == 0 ? 'enemy' : 'player'], notes: Util.makeArray(0)}], + characters: [for (i in 0...2) {tag: i == 0 ? 'enemy' : 'player', position: _UNKNOWN_}], + fieldSettings: {cameraTarget: 'player', order: ['enemy', 'player'], enemy: 'enemy', player: 'player'} + }); + return this; + } +} \ No newline at end of file