package funkin.chart; import flixel.util.FlxColor; import haxe.io.Path; import funkin.system.Song.SwagSong; import haxe.Json; import openfl.utils.Assets; class Chart { public static function loadChartMeta(songName:String, difficulty:String = "normal") { var metaPath = Paths.file('songs/${songName.toLowerCase()}/meta.json'); var metaDiffPath = Paths.file('songs/${songName.toLowerCase()}/meta-${difficulty.toLowerCase()}.json'); var data:ChartMetaData = null; var fromMods:Bool = false; for(path in [metaDiffPath, metaPath]) { if (Assets.exists(path)) { fromMods = Paths.assetsTree.existsSpecific(path, "TEXT", MODS); try { data = Json.parse(Assets.getText(path)); } catch(e) { Logs.trace('Failed to load song metadata for ${songName} ($path): ${Std.string(e)}', ERROR); } if (data != null) break; } } if (data == null) data = { name: songName, bpm: 100 }; data.setFieldDefault("name", songName); data.setFieldDefault("beatsPerMesure", 4); data.setFieldDefault("stepsPerBeat", 4); data.setFieldDefault("needsVoices", true); data.setFieldDefault("icon", "face"); data.setFieldDefault("difficulties", []); data.setFieldDefault("coopAllowed", false); data.setFieldDefault("opponentModeAllowed", false); data.setFieldDefault("displayName", data.name); data.setFieldDefault("parsedColor", data.color.getColorFromDynamic()); if (data.difficulties.length <= 0) { data.difficulties = [for(f in Paths.getFolderContent('songs/${songName.toLowerCase()}/charts/', false, !fromMods)) if (Path.extension(f = f.toUpperCase()) == "JSON") Path.withoutExtension(f)]; if (data.difficulties.length == 3) { var hasHard = false, hasNormal = false, hasEasy = false; for(d in data.difficulties) { switch(d) { case "EASY": hasEasy = true; case "NORMAL": hasNormal = true; case "HARD": hasHard = true; } } if (hasHard && hasNormal && hasEasy) { data.difficulties[0] = "EASY"; data.difficulties[1] = "NORMAL"; data.difficulties[2] = "HARD"; } } } if (data.difficulties.length <= 0) data.difficulties.push("CHART MISSING"); return data; } public static function parse(songName:String, difficulty:String = "normal"):ChartData { var chartPath = Paths.chart(songName, difficulty); var base:ChartData = { strumLines: [], noteTypes: [], events: [], meta: null, scrollSpeed: 2, stage: "stage", codenameChart: true, fromMods: Paths.assetsTree.existsSpecific(chartPath, "TEXT", MODS) }; if (!Assets.exists(chartPath)) { Logs.trace('Chart for song ${songName} ($difficulty) at "$chartPath" was not found.', ERROR, RED); return base; } var data:Dynamic = null; try { data = Json.parse(Assets.getText(chartPath)); } catch(e) { Logs.trace('Could not parse chart for song ${songName} ($difficulty): ${Std.string(e)}', ERROR, RED); } if (Reflect.hasField(data, "codenameChart") && Reflect.field(data, "codenameChart") == true) { return cast data; } else { // base fnf chart parsing var data:SwagSong = data; if (Reflect.hasField(data, "song")) { var field:Dynamic = Reflect.field(data, "song"); if (!(field is String)) data = field; } base.scrollSpeed = data.speed; base.stage = data.stage; base.strumLines.push({ characters: [data.player2], opponent: true, position: "dad", notes: [] }); base.strumLines.push({ characters: [data.player1], opponent: false, position: "boyfriend", notes: [] }); if (data.gf != "none") { base.strumLines.push({ characters: [data.gf != null ? data.gf : "gf"], opponent: true, position: "girlfriend", notes: [], visible: false, strumLinePos: 0.5 }); } var camFocusedBF:Bool = false; var beatsPerMesure:Float = data.beatsPerMesure.getDefault(4); var curBPM:Float = data.bpm; var curTime:Float = 0; var curCrochet:Float = ((60 / curBPM) * 1000); if (data.notes != null) for(section in data.notes) { if (section == null) { curTime += curCrochet * beatsPerMesure; continue; // Yoshi Engine charts crash fix } if (camFocusedBF != (camFocusedBF = section.mustHitSection)) { base.events.push({ time: curTime, type: CAM_MOVEMENT, params: [camFocusedBF ? 1 : 0] }); } if (section.sectionNotes != null) for(note in section.sectionNotes) { if (note[1] < 0) continue; var daStrumTime:Float = note[0]; var daNoteData:Int = Std.int(note[1] % 8); var daNoteType:Int = Std.int(note[1] / 8); var gottaHitNote:Bool = daNoteData >= 4 ? !section.mustHitSection : section.mustHitSection; if (note.length > 2) { if (note[3] is Int) daNoteType = addNoteType(base, data.noteTypes[Std.int(note[3])-1]); else if (note[3] is String) daNoteType = addNoteType(base, note[3]); } else { daNoteType = addNoteType(base, data.noteTypes[daNoteType-1]); } base.strumLines[gottaHitNote ? 1 : 0].notes.push({ time: daStrumTime, id: daNoteData % 4, type: daNoteType, sustainLength: note[2] }); } if (section.changeBPM && section.bpm != curBPM) { // TODO: BPM CHANGE EVENT curCrochet = ((60 / (curBPM = section.bpm)) * 1000); } curTime += curCrochet * beatsPerMesure; } } if (base.meta == null) base.meta = loadChartMeta(songName, difficulty); return base; } private static function addNoteType(chart:ChartData, noteTypeName:String):Int { switch(noteTypeName.trim()) { case "Default Note" | null | "": return 0; default: var index = chart.noteTypes.indexOf(noteTypeName); if (index > -1) return index+1; chart.noteTypes.push(noteTypeName); return chart.noteTypes.length; } } } typedef ChartData = { public var strumLines:Array; public var events:Array; public var meta:ChartMetaData; public var codenameChart:Bool; public var stage:String; public var scrollSpeed:Float; public var fromMods:Bool; public var noteTypes:Array; } typedef ChartMetaData = { public var name:String; public var bpm:Float; public var ?displayName:String; public var ?beatsPerMesure:Float; public var ?stepsPerBeat:Float; public var ?needsVoices:Bool; public var ?icon:String; public var ?color:Dynamic; public var ?difficulties:Array; public var ?coopAllowed:Bool; public var ?opponentModeAllowed:Bool; // NOT TO BE EXPORTED public var ?parsedColor:FlxColor; } typedef ChartStrumLine = { var characters:Array; var opponent:Bool; var notes:Array; var position:String; var ?strumLinePos:Float; // 0.25 = default opponent pos, 0.75 = default boyfriend pos var ?visible:Null; } typedef ChartNote = { var time:Float; // time at which the note will be hit (ms) var id:Int; // strum id of the note var type:Int; // type (int) of the note var sustainLength:Float; // sustain length of the note (ms) } typedef ChartEvent = { var time:Float; var type:ChartEventType; var params:Array; } @:enum abstract ChartEventType(Int) from Int to Int { /** * CUSTOM EVENT * Params: * - Function Name (String) * - Function Parameters... */ var CUSTOM = -1; /** * NO EVENT, MADE FOR UNKNOWN EVENTS / EVENTS THAT CANNOT BE PARSED */ var NONE = 0; /** * CAMERA MOVEMENT EVENT * Params: * - Target Strumline ID (Int) */ var CAM_MOVEMENT = 1; /** * BPM CHANGE EVENT * Params: * - Target BPM (Float) */ var BPM_CHANGE = 2; /** * ALT ANIM TOGGLE * Params: * - Strum Line which is going to be toggled (Int) * - Whenever its going to be toggled or not (Bool) */ var ALT_ANIM_TOGGLE = 3; }