diff --git a/rfc/chart-format/chartformat-7.jsonc b/rfc/chart-format/chartformat-7.jsonc new file mode 100644 index 000000000..361d15c53 --- /dev/null +++ b/rfc/chart-format/chartformat-7.jsonc @@ -0,0 +1,1203 @@ +/********** + * This is the seventh iteration of a chart format proposal workshopped by Eric and Emma. + * + * The chart format is split into several files. + */ + +/********** + * Playable: `gameplay/songs//_metadata.json` + * Non-playable: `core/music/_metadata.json` + * + * This is the first file the game searches for when loading a song. + * It provides important information such as readable name and credits, + * information on how to read the song such as BPM and time signatures, + * and custom data used by mods. + * + * This file is mandatory for playable songs, + * and is optional (but useful) for non-playable songs such as menu music. + */ +{ + /** + * The file version. + * Patch version increments (2.0.x) should only be used for bug fixes. + * Minor version increments (2.x.0) should be used for new features. + * Major version increments (x.0.0) should be used for breaking changes, + * and should include a migration function such that older charts can still be used. + * + * An additional suffix (2.0.0-custom) should be used if the chart uses additional data + * or changes that can only be utilized by a mod or engine. + * The base game will ignore this suffix, and the chart ought to function as expected in this case. + * Other engines may choose to either accomodate or ignore the additional suffix. + */ + "version": "2.0.0", + + /** + * The canonical name of the song in question and its artist. + * This is only supposed to be used to show the proper name of the song, and not for anything to do with loading files! + * In the future, this will need to be changed to a localization key. + */ + "songName": "Philly Nice", + "artist": "Kawai Sprite", + + /** + * timeFormat specifies what kind of timestamps will be used by timeChanges, events, and notes (see chart.json). + * The supported formats are `ticks, ms, and float`. `ticks` will be used in this document for demonstration. + * + * ticks: + * If the chosen format is `ticks`, an extra value called `divisions` needs to be specified as well. + * + * float: + * If the chosen format is `float`, timestamps are based around quarter notes. + * A timestamp of 4.0 is equal to one whole note. + * + * ms: + * If the chosen format is `ms`, timestamps are based on hard millisecond time. + * This means notes are entirely independent of BPM, and cannot be exactly converted from one to another if needed. + * + * Every format is converted to `ms` when the song is loaded in for playing. + * These options mostly exist to allow more flexibility while creating a chart. + * + * If the song is not playable, and there are no BPM or time signature changes, + * this value has no effect and can be excluded. + * @default "ms" + */ + "timeFormat": "ticks", + "divisions": 96, + + /** + * timeChanges is an array of objects that specify changes in BPM and time signature. It should always be sorted by `t`. + * + * The first element in the list is used to initialize the BPM and time signature, and its `t` is ignored. + * To ensure that it's at the top of the list, the first element should ideally have a `t` of 0 or below. + * + * - t: Timestamp in specified `timeFormat`. + * - b: Time in beats (int). The game will calculate further beat values based on this one, so it can do it in a simple linear fashion. + * - bpm: Quarter notes per minute (float). Cannot be empty in the first element of the list, but otherwise it's optional, + * and defaults to the value of the previous element. + * - n: Time signature numerator (int). Optional, defaults to 4. + * - d: Time signature denominator (int). Optional, defaults to 4. Should only ever be a power of two. + * - bt: Beat tuplets (Array or int). This defines how many steps each beat is divided into. + * It can either be an array of length `n` (see above) or a single integer number. Optional, defaults to 4. + */ + "timeChanges": [ + {"t": -1, "b": 0, "bpm": 175, "n": 4, "d": 4, "bt": [4, 4, 4, 4]} + ], + + /** + * Whether the song should loop or not. Only relevant for non-playable songs. + * TODO: Add properties for loopStart and loopEnd, once Flixel supports this functionality + */ + "loop": false, + + /** + * playData contains any information that is ONLY relevant to playable songs. + * It is therefore excluded when the metadata file is describing a non-playable song. + * + * The metadata file should include any information that is needed by the game before loading the chart, + * such as available difficulties and the characters/stages used. + */ + "playData": { + /** + * Informs the game that there is a secondary metadata/chart file pair for this song. + * + * For example, with the song variation `erect`, the game would search for + * `data/songs//erect_metadata.json` and load it, and later load `data/songs//erect_chart.json` if relevant. + * + * Any difficulties defined in the other metadata files will use + * the secondary file's values for `playableChars`, `stage`, etc. + * + * Additionally, the audio files that the game loads for the instrumental and vocals + * will also change, from `songs//Inst.ogg` to `songs///Inst.ogg`. + */ + "songVariations": ["erect"], + + /** + * The available difficulties for this song. + * The game should not allow attempting to load difficulties that are not specified in this array. + * + * Any custom difficulties should automatically work. + */ + "difficulties": ["easy", "normal", "hard"], + + /** + * The list of allowed playable characters, for this song and set of difficulties. + * The keys define the set of playable characters, + * with the values definining what other characters are to be loaded alongside them. + * If no character or `null` is given, there will be no character loaded for that specific role. + * + * - g: The supporting character (ex. gf). + * - o: The opponent character. + * - i: The instrumental track to use. Default is used if not specified. + */ + "playableChars": { + "bf": {"g": "gf", "o": "pico"}, + "pico": { "g": "nene", "o": "bf", "i": "Pico"}, + "tankman": { "o": "pico", "f": false} + }, + + /** + * The stage this song is played on. See `data/stages/`. + * This information is relevant to the loading state, so stage assets can reliably be loaded ahead of time. + * @default "stage" + */ + "stage": "phillyTrain", + + /** + * The note skin used by the song. + * Noteskin would have its own format, which also specifies the number of notes on the strumline. + * @default "default" + */ + "noteSkin": "default" + + /** + * Additional information for use in menus can be added here as needed. + * TODO: How do we allow mods to access this info? + */ + }, + // Not used by anything, but just a note to keep this value free so you can keep track of tool versions to help with troubleshooting. + "generatedBy": "FNF SongConverter v69" +} + +/***** + * `data/songs//chart.json` + * This is the file which contains the actual chart data, as loaded and used for gameplay. + */ +{ + /** + * The file version. See above for more information. + */ + "version": "2.0.0", + + /** + * The scroll speed used for each difficulty. + * scrollSpeed can be one of the following: + * - A single number; the chosen scroll speed will be used for all difficulties in this chart file. + * - An object; the keys of the object are the difficulties, and the values are the scroll speeds. + * The "default" key is also supported, and will be used if no scroll speed is specified for a specific difficulty. + */ + "scrollSpeed": { + "easy": 1, + "normal": 1.3, + "hard": 2, + "default": 1.3 + }, + + /** + * The list of song events in this chart. + * + * Song events represent behavior which occurs at a specific time in the song. + * - t: A number providing timestamp at which the event triggers, in the `timeFormat` specified in the metadata file. + * - e: A string specifying the event type. See below for more info. + * - v: The (optional) value for this event. Its type depends on the associated event (it could be a number, bool, string, array, object...) + * + * This list is assumed to be in timestamp order, and unexpected behavior may occur if it is not. + * + * This list will be iterated through before the song is loaded, allowing for modules to mutate them before the song starts. + * It also serves to allow for pre-loading certain assets that may be added to the game mid-song through events. + * + * Many event types will be supported, all with the goal of keeping the game's logic dynamic and synchronized with the song. + * Common actions (such as triggering a specific animation at a certain time in the song) should be supported through this mechanism, + * without having to create any additional scripts. + * + * Several possible ideas for event types include: + * - "FocusCamera": Sets which character the camera is currently focused on. + * This would fully replace "mustHitSection", which is great because it drives me insane. + * - "PlayAnimation": Forces a character to play a specific animation. + * - "SetIdleSuffix": Sets the suffix to be added to a character's idle animation. + * For example, if the value is "-alt", the character will play the "idle-alt" or "danceLeft-alt" animations, as appropriate. + * - "SetScrollSpeed": Multiply the current difficulty's scroll speed by the given value. + * For example, if this difficulty has a scroll speed of 1.3, and the event specifies a value of 1.5, the scroll speed will be set to 1.95. + * - "ScriptEvent": Fires a script event, which is received by all scripts, including character, stage, song, and module scripts. + * This should be more than sufficient for mods to implement custom events. + */ + "events": [ + {"t":0,"e":"FocusCamera","v":1}, + {"t":2304,"e":"FocusCamera","v":0}, + {"t":3840,"e":"FocusCamera","v":1}, + {"t":5376,"e":"FocusCamera","v":0}, + {"t":6912,"e":"FocusCamera","v":1}, + {"t":8448,"e":"FocusCamera","v":0}, + {"t":9984,"e":"FocusCamera","v":1}, + {"t":11520,"e":"FocusCamera","v":0}, + {"t":13056,"e":"FocusCamera","v":1}, + {"t":14592,"e":"FocusCamera","v":0}, + {"t":16128,"e":"FocusCamera","v":1}, + {"t":17664,"e":"FocusCamera","v":0}, + {"t":19200,"e":"FocusCamera","v":1}, + {"t":20736,"e":"FocusCamera","v":0}, + {"t":22272,"e":"FocusCamera","v":1}, + {"t":23808,"e":"FocusCamera","v":0} + ], + + /** + * The actual playable charts for each difficulty. + * The keys are the difficulty names, and the values are arrays of NoteData objects. + * Notes are key/value objects, rather than arrays, to allow for easier manipulation. + * Each difficulty's chart is assumed to be in timestamp order, and unexpected behavior may occur if it is not. + * Note that optional keys should be scrubbed to save space. + * + * - t: Timestamp in the timeFormat specified in the metadata file. + * - d: Index on the strumline (i.e. direction). + * The `strumlineSize` value is determined by the song's `noteSkin`. + * Performing `floor(d / strumlineSize)` specifies which strumline the note appears on. + * (with 0 being the player, 1 being the opponent, etc.). + * Performing `d % strumlineSize` specifies the actual note direction (index) on the strumline. + * - l: Hold length in specified timeFormat. If none is given, no sustain trail is created. + * - k: Kind of this note. If unspecified, defaults to `"normal"`. + * This can allow the note to either include custom behavior defined in a module script, + * or have a custom appearance defined by the noteSkin (or both). + */ + "notes": { + "easy": [ + {"t":816,"d":6}, + {"t":960,"d":5,"l":72}, + {"t":1056,"d":7,"l":48}, + {"t":1200,"d":6}, + {"t":1344,"d":5,"l":72}, + {"t":1440,"d":7,"l":48}, + {"t":1584,"d":6}, + {"t":1728,"d":5,"l":72}, + {"t":1824,"d":7,"l":48}, + {"t":1920,"d":4}, + {"t":2016,"d":6}, + {"t":2112,"d":6}, + {"t":2208,"d":4}, + {"t":2352,"d":2}, + {"t":2496,"d":1,"l":72}, + {"t":2592,"d":3,"l":48}, + {"t":2736,"d":2}, + {"t":2880,"d":1,"l":72}, + {"t":2976,"d":3,"l":48}, + {"t":3120,"d":2}, + {"t":3264,"d":1,"l":72}, + {"t":3360,"d":3,"l":48}, + {"t":3456,"d":0}, + {"t":3552,"d":2}, + {"t":3648,"d":2}, + {"t":3744,"d":0}, + {"t":3936,"d":6}, + {"t":4032,"d":4}, + {"t":4320,"d":7}, + {"t":4416,"d":6}, + {"t":4560,"d":5}, + {"t":4704,"d":7}, + {"t":4800,"d":4}, + {"t":4896,"d":7}, + {"t":4992,"d":7}, + {"t":5088,"d":5}, + {"t":5184,"d":7}, + {"t":5472,"d":2}, + {"t":5568,"d":0}, + {"t":5856,"d":3}, + {"t":5952,"d":2}, + {"t":6096,"d":1}, + {"t":6240,"d":3}, + {"t":6336,"d":0}, + {"t":6432,"d":3}, + {"t":6528,"d":3}, + {"t":6624,"d":1}, + {"t":6720,"d":3}, + {"t":6912,"d":6}, + {"t":7104,"d":4}, + {"t":7200,"d":7}, + {"t":7392,"d":7}, + {"t":7488,"d":6}, + {"t":7632,"d":5}, + {"t":7776,"d":7}, + {"t":7872,"d":4}, + {"t":7968,"d":7}, + {"t":8064,"d":7}, + {"t":8160,"d":5}, + {"t":8256,"d":7}, + {"t":8448,"d":2}, + {"t":8640,"d":0}, + {"t":8736,"d":3}, + {"t":8928,"d":3}, + {"t":9024,"d":2}, + {"t":9168,"d":1}, + {"t":9312,"d":3}, + {"t":9408,"d":0}, + {"t":9504,"d":3}, + {"t":9600,"d":3}, + {"t":9696,"d":1}, + {"t":9792,"d":3}, + {"t":9984,"d":6}, + {"t":10176,"d":6}, + {"t":10272,"d":4}, + {"t":10368,"d":6}, + {"t":10560,"d":6}, + {"t":10656,"d":4}, + {"t":10752,"d":6}, + {"t":10944,"d":6}, + {"t":11040,"d":4}, + {"t":11136,"d":6}, + {"t":11328,"d":6}, + {"t":11424,"d":4}, + {"t":11520,"d":2}, + {"t":11712,"d":2}, + {"t":11808,"d":0}, + {"t":11904,"d":2}, + {"t":12096,"d":2}, + {"t":12192,"d":0}, + {"t":12288,"d":2}, + {"t":12480,"d":2}, + {"t":12576,"d":0}, + {"t":12672,"d":2}, + {"t":12864,"d":2}, + {"t":12960,"d":0}, + {"t":13056,"d":6}, + {"t":13248,"d":6}, + {"t":13344,"d":4}, + {"t":13440,"d":6}, + {"t":13632,"d":6}, + {"t":13728,"d":4}, + {"t":13824,"d":6}, + {"t":14016,"d":6}, + {"t":14112,"d":4}, + {"t":14208,"d":7}, + {"t":14400,"d":6}, + {"t":14496,"d":4}, + {"t":14592,"d":2}, + {"t":14784,"d":2}, + {"t":14880,"d":0}, + {"t":14976,"d":2}, + {"t":15168,"d":2}, + {"t":15264,"d":0}, + {"t":15360,"d":2}, + {"t":15552,"d":2}, + {"t":15648,"d":0}, + {"t":15744,"d":3}, + {"t":15936,"d":2}, + {"t":16032,"d":0}, + {"t":16128,"d":7}, + {"t":16320,"d":6}, + {"t":16512,"d":6}, + {"t":16608,"d":7}, + {"t":16704,"d":6}, + {"t":16896,"d":7}, + {"t":17088,"d":6}, + {"t":17232,"d":4}, + {"t":17280,"d":6}, + {"t":17376,"d":4}, + {"t":17472,"d":6}, + {"t":17568,"d":4}, + {"t":17664,"d":3}, + {"t":17856,"d":2}, + {"t":18048,"d":2}, + {"t":18144,"d":3}, + {"t":18240,"d":2}, + {"t":18432,"d":3}, + {"t":18624,"d":2}, + {"t":18768,"d":0}, + {"t":18816,"d":2}, + {"t":18912,"d":0}, + {"t":19008,"d":2}, + {"t":19104,"d":0}, + {"t":19200,"d":6}, + {"t":19392,"d":6}, + {"t":19584,"d":4}, + {"t":19776,"d":6}, + {"t":19968,"d":7}, + {"t":20160,"d":6}, + {"t":20256,"d":4}, + {"t":20352,"d":7}, + {"t":20544,"d":6}, + {"t":20640,"d":4}, + {"t":20736,"d":2}, + {"t":20928,"d":2}, + {"t":21120,"d":0}, + {"t":21312,"d":2}, + {"t":21504,"d":3}, + {"t":21696,"d":2}, + {"t":21792,"d":0}, + {"t":21888,"d":3}, + {"t":22080,"d":2}, + {"t":22176,"d":0}, + {"t":22272,"d":6}, + {"t":22464,"d":6}, + {"t":22560,"d":4}, + {"t":22656,"d":6}, + {"t":22848,"d":6}, + {"t":22944,"d":4}, + {"t":23040,"d":6}, + {"t":23232,"d":6}, + {"t":23328,"d":4}, + {"t":23424,"d":7}, + {"t":23616,"d":6}, + {"t":23712,"d":4}, + {"t":23808,"d":2}, + {"t":24000,"d":2}, + {"t":24096,"d":0}, + {"t":24192,"d":2}, + {"t":24384,"d":2}, + {"t":24480,"d":0}, + {"t":24576,"d":2}, + {"t":24768,"d":2}, + {"t":24864,"d":0}, + {"t":24960,"d":3}, + {"t":25152,"d":2}, + {"t":25248,"d":0}, + {"t":25296,"d":0}, + {"t":25344,"d":1} + ], + "normal": [ + {"t":816,"d":6}, + {"t":960,"d":5,"l":72}, + {"t":1056,"d":7,"l":48}, + {"t":1200,"d":6}, + {"t":1344,"d":5,"l":72}, + {"t":1440,"d":7,"l":48}, + {"t":1584,"d":6}, + {"t":1728,"d":5,"l":72}, + {"t":1824,"d":7,"l":48}, + {"t":1920,"d":4}, + {"t":2112,"d":6}, + {"t":2208,"d":4}, + {"t":2352,"d":2}, + {"t":2496,"d":1,"l":72}, + {"t":2592,"d":3,"l":48}, + {"t":2736,"d":2}, + {"t":2880,"d":1,"l":72}, + {"t":2976,"d":3,"l":48}, + {"t":3120,"d":2}, + {"t":3264,"d":1,"l":72}, + {"t":3360,"d":3,"l":48}, + {"t":3456,"d":0}, + {"t":3552,"d":2}, + {"t":3648,"d":2}, + {"t":3744,"d":0}, + {"t":3936,"d":6}, + {"t":4032,"d":4}, + {"t":4128,"d":7}, + {"t":4176,"d":6}, + {"t":4320,"d":7}, + {"t":4416,"d":6}, + {"t":4512,"d":4}, + {"t":4560,"d":5}, + {"t":4656,"d":4}, + {"t":4704,"d":7}, + {"t":4800,"d":4}, + {"t":4896,"d":7}, + {"t":4992,"d":7}, + {"t":5088,"d":5}, + {"t":5184,"d":6}, + {"t":5280,"d":7}, + {"t":5472,"d":2}, + {"t":5568,"d":0}, + {"t":5664,"d":3}, + {"t":5712,"d":2}, + {"t":5856,"d":3}, + {"t":5952,"d":2}, + {"t":6048,"d":0}, + {"t":6096,"d":1}, + {"t":6192,"d":0}, + {"t":6240,"d":3}, + {"t":6336,"d":0}, + {"t":6432,"d":3}, + {"t":6528,"d":3}, + {"t":6624,"d":1}, + {"t":6720,"d":2}, + {"t":6816,"d":3}, + {"t":6912,"d":6}, + {"t":6960,"d":6}, + {"t":7008,"d":6}, + {"t":7104,"d":4}, + {"t":7200,"d":7}, + {"t":7248,"d":6}, + {"t":7392,"d":7}, + {"t":7488,"d":6}, + {"t":7584,"d":4}, + {"t":7632,"d":5}, + {"t":7728,"d":4}, + {"t":7776,"d":7}, + {"t":7824,"d":7}, + {"t":7872,"d":4}, + {"t":7920,"d":4}, + {"t":7968,"d":7}, + {"t":8016,"d":7}, + {"t":8064,"d":7}, + {"t":8160,"d":5}, + {"t":8256,"d":6}, + {"t":8352,"d":7}, + {"t":8448,"d":2}, + {"t":8496,"d":2}, + {"t":8544,"d":2}, + {"t":8640,"d":0}, + {"t":8736,"d":3}, + {"t":8784,"d":2}, + {"t":8928,"d":3}, + {"t":9024,"d":2}, + {"t":9120,"d":0}, + {"t":9168,"d":1}, + {"t":9264,"d":0}, + {"t":9312,"d":3}, + {"t":9360,"d":3}, + {"t":9408,"d":0}, + {"t":9456,"d":0}, + {"t":9504,"d":3}, + {"t":9552,"d":3}, + {"t":9600,"d":3}, + {"t":9696,"d":1}, + {"t":9792,"d":2}, + {"t":9888,"d":3}, + {"t":9984,"d":6}, + {"t":10176,"d":6}, + {"t":10224,"d":7}, + {"t":10272,"d":4}, + {"t":10320,"d":7}, + {"t":10368,"d":6}, + {"t":10560,"d":6}, + {"t":10608,"d":7}, + {"t":10656,"d":4}, + {"t":10704,"d":5}, + {"t":10752,"d":6}, + {"t":10944,"d":6}, + {"t":10992,"d":7}, + {"t":11040,"d":4}, + {"t":11088,"d":7}, + {"t":11136,"d":6}, + {"t":11328,"d":6}, + {"t":11376,"d":7}, + {"t":11424,"d":4}, + {"t":11472,"d":4}, + {"t":11520,"d":2}, + {"t":11712,"d":2}, + {"t":11760,"d":3}, + {"t":11808,"d":0}, + {"t":11856,"d":3}, + {"t":11904,"d":2}, + {"t":12096,"d":2}, + {"t":12144,"d":3}, + {"t":12192,"d":0}, + {"t":12240,"d":1}, + {"t":12288,"d":2}, + {"t":12480,"d":2}, + {"t":12528,"d":3}, + {"t":12576,"d":0}, + {"t":12624,"d":3}, + {"t":12672,"d":2}, + {"t":12864,"d":2}, + {"t":12912,"d":3}, + {"t":12960,"d":0}, + {"t":13008,"d":0}, + {"t":13056,"d":6}, + {"t":13152,"d":5}, + {"t":13248,"d":6}, + {"t":13296,"d":7}, + {"t":13344,"d":4}, + {"t":13392,"d":7}, + {"t":13440,"d":6}, + {"t":13536,"d":6}, + {"t":13632,"d":6}, + {"t":13680,"d":7}, + {"t":13728,"d":4}, + {"t":13776,"d":5}, + {"t":13824,"d":6}, + {"t":13920,"d":5}, + {"t":14016,"d":6}, + {"t":14064,"d":7}, + {"t":14112,"d":4}, + {"t":14160,"d":7}, + {"t":14208,"d":6}, + {"t":14304,"d":6}, + {"t":14400,"d":6}, + {"t":14448,"d":7}, + {"t":14496,"d":4}, + {"t":14544,"d":4}, + {"t":14592,"d":2}, + {"t":14688,"d":1}, + {"t":14784,"d":2}, + {"t":14832,"d":3}, + {"t":14880,"d":0}, + {"t":14928,"d":3}, + {"t":14976,"d":2}, + {"t":15072,"d":2}, + {"t":15168,"d":2}, + {"t":15216,"d":3}, + {"t":15264,"d":0}, + {"t":15312,"d":1}, + {"t":15360,"d":2}, + {"t":15456,"d":1}, + {"t":15552,"d":2}, + {"t":15600,"d":3}, + {"t":15648,"d":0}, + {"t":15696,"d":3}, + {"t":15744,"d":2}, + {"t":15840,"d":2}, + {"t":15936,"d":2}, + {"t":15984,"d":3}, + {"t":16032,"d":0}, + {"t":16080,"d":0}, + {"t":16128,"d":7}, + {"t":16224,"d":5}, + {"t":16320,"d":6}, + {"t":16464,"d":4}, + {"t":16512,"d":6}, + {"t":16608,"d":7}, + {"t":16704,"d":6}, + {"t":16848,"d":5}, + {"t":16896,"d":7}, + {"t":16992,"d":5}, + {"t":17088,"d":6}, + {"t":17232,"d":4}, + {"t":17280,"d":6}, + {"t":17376,"d":4}, + {"t":17472,"d":6}, + {"t":17568,"d":4}, + {"t":17616,"d":6}, + {"t":17664,"d":3}, + {"t":17760,"d":1}, + {"t":17856,"d":2}, + {"t":18000,"d":0}, + {"t":18048,"d":2}, + {"t":18144,"d":3}, + {"t":18240,"d":2}, + {"t":18384,"d":1}, + {"t":18432,"d":3}, + {"t":18528,"d":1}, + {"t":18624,"d":2}, + {"t":18768,"d":0}, + {"t":18816,"d":2}, + {"t":18912,"d":0}, + {"t":19008,"d":2}, + {"t":19104,"d":0}, + {"t":19152,"d":2}, + {"t":19200,"d":6}, + {"t":19296,"d":5}, + {"t":19392,"d":6}, + {"t":19488,"d":5}, + {"t":19584,"d":4}, + {"t":19680,"d":5}, + {"t":19776,"d":6}, + {"t":19872,"d":6}, + {"t":19968,"d":7}, + {"t":20064,"d":4}, + {"t":20160,"d":6}, + {"t":20256,"d":4}, + {"t":20352,"d":7}, + {"t":20448,"d":6}, + {"t":20544,"d":6}, + {"t":20592,"d":7}, + {"t":20640,"d":4}, + {"t":20688,"d":5}, + {"t":20736,"d":2}, + {"t":20832,"d":1}, + {"t":20928,"d":2}, + {"t":21024,"d":1}, + {"t":21120,"d":0}, + {"t":21216,"d":1}, + {"t":21312,"d":2}, + {"t":21408,"d":2}, + {"t":21504,"d":3}, + {"t":21600,"d":0}, + {"t":21696,"d":2}, + {"t":21792,"d":0}, + {"t":21888,"d":3}, + {"t":21984,"d":2}, + {"t":22080,"d":2}, + {"t":22128,"d":3}, + {"t":22176,"d":0}, + {"t":22224,"d":1}, + {"t":22272,"d":6}, + {"t":22368,"d":5}, + {"t":22464,"d":6}, + {"t":22512,"d":7}, + {"t":22560,"d":4}, + {"t":22608,"d":7}, + {"t":22656,"d":6}, + {"t":22752,"d":6}, + {"t":22848,"d":6}, + {"t":22896,"d":7}, + {"t":22944,"d":4}, + {"t":22992,"d":5}, + {"t":23040,"d":6}, + {"t":23136,"d":5}, + {"t":23232,"d":6}, + {"t":23280,"d":7}, + {"t":23328,"d":4}, + {"t":23376,"d":7}, + {"t":23424,"d":6}, + {"t":23520,"d":6}, + {"t":23616,"d":6}, + {"t":23664,"d":7}, + {"t":23712,"d":4}, + {"t":23760,"d":4}, + {"t":23808,"d":2}, + {"t":23904,"d":1}, + {"t":24000,"d":2}, + {"t":24048,"d":3}, + {"t":24096,"d":0}, + {"t":24144,"d":3}, + {"t":24192,"d":2}, + {"t":24288,"d":2}, + {"t":24384,"d":2}, + {"t":24432,"d":3}, + {"t":24480,"d":0}, + {"t":24528,"d":1}, + {"t":24576,"d":2}, + {"t":24672,"d":1}, + {"t":24768,"d":2}, + {"t":24816,"d":3}, + {"t":24864,"d":0}, + {"t":24912,"d":3}, + {"t":24960,"d":2}, + {"t":25056,"d":2}, + {"t":25152,"d":2}, + {"t":25200,"d":3}, + {"t":25248,"d":0}, + {"t":25296,"d":0}, + {"t":25344,"d":1} + ], + "hard": [ + {"t":816,"d":6}, + {"t":864,"d":4}, + {"t":960,"d":5,"l":72}, + {"t":1056,"d":7,"l":48}, + {"t":1200,"d":6}, + {"t":1248,"d":4}, + {"t":1344,"d":5,"l":72}, + {"t":1440,"d":7,"l":48}, + {"t":1584,"d":6}, + {"t":1632,"d":4}, + {"t":1728,"d":5,"l":72}, + {"t":1824,"d":7,"l":48}, + {"t":1920,"d":4}, + {"t":1968,"d":7}, + {"t":2016,"d":6}, + {"t":2112,"d":6}, + {"t":2208,"d":4}, + {"t":2256,"d":4}, + {"t":2352,"d":2}, + {"t":2400,"d":0}, + {"t":2496,"d":1,"l":72}, + {"t":2592,"d":3,"l":48}, + {"t":2736,"d":2}, + {"t":2784,"d":0}, + {"t":2880,"d":1,"l":72}, + {"t":2976,"d":3,"l":48}, + {"t":3120,"d":2}, + {"t":3168,"d":0}, + {"t":3264,"d":1,"l":72}, + {"t":3360,"d":3,"l":48}, + {"t":3456,"d":0}, + {"t":3504,"d":3}, + {"t":3552,"d":2}, + {"t":3648,"d":2}, + {"t":3744,"d":0}, + {"t":3888,"d":6}, + {"t":3936,"d":4}, + {"t":4032,"d":4}, + {"t":4128,"d":7}, + {"t":4176,"d":5}, + {"t":4272,"d":7}, + {"t":4320,"d":4}, + {"t":4416,"d":6}, + {"t":4512,"d":4}, + {"t":4560,"d":5}, + {"t":4656,"d":4}, + {"t":4704,"d":5}, + {"t":4752,"d":7}, + {"t":4800,"d":5}, + {"t":4848,"d":4}, + {"t":4896,"d":5}, + {"t":4944,"d":7}, + {"t":4968,"d":4}, + {"t":4992,"d":7}, + {"t":5040,"d":7}, + {"t":5088,"d":5}, + {"t":5184,"d":6}, + {"t":5280,"d":6}, + {"t":5328,"d":7}, + {"t":5424,"d":2}, + {"t":5472,"d":0}, + {"t":5568,"d":0}, + {"t":5664,"d":3}, + {"t":5712,"d":1}, + {"t":5808,"d":3}, + {"t":5856,"d":0}, + {"t":5952,"d":2}, + {"t":6048,"d":0}, + {"t":6096,"d":1}, + {"t":6192,"d":0}, + {"t":6240,"d":1}, + {"t":6288,"d":3}, + {"t":6336,"d":1}, + {"t":6384,"d":0}, + {"t":6432,"d":1}, + {"t":6480,"d":3}, + {"t":6504,"d":0}, + {"t":6528,"d":3}, + {"t":6576,"d":3}, + {"t":6624,"d":1}, + {"t":6720,"d":2}, + {"t":6816,"d":2}, + {"t":6864,"d":3}, + {"t":6912,"d":6}, + {"t":6960,"d":6}, + {"t":7008,"d":4}, + {"t":7104,"d":4}, + {"t":7200,"d":7}, + {"t":7248,"d":5}, + {"t":7344,"d":7}, + {"t":7392,"d":4}, + {"t":7488,"d":6}, + {"t":7584,"d":4}, + {"t":7632,"d":5}, + {"t":7728,"d":4}, + {"t":7776,"d":5}, + {"t":7824,"d":7}, + {"t":7872,"d":5}, + {"t":7920,"d":4}, + {"t":7968,"d":5}, + {"t":8016,"d":7}, + {"t":8040,"d":4}, + {"t":8064,"d":7}, + {"t":8112,"d":7}, + {"t":8160,"d":5}, + {"t":8256,"d":6}, + {"t":8352,"d":6}, + {"t":8400,"d":7}, + {"t":8448,"d":2}, + {"t":8496,"d":2}, + {"t":8544,"d":0}, + {"t":8640,"d":0}, + {"t":8736,"d":3}, + {"t":8784,"d":1}, + {"t":8880,"d":3}, + {"t":8928,"d":0}, + {"t":9024,"d":2}, + {"t":9120,"d":0}, + {"t":9168,"d":1}, + {"t":9264,"d":0}, + {"t":9312,"d":1}, + {"t":9360,"d":3}, + {"t":9408,"d":1}, + {"t":9456,"d":0}, + {"t":9504,"d":1}, + {"t":9552,"d":3}, + {"t":9576,"d":0}, + {"t":9600,"d":3}, + {"t":9648,"d":3}, + {"t":9696,"d":1}, + {"t":9792,"d":2}, + {"t":9888,"d":2}, + {"t":9936,"d":3}, + {"t":9984,"d":6}, + {"t":10032,"d":7}, + {"t":10080,"d":5}, + {"t":10128,"d":4}, + {"t":10176,"d":6}, + {"t":10224,"d":7}, + {"t":10272,"d":4}, + {"t":10320,"d":7}, + {"t":10368,"d":6}, + {"t":10416,"d":7}, + {"t":10464,"d":6}, + {"t":10512,"d":4}, + {"t":10560,"d":6}, + {"t":10608,"d":7}, + {"t":10656,"d":4}, + {"t":10704,"d":5}, + {"t":10752,"d":6}, + {"t":10800,"d":7}, + {"t":10848,"d":5}, + {"t":10896,"d":4}, + {"t":10944,"d":6}, + {"t":10992,"d":7}, + {"t":11040,"d":4}, + {"t":11088,"d":7}, + {"t":11136,"d":6}, + {"t":11184,"d":7}, + {"t":11232,"d":6}, + {"t":11280,"d":4}, + {"t":11328,"d":6}, + {"t":11376,"d":7}, + {"t":11424,"d":4}, + {"t":11472,"d":4}, + {"t":11520,"d":2}, + {"t":11568,"d":3}, + {"t":11616,"d":1}, + {"t":11664,"d":0}, + {"t":11712,"d":2}, + {"t":11760,"d":3}, + {"t":11808,"d":0}, + {"t":11856,"d":3}, + {"t":11904,"d":2}, + {"t":11952,"d":3}, + {"t":12000,"d":2}, + {"t":12048,"d":0}, + {"t":12096,"d":2}, + {"t":12144,"d":3}, + {"t":12192,"d":0}, + {"t":12240,"d":1}, + {"t":12288,"d":2}, + {"t":12336,"d":3}, + {"t":12384,"d":1}, + {"t":12432,"d":0}, + {"t":12480,"d":2}, + {"t":12528,"d":3}, + {"t":12576,"d":0}, + {"t":12624,"d":3}, + {"t":12672,"d":2}, + {"t":12720,"d":3}, + {"t":12768,"d":2}, + {"t":12816,"d":0}, + {"t":12864,"d":2}, + {"t":12912,"d":3}, + {"t":12960,"d":0}, + {"t":13008,"d":0}, + {"t":13056,"d":6}, + {"t":13104,"d":7}, + {"t":13152,"d":5}, + {"t":13200,"d":4}, + {"t":13248,"d":6}, + {"t":13296,"d":7}, + {"t":13344,"d":4}, + {"t":13392,"d":7}, + {"t":13440,"d":6}, + {"t":13488,"d":7}, + {"t":13536,"d":6}, + {"t":13584,"d":4}, + {"t":13632,"d":6}, + {"t":13680,"d":7}, + {"t":13728,"d":4}, + {"t":13776,"d":4}, + {"t":13824,"d":6}, + {"t":13872,"d":7}, + {"t":13920,"d":5}, + {"t":13968,"d":4}, + {"t":14016,"d":6}, + {"t":14064,"d":7}, + {"t":14112,"d":4}, + {"t":14160,"d":7}, + {"t":14208,"d":6}, + {"t":14256,"d":7}, + {"t":14304,"d":6}, + {"t":14352,"d":4}, + {"t":14400,"d":6}, + {"t":14448,"d":7}, + {"t":14496,"d":4}, + {"t":14544,"d":4}, + {"t":14592,"d":2}, + {"t":14640,"d":3}, + {"t":14688,"d":1}, + {"t":14736,"d":0}, + {"t":14784,"d":2}, + {"t":14832,"d":3}, + {"t":14880,"d":0}, + {"t":14928,"d":3}, + {"t":14976,"d":2}, + {"t":15024,"d":3}, + {"t":15072,"d":2}, + {"t":15120,"d":0}, + {"t":15168,"d":2}, + {"t":15216,"d":3}, + {"t":15264,"d":0}, + {"t":15312,"d":0}, + {"t":15360,"d":2}, + {"t":15408,"d":3}, + {"t":15456,"d":1}, + {"t":15504,"d":0}, + {"t":15552,"d":2}, + {"t":15600,"d":3}, + {"t":15648,"d":0}, + {"t":15696,"d":3}, + {"t":15744,"d":2}, + {"t":15792,"d":3}, + {"t":15840,"d":2}, + {"t":15888,"d":0}, + {"t":15936,"d":2}, + {"t":15984,"d":3}, + {"t":16032,"d":0}, + {"t":16080,"d":0}, + {"t":16128,"d":7}, + {"t":16176,"d":7}, + {"t":16224,"d":5}, + {"t":16272,"d":5}, + {"t":16320,"d":6}, + {"t":16368,"d":6}, + {"t":16464,"d":4}, + {"t":16512,"d":6}, + {"t":16560,"d":7}, + {"t":16584,"d":4}, + {"t":16608,"d":7}, + {"t":16656,"d":7}, + {"t":16704,"d":6}, + {"t":16752,"d":4}, + {"t":16800,"d":4}, + {"t":16848,"d":5}, + {"t":16896,"d":7}, + {"t":16944,"d":7}, + {"t":16968,"d":4}, + {"t":16992,"d":7}, + {"t":17040,"d":5}, + {"t":17088,"d":6}, + {"t":17136,"d":6}, + {"t":17184,"d":4}, + {"t":17232,"d":4}, + {"t":17280,"d":6}, + {"t":17328,"d":7}, + {"t":17376,"d":4}, + {"t":17424,"d":7}, + {"t":17472,"d":6}, + {"t":17568,"d":5}, + {"t":17616,"d":7}, + {"t":17664,"d":3}, + {"t":17712,"d":3}, + {"t":17760,"d":1}, + {"t":17808,"d":1}, + {"t":17856,"d":2}, + {"t":17904,"d":2}, + {"t":18000,"d":0}, + {"t":18048,"d":2}, + {"t":18096,"d":3}, + {"t":18120,"d":0}, + {"t":18144,"d":3}, + {"t":18192,"d":3}, + {"t":18240,"d":2}, + {"t":18288,"d":0}, + {"t":18336,"d":0}, + {"t":18384,"d":1}, + {"t":18432,"d":3}, + {"t":18480,"d":3}, + {"t":18504,"d":0}, + {"t":18528,"d":3}, + {"t":18576,"d":1}, + {"t":18624,"d":2}, + {"t":18672,"d":2}, + {"t":18720,"d":0}, + {"t":18768,"d":0}, + {"t":18816,"d":2}, + {"t":18864,"d":3}, + {"t":18912,"d":0}, + {"t":18960,"d":3}, + {"t":19008,"d":2}, + {"t":19104,"d":1}, + {"t":19152,"d":3}, + {"t":19200,"d":6}, + {"t":19296,"d":5}, + {"t":19344,"d":7}, + {"t":19392,"d":6}, + {"t":19488,"d":5}, + {"t":19536,"d":7}, + {"t":19584,"d":4}, + {"t":19632,"d":7}, + {"t":19680,"d":5}, + {"t":19728,"d":7}, + {"t":19776,"d":6}, + {"t":19872,"d":6}, + {"t":19920,"d":6}, + {"t":19968,"d":7}, + {"t":20016,"d":5}, + {"t":20064,"d":4}, + {"t":20112,"d":7}, + {"t":20160,"d":6}, + {"t":20256,"d":4}, + {"t":20304,"d":4}, + {"t":20352,"d":7}, + {"t":20400,"d":7}, + {"t":20448,"d":6}, + {"t":20496,"d":4}, + {"t":20544,"d":6}, + {"t":20592,"d":7}, + {"t":20640,"d":4}, + {"t":20688,"d":5}, + {"t":20736,"d":2}, + {"t":20832,"d":1}, + {"t":20880,"d":3}, + {"t":20928,"d":2}, + {"t":21024,"d":1}, + {"t":21072,"d":3}, + {"t":21120,"d":0}, + {"t":21168,"d":3}, + {"t":21216,"d":1}, + {"t":21264,"d":3}, + {"t":21312,"d":2}, + {"t":21408,"d":2}, + {"t":21456,"d":2}, + {"t":21504,"d":3}, + {"t":21552,"d":1}, + {"t":21600,"d":0}, + {"t":21648,"d":3}, + {"t":21696,"d":2}, + {"t":21792,"d":0}, + {"t":21840,"d":0}, + {"t":21888,"d":3}, + {"t":21936,"d":3}, + {"t":21984,"d":2}, + {"t":22032,"d":0}, + {"t":22080,"d":2}, + {"t":22128,"d":3}, + {"t":22176,"d":0}, + {"t":22224,"d":1}, + {"t":22272,"d":6}, + {"t":22320,"d":7}, + {"t":22368,"d":5}, + {"t":22416,"d":4}, + {"t":22464,"d":6}, + {"t":22512,"d":7}, + {"t":22560,"d":4}, + {"t":22608,"d":7}, + {"t":22656,"d":6}, + {"t":22704,"d":7}, + {"t":22752,"d":6}, + {"t":22800,"d":4}, + {"t":22848,"d":6}, + {"t":22896,"d":7}, + {"t":22944,"d":4}, + {"t":22992,"d":4}, + {"t":23040,"d":6}, + {"t":23088,"d":7}, + {"t":23136,"d":5}, + {"t":23184,"d":4}, + {"t":23232,"d":6}, + {"t":23280,"d":7}, + {"t":23328,"d":4}, + {"t":23376,"d":7}, + {"t":23424,"d":6}, + {"t":23472,"d":7}, + {"t":23520,"d":6}, + {"t":23568,"d":4}, + {"t":23616,"d":6}, + {"t":23664,"d":7}, + {"t":23712,"d":4}, + {"t":23760,"d":4}, + {"t":23808,"d":2}, + {"t":23856,"d":3}, + {"t":23904,"d":1}, + {"t":23952,"d":0}, + {"t":24000,"d":2}, + {"t":24048,"d":3}, + {"t":24096,"d":0}, + {"t":24144,"d":3}, + {"t":24192,"d":2}, + {"t":24240,"d":3}, + {"t":24288,"d":2}, + {"t":24336,"d":0}, + {"t":24384,"d":2}, + {"t":24432,"d":3}, + {"t":24480,"d":0}, + {"t":24528,"d":0}, + {"t":24576,"d":2}, + {"t":24624,"d":3}, + {"t":24672,"d":1}, + {"t":24720,"d":0}, + {"t":24768,"d":2}, + {"t":24816,"d":3}, + {"t":24864,"d":0}, + {"t":24912,"d":3}, + {"t":24960,"d":2}, + {"t":25008,"d":3}, + {"t":25056,"d":2}, + {"t":25104,"d":0}, + {"t":25152,"d":2}, + {"t":25200,"d":3}, + {"t":25248,"d":0}, + {"t":25296,"d":0}, + {"t":25344,"d":1} + ] + }, + + // Not used by anything, but just a note to keep this value free so you can keep track of tool versions to help with troubleshooting. + "generatedBy": "FNF SongConverter v69" +} diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index f5c17fc17..9a95a9863 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -72,19 +72,20 @@ class Conductor return crochet / 4; } - public static var currentBeat(get, null):Int; + /** + * Current position in the song, in beats. + **/ + public static var currentBeat(default, null):Int; - static function get_currentBeat():Int - { - return currentBeat; - } + /** + * Current position in the song, in steps. + */ + public static var currentStep(default, null):Int; - public static var currentStep(get, null):Int; - - static function get_currentStep():Int - { - return currentStep; - } + /** + * Current position in the song, in steps and fractions of a step. + */ + public static var currentStepTime(default, null):Float; public static var beatHit(default, null):FlxSignal = new FlxSignal(); public static var stepHit(default, null):FlxSignal = new FlxSignal(); @@ -94,6 +95,9 @@ class Conductor public static var audioOffset:Float = 0; public static var offset:Float = 0; + // TODO: Add code to update this. + public static var beatsPerMeasure:Int = 4; + private function new() { } @@ -116,7 +120,13 @@ class Conductor return lastChange; } - @:deprecated // Use loadSong with metadata files instead. + /** + * Forcibly defines the current BPM of the song. + * Useful for things like the chart editor that need to manipulate BPM in real time. + * + * WARNING: Avoid this for things like setting the BPM of the title screen music, + * you should have a metadata file for it instead. + */ public static function forceBPM(bpm:Float) { Conductor.bpmOverride = bpm; @@ -156,13 +166,15 @@ class Conductor } else if (currentTimeChange != null) { - currentStep = Math.floor((currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepCrochet); + currentStepTime = (currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepCrochet; + currentStep = Math.floor(currentStepTime); currentBeat = Math.floor(currentStep / 4); } else { // Assume a constant BPM equal to the forced value. - currentStep = Math.floor((songPosition) / stepCrochet); + currentStepTime = (songPosition / stepCrochet); + currentStep = Math.floor(currentStepTime); currentBeat = Math.floor(currentStep / 4); } @@ -209,23 +221,6 @@ class Conductor for (currentTimeChange in songTimeChanges) { - // var prevTimeChange:SongTimeChange = timeChanges.length == 0 ? null : timeChanges[timeChanges.length - 1]; - - /* - if (prevTimeChange != null) - { - var deltaTime:Float = currentTimeChange.timeStamp - prevTimeChange.timeStamp; - var deltaSteps:Int = Math.round(deltaTime / (60 / prevTimeChange.bpm) * 1000 / 4); - - currentTimeChange.stepTime = prevTimeChange.stepTime + deltaSteps; - } - else - { - // We know the time and steps of this time change is 0, since this is the first time change. - currentTimeChange.stepTime = 0; - } - */ - timeChanges.push(currentTimeChange); } @@ -233,4 +228,39 @@ class Conductor // Done. } + + /** + * Given a time in milliseconds, return a time in steps. + */ + public static function getTimeInSteps(ms:Float):Int + { + if (timeChanges.length == 0) + { + // Assume a constant BPM equal to the forced value. + return Math.floor(ms / stepCrochet); + } + else + { + var resultStep:Int = 0; + + var lastTimeChange:SongTimeChange = timeChanges[0]; + for (timeChange in timeChanges) + { + if (ms >= timeChange.timeStamp) + { + lastTimeChange = timeChange; + resultStep = lastTimeChange.beatTime * 4; + } + else + { + // This time change is after the requested time. + break; + } + } + + resultStep += Math.floor((ms - lastTimeChange.timeStamp) / stepCrochet); + + return resultStep; + } + } } diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx index 79f3f217a..a62cdf199 100644 --- a/source/funkin/LatencyState.hx +++ b/source/funkin/LatencyState.hx @@ -9,7 +9,7 @@ import flixel.system.FlxSound; import flixel.system.debug.stats.StatsGraph; import flixel.text.FlxText; import flixel.util.FlxColor; -import funkin.audiovis.PolygonSpectogram; +import funkin.audio.visualize.PolygonSpectogram; import funkin.ui.CoolStatsGraph; import haxe.Timer; import openfl.events.KeyboardEvent; diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx index a2f1c8437..9c6b39ab6 100644 --- a/source/funkin/MusicBeatState.hx +++ b/source/funkin/MusicBeatState.hx @@ -76,8 +76,6 @@ class MusicBeatState extends FlxUIState FlxG.state.openSubState(new DebugMenuSubState()); } - // Conductor.update(FlxG.sound.music.time + Conductor.offset); - FlxG.watch.addQuick("songPos", Conductor.songPosition); dispatchEvent(new UpdateScriptEvent(elapsed)); diff --git a/source/funkin/Note.hx b/source/funkin/Note.hx index 9db44775e..916e3bf8e 100644 --- a/source/funkin/Note.hx +++ b/source/funkin/Note.hx @@ -153,10 +153,10 @@ class Note extends FlxSprite default: frames = Paths.getSparrowAtlas('NOTE_assets'); + animation.addByPrefix('purpleScroll', 'purple instance'); + animation.addByPrefix('blueScroll', 'blue instance'); animation.addByPrefix('greenScroll', 'green instance'); animation.addByPrefix('redScroll', 'red instance'); - animation.addByPrefix('blueScroll', 'blue instance'); - animation.addByPrefix('purpleScroll', 'purple instance'); animation.addByPrefix('purpleholdend', 'pruple end hold'); animation.addByPrefix('greenholdend', 'green hold end'); diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx index 245228379..b4b521052 100644 --- a/source/funkin/TitleState.hx +++ b/source/funkin/TitleState.hx @@ -587,10 +587,13 @@ class TitleState extends MusicBeatState danceLeft = !danceLeft; - if (danceLeft) - gfDance.animation.play('danceRight'); - else - gfDance.animation.play('danceLeft'); + if (gfDance != null && gfDance.animation != null) + { + if (danceLeft) + gfDance.animation.play('danceRight'); + else + gfDance.animation.play('danceLeft'); + } } return true; diff --git a/source/funkin/audiovis/PolygonSpectogram.hx b/source/funkin/audio/visualize/PolygonSpectogram.hx similarity index 94% rename from source/funkin/audiovis/PolygonSpectogram.hx rename to source/funkin/audio/visualize/PolygonSpectogram.hx index d8af4e07d..f1ccebdb7 100644 --- a/source/funkin/audiovis/PolygonSpectogram.hx +++ b/source/funkin/audio/visualize/PolygonSpectogram.hx @@ -1,12 +1,12 @@ -package funkin.audiovis; +package funkin.audio.visualize; -import funkin.audiovis.VisShit.CurAudioInfo; import flixel.math.FlxMath; import flixel.math.FlxPoint; import flixel.system.FlxSound; import flixel.util.FlxColor; +import funkin.audiovis.VisShit; +import funkin.graphics.rendering.MeshRender; import lime.utils.Int16Array; -import funkin.rendering.MeshRender; class PolygonSpectogram extends MeshRender { @@ -30,7 +30,7 @@ class PolygonSpectogram extends MeshRender { super(0, 0, col); - vis = new VisShit(daSound); + setSound(daSound); if (height != null) this.daHeight = height; @@ -40,6 +40,11 @@ class PolygonSpectogram extends MeshRender // col not in yet } + public function setSound(daSound:FlxSound) + { + vis = new VisShit(daSound); + } + override function update(elapsed:Float) { super.update(elapsed); diff --git a/source/funkin/audiovis/SpectogramSprite.hx b/source/funkin/audiovis/SpectogramSprite.hx index 9d4f7d3c2..8d33f80be 100644 --- a/source/funkin/audiovis/SpectogramSprite.hx +++ b/source/funkin/audiovis/SpectogramSprite.hx @@ -7,7 +7,7 @@ import flixel.math.FlxPoint; import flixel.math.FlxVector; import flixel.system.FlxSound; import flixel.util.FlxColor; -import funkin.audiovis.PolygonSpectogram.VISTYPE; +import funkin.audio.visualize.PolygonSpectogram.VISTYPE; import funkin.audiovis.VisShit.CurAudioInfo; import funkin.audiovis.dsp.FFT; import haxe.Timer; diff --git a/source/funkin/charting/ChartingState.hx b/source/funkin/charting/ChartingState.hx index 57059f5c8..d51df7689 100644 --- a/source/funkin/charting/ChartingState.hx +++ b/source/funkin/charting/ChartingState.hx @@ -19,13 +19,13 @@ import flixel.util.FlxColor; import funkin.Conductor.BPMChangeEvent; import funkin.Section.SwagSection; import funkin.SongLoad.SwagSong; +import funkin.audio.visualize.PolygonSpectogram; import funkin.audiovis.ABotVis; -import funkin.audiovis.PolygonSpectogram; import funkin.audiovis.SpectogramSprite; +import funkin.graphics.rendering.MeshRender; import funkin.noteStuff.NoteBasic.NoteData; import funkin.play.HealthIcon; import funkin.play.PlayState; -import funkin.rendering.MeshRender; import haxe.Json; import lime.media.AudioBuffer; import lime.utils.Assets; @@ -1021,7 +1021,7 @@ class ChartingState extends MusicBeatState } } - function recalculateSteps():Int + function recalculateSteps():Float { var lastChange:BPMChangeEvent = { stepTime: 0, diff --git a/source/funkin/rendering/MeshRender.hx b/source/funkin/graphics/rendering/MeshRender.hx similarity index 97% rename from source/funkin/rendering/MeshRender.hx rename to source/funkin/graphics/rendering/MeshRender.hx index 2865b1a3d..1f88c1868 100644 --- a/source/funkin/rendering/MeshRender.hx +++ b/source/funkin/graphics/rendering/MeshRender.hx @@ -1,4 +1,4 @@ -package funkin.rendering; +package funkin.graphics.rendering; import flixel.FlxStrip; import flixel.util.FlxColor; diff --git a/source/funkin/graphics/rendering/SustainTrail.hx b/source/funkin/graphics/rendering/SustainTrail.hx new file mode 100644 index 000000000..0eeffccf7 --- /dev/null +++ b/source/funkin/graphics/rendering/SustainTrail.hx @@ -0,0 +1,238 @@ +package funkin.graphics.rendering; + +import flixel.FlxSprite; +import flixel.graphics.FlxGraphic; +import flixel.graphics.tile.FlxDrawTrianglesItem; +import flixel.math.FlxMath; + +/** + * This is based heavily on the `FlxStrip` class. It uses `drawTriangles()` to clip a sustain note + * trail at a certain time. + * The whole `FlxGraphic` is used as a texture map. See the `NOTE_hold_assets.fla` file for specifics + * on how it should be constructed. + * + * @author MtH + */ +class SustainTrail extends FlxSprite +{ + /** + * Used to determine which note color/direction to draw for the sustain. + */ + public var noteData:Int = 0; + + /** + * The zoom level to render the sustain at. + * Defaults to 1.0, increased to 6.0 for pixel notes. + */ + public var zoom(default, set):Float = 1; + + /** + * The strumtime of the note, in milliseconds. + */ + public var strumTime:Float = 0; // millis + + /** + * The sustain length of the note, in milliseconds. + */ + public var sustainLength(default, set):Float = 0; // millis + + /** + * The scroll speed of the note, as a multiplier. + */ + public var scrollSpeed(default, set):Float = 1.0; // stand-in for PlayState scroll speed + + /** + * Whether the note was missed. + */ + public var missed:Bool = false; // maybe BlendMode.MULTIPLY if missed somehow, drawTriangles does not support! + + /** + * A `Vector` of floats where each pair of numbers is treated as a coordinate location (an x, y pair). + */ + private var vertices:DrawData = new DrawData(); + + /** + * A `Vector` of integers or indexes, where every three indexes define a triangle. + */ + private var indices:DrawData = new DrawData(); + + /** + * A `Vector` of normalized coordinates used to apply texture mapping. + */ + private var uvtData:DrawData = new DrawData(); + + private var processedGraphic:FlxGraphic; + + /** + * What part of the trail's end actually represents the end of the note. + * This can be used to have a little bit sticking out. + */ + public var endOffset:Float = 0.5; // 0.73 is roughly the bottom of the sprite in the normal graphic! + + /** + * At what point the bottom for the trail's end should be clipped off. + * Used in cases where there's an extra bit of the graphic on the bottom to avoid antialiasing issues with overflow. + */ + public var bottomClip:Float = 0.9; + + /** + * Normally you would take strumTime:Float, noteData:Int, sustainLength:Float, parentNote:Note (?) + * @param NoteData + * @param SustainLength + * @param FileName + */ + public function new(NoteData:Int, SustainLength:Float, Path:String, ?Alpha:Float = 0.6, ?Pixel:Bool = false) + { + super(0, 0, Path); + + // BASIC SETUP + this.sustainLength = SustainLength; + this.noteData = NoteData; + + // CALCULATE SIZE + if (Pixel) + { + this.endOffset = bottomClip = 1; + this.antialiasing = false; + this.zoom = 6.0; + } + else + { + this.antialiasing = true; + this.zoom = 1.0; + } + // width = graphic.width / 8 * zoom; // amount of notes * 2 + height = sustainHeight(sustainLength, scrollSpeed); + // instead of scrollSpeed, PlayState.SONG.speed + + alpha = Alpha; // setting alpha calls updateColorTransform(), which initializes processedGraphic! + + updateClipping(); + indices = new DrawData(12, true, [0, 1, 2, 2, 3, 0, 4, 5, 6, 6, 7, 4]); + } + + /** + * Calculates height of a sustain note for a given length (milliseconds) and scroll speed. + * @param susLength The length of the sustain note in milliseconds. + * @param scroll The current scroll speed. + */ + public static inline function sustainHeight(susLength:Float, scroll:Float) + { + return (susLength * 0.45 * scroll); + } + + function set_zoom(z:Float) + { + this.zoom = z; + width = graphic.width / 8 * z; + updateClipping(); + return this.zoom; + } + + function set_sustainLength(s:Float) + { + height = sustainHeight(s, scrollSpeed); + return sustainLength = s; + } + + function set_scrollSpeed(s:Float) + { + height = sustainHeight(sustainLength, s); + return scrollSpeed = s; + } + + /** + * Sets up new vertex and UV data to clip the trail. + * If flipY is true, top and bottom bounds swap places. + * @param songTime The time to clip the note at, in milliseconds. + */ + public function updateClipping(songTime:Float = 0):Void + { + var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), scrollSpeed), 0, height); + if (clipHeight == 0) + { + visible = false; + return; + } + else + visible = true; + var bottomHeight:Float = graphic.height * zoom * endOffset; + var partHeight:Float = clipHeight - bottomHeight; + // == HOLD == // + // left bound + vertices[6] = vertices[0] = 0.0; + // top bound + vertices[3] = vertices[1] = flipY ? clipHeight : height - clipHeight; + // right bound + vertices[4] = vertices[2] = width; + // bottom bound (also top bound for hold ends) + if (partHeight > 0) + vertices[7] = vertices[5] = flipY ? 0.0 + bottomHeight : vertices[1] + partHeight; + else + vertices[7] = vertices[5] = vertices[1]; + + // same shit with da bounds, just in relation to the texture + uvtData[6] = uvtData[0] = 1 / 4 * (noteData % 4); + // height overflows past image bounds so wraps around, looping the texture + // flipY bounds are not swapped for UV data, so the graphic is actually flipped + // top bound + uvtData[3] = uvtData[1] = (-partHeight) / graphic.height / zoom; + uvtData[4] = uvtData[2] = uvtData[0] + 1 / 8; // 1 + // bottom bound + uvtData[7] = uvtData[5] = 0.0; + + // == HOLD ENDS == // + // left bound + vertices[14] = vertices[8] = vertices[0]; + // top bound + vertices[11] = vertices[9] = vertices[5]; + // right bound + vertices[12] = vertices[10] = vertices[2]; + // bottom bound, mind the bottomClip because it clips off bottom of graphic!! + vertices[15] = vertices[13] = flipY ? graphic.height * (-bottomClip + endOffset) : height + graphic.height * (bottomClip - endOffset); + + uvtData[14] = uvtData[8] = uvtData[2]; + if (partHeight > 0) + uvtData[11] = uvtData[9] = 0.0; + else + uvtData[11] = uvtData[9] = (bottomHeight - clipHeight) / zoom / graphic.height; + uvtData[12] = uvtData[10] = uvtData[8] + 1 / 8; + // again, clips off bottom !! + uvtData[15] = uvtData[13] = bottomClip; + } + + @:access(flixel.FlxCamera) + override public function draw():Void + { + if (alpha == 0 || graphic == null || vertices == null) + return; + + for (camera in cameras) + { + if (!camera.visible || !camera.exists || !isOnScreen(camera)) + continue; + + getScreenPosition(_point, camera).subtractPoint(offset); + camera.drawTriangles(processedGraphic, vertices, indices, uvtData, null, _point, blend, true, antialiasing); + } + } + + override public function destroy():Void + { + vertices = null; + indices = null; + uvtData = null; + processedGraphic.destroy(); + + super.destroy(); + } + + override function updateColorTransform():Void + { + super.updateColorTransform(); + if (processedGraphic != null) + processedGraphic.destroy(); + processedGraphic = FlxGraphic.fromGraphic(graphic, true); + processedGraphic.bitmap.colorTransform(processedGraphic.bitmap.rect, colorTransform); + } +} diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index 0c6a400b9..e5b186398 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -23,11 +23,21 @@ class PolymodHandler */ static final MOD_FOLDER = "mods"; + public static function createModRoot() + { + if (!sys.FileSystem.exists(MOD_FOLDER)) + { + sys.FileSystem.createDirectory(MOD_FOLDER); + } + } + /** * Loads the game with ALL mods enabled with Polymod. */ public static function loadAllMods() { + // Create the mod root if it doesn't exist. + createModRoot(); trace("Initializing Polymod (using all mods)..."); loadModsById(getAllModIds()); } @@ -37,6 +47,9 @@ class PolymodHandler */ public static function loadEnabledMods() { + // Create the mod root if it doesn't exist. + createModRoot(); + trace("Initializing Polymod (using configured mods)..."); loadModsById(getEnabledModIds()); } @@ -46,6 +59,9 @@ class PolymodHandler */ public static function loadNoMods() { + // Create the mod root if it doesn't exist. + createModRoot(); + // We still need to configure the debug print calls etc. trace("Initializing Polymod (using no mods)..."); loadModsById([]); diff --git a/source/funkin/play/HealthIcon.hx b/source/funkin/play/HealthIcon.hx index e37050088..4c4d8caaa 100644 --- a/source/funkin/play/HealthIcon.hx +++ b/source/funkin/play/HealthIcon.hx @@ -43,7 +43,7 @@ class HealthIcon extends FlxSprite /** * Since the `scale` of the sprite dynamically changes over time, * this value allows you to set a relative scale for the icon. - * @default 1x scale + * @default 1x scale = 150px width and height. */ public var size:FlxPoint = new FlxPoint(1, 1); @@ -87,13 +87,13 @@ class HealthIcon extends FlxSprite * The size of a non-pixel icon when using the legacy format. * Remember, modern icons can be any size. */ - static final LEGACY_ICON_SIZE = 150; + public static final HEALTH_ICON_SIZE = 150; /** * The size of a pixel icon when using the legacy format. * Remember, modern icons can be any size. */ - static final LEGACY_PIXEL_SIZE = 32; + static final PIXEL_ICON_SIZE = 32; /** * shitty hardcoded value for a specific positioning!!! @@ -145,11 +145,9 @@ class HealthIcon extends FlxSprite { super.update(elapsed); - if (PlayState.instance == null) - return; - // Auto-update the state of the icon based on the player's health. - if (autoUpdate) + // Make sure this is false if the health icon is not being used in the PlayState. + if (autoUpdate && PlayState.instance != null) { switch (playerId) { @@ -168,19 +166,22 @@ class HealthIcon extends FlxSprite + (PlayState.instance.healthBar.width * (FlxMath.remapToRange(PlayState.instance.healthBar.value, 0, 2, 100, 0) * 0.01)) - (this.width - POSITION_OFFSET); } + } + if (bumpEvery != 0) + { // Lerp the health icon back to its normal size, // while maintaining aspect ratio. if (this.width > this.height) { // Apply linear interpolation while accounting for frame rate. - var targetSize = Std.int(CoolUtil.coolLerp(this.width, 150 * this.size.x, 0.15)); + var targetSize = Std.int(CoolUtil.coolLerp(this.width, HEALTH_ICON_SIZE * this.size.x, 0.15)); setGraphicSize(targetSize, 0); } else { - var targetSize = Std.int(CoolUtil.coolLerp(this.height, 150 * this.size.y, 0.15)); + var targetSize = Std.int(CoolUtil.coolLerp(this.height, HEALTH_ICON_SIZE * this.size.y, 0.15)); setGraphicSize(0, targetSize); } @@ -190,18 +191,20 @@ class HealthIcon extends FlxSprite public function onStepHit(curStep:Int) { - if (curStep % bumpEvery == 0 && isLegacyStyle) + if (bumpEvery != 0 && curStep % bumpEvery == 0 && isLegacyStyle) { // Make the health icons bump (the update function causes them to lerp back down). if (this.width > this.height) { - var targetSize = Std.int(CoolUtil.coolLerp(this.width + 30, 150, 0.15)); + var targetSize = Std.int(CoolUtil.coolLerp(this.width + HEALTH_ICON_SIZE * 0.2, HEALTH_ICON_SIZE, 0.15)); + targetSize = Std.int(Math.min(targetSize, HEALTH_ICON_SIZE * 1.2)); setGraphicSize(targetSize, 0); } else { - var targetSize = Std.int(CoolUtil.coolLerp(this.height + 30, 150, 0.15)); + var targetSize = Std.int(CoolUtil.coolLerp(this.height + HEALTH_ICON_SIZE * 0.2, HEALTH_ICON_SIZE, 0.15)); + targetSize = Std.int(Math.min(targetSize, HEALTH_ICON_SIZE * 1.2)); setGraphicSize(0, targetSize); } @@ -211,7 +214,7 @@ class HealthIcon extends FlxSprite inline function initTargetSize() { - setGraphicSize(150); + setGraphicSize(HEALTH_ICON_SIZE); updateHitbox(); } @@ -330,8 +333,7 @@ class HealthIcon extends FlxSprite } else { - loadGraphic(Paths.image('icons/icon-$charId'), true, isPixel ? LEGACY_PIXEL_SIZE : LEGACY_ICON_SIZE, - isPixel ? LEGACY_PIXEL_SIZE : LEGACY_ICON_SIZE); + loadGraphic(Paths.image('icons/icon-$charId'), true, isPixel ? PIXEL_ICON_SIZE : HEALTH_ICON_SIZE, isPixel ? PIXEL_ICON_SIZE : HEALTH_ICON_SIZE); loadAnimationOld(charId); } diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index f57cf405b..092d2e639 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -1340,10 +1340,11 @@ class PlayState extends MusicBeatState function resyncVocals():Void { - if (_exiting) + if (_exiting || vocals == null) return; vocals.pause(); + FlxG.sound.music.play(); Conductor.update(FlxG.sound.music.time + Conductor.offset); @@ -2226,8 +2227,10 @@ class PlayState extends MusicBeatState resyncVocals(); } - iconP1.onStepHit(Conductor.currentStep); - iconP2.onStepHit(Conductor.currentStep); + if (iconP1 != null) + iconP1.onStepHit(Std.int(Conductor.currentStep)); + if (iconP2 != null) + iconP2.onStepHit(Std.int(Conductor.currentStep)); return true; } diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx index 5554f1f66..132845832 100644 --- a/source/funkin/play/character/MultiSparrowCharacter.hx +++ b/source/funkin/play/character/MultiSparrowCharacter.hx @@ -14,6 +14,9 @@ import funkin.util.assets.FlxAnimationUtil; * * BaseCharacter has game logic, SparrowCharacter has only rendering logic. * KEEP THEM SEPARATE! + * + * TODO: Rewrite this to use a single frame collection. + * @see https://github.com/HaxeFlixel/flixel/issues/2587#issuecomment-1179620637 */ class MultiSparrowCharacter extends BaseCharacter { diff --git a/source/funkin/play/event/SongEvent.hx b/source/funkin/play/event/SongEvent.hx index 8af2c2105..4c0e29575 100644 --- a/source/funkin/play/event/SongEvent.hx +++ b/source/funkin/play/event/SongEvent.hx @@ -2,6 +2,7 @@ package funkin.play.event; import flixel.FlxSprite; import funkin.play.PlayState; +import funkin.play.character.BaseCharacter; import funkin.play.song.SongData.RawSongEventData; import haxe.DynamicAccess; @@ -260,12 +261,24 @@ class VanillaEventCallbacks case 'boyfriend': trace('[EVENT] Playing animation $anim on boyfriend.'); target = PlayState.instance.currentStage.getBoyfriend(); + case 'bf': + trace('[EVENT] Playing animation $anim on boyfriend.'); + target = PlayState.instance.currentStage.getBoyfriend(); + case 'player': + trace('[EVENT] Playing animation $anim on boyfriend.'); + target = PlayState.instance.currentStage.getBoyfriend(); case 'dad': trace('[EVENT] Playing animation $anim on dad.'); target = PlayState.instance.currentStage.getDad(); + case 'opponent': + trace('[EVENT] Playing animation $anim on dad.'); + target = PlayState.instance.currentStage.getDad(); case 'girlfriend': trace('[EVENT] Playing animation $anim on girlfriend.'); target = PlayState.instance.currentStage.getGirlfriend(); + case 'gf': + trace('[EVENT] Playing animation $anim on girlfriend.'); + target = PlayState.instance.currentStage.getGirlfriend(); default: target = PlayState.instance.currentStage.getNamedProp(targetName); if (target == null) @@ -276,7 +289,15 @@ class VanillaEventCallbacks if (target != null) { - target.animation.play(anim, force); + if (Std.isOfType(target, BaseCharacter)) + { + var targetChar:BaseCharacter = cast target; + targetChar.playAnimation(anim, force, force); + } + else + { + target.animation.play(anim, force); + } } } } diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx index fed34ddf5..879cf5161 100644 --- a/source/funkin/play/song/SongData.hx +++ b/source/funkin/play/song/SongData.hx @@ -213,7 +213,7 @@ class SongDataParser } } -typedef SongMetadata = +typedef RawSongMetadata = { /** * A semantic versioning string for the song data format. @@ -231,11 +231,41 @@ typedef SongMetadata = var generatedBy:String; /** - * Defaults to ''. Populated later. + * Defaults to `default` or `''`. Populated later. */ var variation:String; }; +@:forward +abstract SongMetadata(RawSongMetadata) +{ + public function new(songName:String, artist:String, variation:String = 'default') + { + this = { + version: SongMigrator.CHART_VERSION, + songName: songName, + artist: artist, + timeFormat: 'ms', + divisions: 96, + timeChanges: [new SongTimeChange(-1, 0, 100, 4, 4, [4, 4, 4, 4])], + loop: false, + playData: { + songVariations: [], + difficulties: ['normal'], + + playableChars: { + bf: new SongPlayableChar('gf', 'dad'), + }, + + stage: 'mainStage', + noteSkin: 'Normal' + }, + generatedBy: SongValidator.DEFAULT_GENERATEDBY, + variation: variation + }; + } +} + typedef SongPlayData = { var songVariations:Array; @@ -310,6 +340,14 @@ abstract SongNoteData(RawSongNoteData) return this.t = value; } + public var stepTime(get, never):Float; + + public function get_stepTime():Float + { + // TODO: Account for changes in BPM. + return this.t / Conductor.stepCrochet; + } + /** * The raw data for the note. */ @@ -336,6 +374,23 @@ abstract SongNoteData(RawSongNoteData) return this.d % strumlineSize; } + public function getDirectionName(strumlineSize:Int = 4):String + { + switch (this.d % strumlineSize) + { + case 0: + return 'Left'; + case 1: + return 'Down'; + case 2: + return 'Up'; + case 3: + return 'Right'; + default: + return 'Unknown'; + } + } + /** * The strumline index of the note, if applicable. * Strips the direction from the data. @@ -543,14 +598,18 @@ typedef RawSongChartData = @:forward abstract SongChartData(RawSongChartData) { - public function new(scrollSpeed:DynamicAccess, events:Array, notes:DynamicAccess>) + public function new(scrollSpeed:Float, events:Array, notes:Array) { this = { version: SongMigrator.CHART_VERSION, events: events, - notes: notes, - scrollSpeed: scrollSpeed, + notes: { + normal: notes + }, + scrollSpeed: { + normal: scrollSpeed + }, generatedBy: SongValidator.DEFAULT_GENERATEDBY } } diff --git a/source/funkin/play/song/SongSerializer.hx b/source/funkin/play/song/SongSerializer.hx new file mode 100644 index 000000000..af00106d0 --- /dev/null +++ b/source/funkin/play/song/SongSerializer.hx @@ -0,0 +1,216 @@ +package funkin.play.song; + +import funkin.play.song.SongData.SongChartData; +import funkin.play.song.SongData.SongMetadata; +import funkin.util.SerializerUtil; +import lime.utils.Bytes; +import openfl.events.Event; +import openfl.events.IOErrorEvent; +import openfl.net.FileReference; + +/** + * Utilities for exporting a chart to a JSON file. + * Primarily used for the chart editor. + */ +class SongSerializer +{ + /** + * Access a SongChartData JSON file from a specific path, then load it. + * @param path The file path to read from. + */ + public static function importSongChartDataSync(path:String):SongChartData + { + var fileData = readFile(path); + + if (fileData == null) + return null; + + var songChartData:SongChartData = SerializerUtil.fromJSON(fileData); + + return songChartData; + } + + /** + * Access a SongMetadata JSON file from a specific path, then load it. + * @param path The file path to read from. + */ + public static function importSongMetadataSync(path:String):SongMetadata + { + var fileData = readFile(path); + + if (fileData == null) + return null; + + var songMetadata:SongMetadata = SerializerUtil.fromJSON(fileData); + + return songMetadata; + } + + /** + * Prompt the user to browse for a SongChartData JSON file path, then load it. + * @param callback The function to call when the file is loaded. + */ + public static function importSongChartDataAsync(callback:SongChartData->Void):Void + { + browseFileReference(function(fileReference:FileReference) + { + var data = fileReference.data.toString(); + + if (data == null) + return; + + var songChartData:SongChartData = SerializerUtil.fromJSON(data); + + if (songChartData != null) + callback(songChartData); + }); + } + + /** + * Prompt the user to browse for a SongMetadata JSON file path, then load it. + * @param callback The function to call when the file is loaded. + */ + public static function importSongMetadataAsync(callback:SongMetadata->Void):Void + { + browseFileReference(function(fileReference:FileReference) + { + var data = fileReference.data.toString(); + + if (data == null) + return; + + var songMetadata:SongMetadata = SerializerUtil.fromJSON(data); + + if (songMetadata != null) + callback(songMetadata); + }); + } + + /** + * Save a SongChartData object as a JSON file to an automatically generated path. + * Works great on HTML5 and desktop. + */ + public static function exportSongChartData(data:SongChartData) + { + var path = 'chart.json'; + exportSongChartDataAs(path, data); + } + + /** + * Save a SongMetadata object as a JSON file to an automatically generated path. + * Works great on HTML5 and desktop. + */ + public static function exportSongMetadata(data:SongMetadata) + { + var path = 'metadata.json'; + exportSongMetadataAs(path, data); + } + + /** + * Save a SongChartData object as a JSON file to a specified path. + * Works great on HTML5 and desktop. + * + * @param path The file path to save to. + */ + public static function exportSongChartDataAs(path:String, data:SongChartData) + { + var dataString = SerializerUtil.toJSON(data); + + writeFileReference(path, dataString); + } + + /** + * Save a SongMetadata object as a JSON file to a specified path. + * Works great on HTML5 and desktop. + * + * @param path The file path to save to. + */ + public static function exportSongMetadataAs(path:String, data:SongMetadata) + { + var dataString = SerializerUtil.toJSON(data); + + writeFileReference(path, dataString); + } + + /** + * Read the string contents of a file. + * Only works on desktop platforms. + * @param path The file path to read from. + */ + static function readFile(path:String):String + { + #if sys + var fileBytes:Bytes = sys.io.File.getBytes(path); + + if (fileBytes == null) + return null; + + return fileBytes.toString(); + #end + + trace('ERROR: readFile not implemented for this platform'); + return null; + } + + /** + * Write string contents to a file. + * Only works on desktop platforms. + * @param path The file path to read from. + */ + static function writeFile(path:String, data:String):Void + { + #if sys + sys.io.File.saveContent(path, data); + return; + #end + trace('ERROR: writeFile not implemented for this platform'); + return; + } + + /** + * Browse for a file to read and execute a callback once we have a file reference. + * Works great on HTML5 or desktop. + * + * @param callback The function to call when the file is loaded. + */ + static function browseFileReference(callback:FileReference->Void) + { + var file = new FileReference(); + + file.addEventListener(Event.SELECT, function(e) + { + var selectedFileRef:FileReference = e.target; + trace('Selected file: ' + selectedFileRef.name); + selectedFileRef.addEventListener(Event.COMPLETE, function(e) + { + var loadedFileRef:FileReference = e.target; + trace('Loaded file: ' + loadedFileRef.name); + callback(loadedFileRef); + }); + selectedFileRef.load(); + }); + + file.browse(); + } + + /** + * Prompts the user to save a file to their computer. + */ + static function writeFileReference(path:String, data:String) + { + var file = new FileReference(); + file.addEventListener(Event.COMPLETE, function(e:Event) + { + trace('Successfully wrote file.'); + }); + file.addEventListener(Event.CANCEL, function(e:Event) + { + trace('Cancelled writing file.'); + }); + file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) + { + trace('IO error writing file.'); + }); + file.save(data, path); + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorCommand.hx b/source/funkin/ui/debug/charting/ChartEditorCommand.hx new file mode 100644 index 000000000..502b284e1 --- /dev/null +++ b/source/funkin/ui/debug/charting/ChartEditorCommand.hx @@ -0,0 +1,137 @@ +package funkin.ui.debug.charting; + +import funkin.play.song.SongData.SongNoteData; + +/** + * Actions in the chart editor are backed by the Command pattern + * (see Bob Nystrom's book "Game Programming Patterns" for more info) + * + * To make a function compatible with the undo/redo history, create a new class + * that implements ChartEditorCommand, then call `ChartEditorState.performCommand(new Command())` + */ +interface ChartEditorCommand +{ + /** + * Calling this function should perform the action that this command represents. + * @param state The ChartEditorState to perform the action on. + */ + public function execute(state:ChartEditorState):Void; + + /** + * Calling this function should perform the inverse of the action that this command represents, + * effectively undoing the action. + * @param state The ChartEditorState to undo the action on. + */ + public function undo(state:ChartEditorState):Void; + + /** + * Get a short description of the action (for the UI). + * For example, return `Add Left Note` to display `Undo Add Left Note` in the menu. + */ + public function toString():String; +} + +class AddNoteCommand implements ChartEditorCommand +{ + private var note:SongNoteData; + + public function new(note:SongNoteData) + { + this.note = note; + } + + public function execute(state:ChartEditorState):Void + { + state.currentSongChartNoteData.push(note); + state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + state.noteDisplayDirty = true; + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartNoteData.remove(note); + state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + state.noteDisplayDirty = true; + state.sortChartData(); + } + + public function toString():String + { + var dir:String = note.getDirectionName(); + + return 'Add $dir Note'; + } +} + +class RemoveNoteCommand implements ChartEditorCommand +{ + private var note:SongNoteData; + + public function new(note:SongNoteData) + { + this.note = note; + } + + public function execute(state:ChartEditorState):Void + { + state.currentSongChartNoteData.remove(note); + state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); + state.noteDisplayDirty = true; + state.sortChartData(); + } + + public function undo(state:ChartEditorState):Void + { + state.currentSongChartNoteData.push(note); + state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); + state.noteDisplayDirty = true; + state.sortChartData(); + } + + public function toString():String + { + var dir:String = note.getDirectionName(); + + return 'Remove $dir Note'; + } +} + +class SwitchDifficultyCommand implements ChartEditorCommand +{ + private var prevDifficulty:String; + private var newDifficulty:String; + private var prevVariation:String; + private var newVariation:String; + + public function new(prevDifficulty:String, newDifficulty:String, prevVariation:String, newVariation:String) + { + this.prevDifficulty = prevDifficulty; + this.newDifficulty = newDifficulty; + this.prevVariation = prevVariation; + this.newVariation = newVariation; + } + + public function execute(state:ChartEditorState):Void + { + state.selectedVariation = newVariation != null ? newVariation : prevVariation; + state.selectedDifficulty = newDifficulty != null ? newDifficulty : prevDifficulty; + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function undo(state:ChartEditorState):Void + { + state.selectedVariation = prevVariation != null ? prevVariation : newVariation; + state.selectedDifficulty = prevDifficulty != null ? prevDifficulty : newDifficulty; + + state.noteDisplayDirty = true; + state.notePreviewDirty = true; + } + + public function toString():String + { + return 'Switch Difficulty'; + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx new file mode 100644 index 000000000..8b0d80d6b --- /dev/null +++ b/source/funkin/ui/debug/charting/ChartEditorNoteSprite.hx @@ -0,0 +1,173 @@ +package funkin.ui.debug.charting; + +import flixel.FlxSprite; +import flixel.graphics.frames.FlxFramesCollection; +import flixel.graphics.frames.FlxTileFrames; +import flixel.math.FlxPoint; +import funkin.play.song.SongData.SongNoteData; + +/** + * A note sprite that can be used to display a note in a chart. + * Designed to be used and reused efficiently. Has no gameplay functionality. + */ +class ChartEditorNoteSprite extends FlxSprite +{ + /** + * The note data that this sprite represents. + * You can set this to null to kill the sprite and flag it for recycling. + */ + public var noteData(default, set):SongNoteData; + + /** + * The note skin that this sprite displays. + */ + public var noteSkin(default, set):String = 'Normal'; + + public function new() + { + super(); + + if (noteFrameCollection == null) + { + initFrameCollection(); + } + + this.frames = noteFrameCollection; + + // Initialize all the animations, not just the one we're going to use immediately, + // so that later we can reuse the sprite without having to initialize more animations during scrolling. + this.animation.addByPrefix('tapLeftNormal', 'purple instance'); + this.animation.addByPrefix('tapDownNormal', 'blue instance'); + this.animation.addByPrefix('tapUpNormal', 'green instance'); + this.animation.addByPrefix('tapRightNormal', 'red instance'); + + this.animation.addByPrefix('holdLeftNormal', 'purple hold piece instance'); + this.animation.addByPrefix('holdDownNormal', 'blue hold piece instance'); + this.animation.addByPrefix('holdUpNormal', 'green hold piece instance'); + this.animation.addByPrefix('holdRightNormal', 'red hold piece instance'); + + this.animation.addByPrefix('holdEndLeftNormal', 'pruple end hold instance'); + this.animation.addByPrefix('holdEndDownNormal', 'blue end hold instance'); + this.animation.addByPrefix('holdEndUpNormal', 'green end hold instance'); + this.animation.addByPrefix('holdEndRightNormal', 'red end hold instance'); + + this.animation.addByPrefix('tapLeftPixel', 'pixel4'); + this.animation.addByPrefix('tapDownPixel', 'pixel5'); + this.animation.addByPrefix('tapUpPixel', 'pixel6'); + this.animation.addByPrefix('tapRightPixel', 'pixel7'); + + resizeNote(); + } + + static var noteFrameCollection:FlxFramesCollection = null; + + /** + * We load all the note frames once, then reuse them. + */ + static function initFrameCollection():Void + { + noteFrameCollection = new FlxFramesCollection(null, ATLAS, null); + + // TODO: Automatically iterate over the list of note skins. + + // Normal notes + var frameCollectionNormal = Paths.getSparrowAtlas('NOTE_assets'); + + for (frame in frameCollectionNormal.frames) + { + noteFrameCollection.pushFrame(frame); + } + + // Pixel notes + var graphicPixel = FlxG.bitmap.add(Paths.image('weeb/pixelUI/arrows-pixels', 'week6'), false, null); + if (graphicPixel == null) + trace('ERROR: Could not load graphic: ' + Paths.image('weeb/pixelUI/arrows-pixels', 'week6')); + var frameCollectionPixel = FlxTileFrames.fromGraphic(graphicPixel, new FlxPoint(17, 17)); + for (i in 0...frameCollectionPixel.frames.length) + { + var frame = frameCollectionPixel.frames[i]; + + frame.name = 'pixel' + i; + noteFrameCollection.pushFrame(frame); + } + } + + function set_noteData(value:SongNoteData):SongNoteData + { + this.noteData = value; + + if (this.noteData == null) + { + this.kill(); + return this.noteData; + } + + this.visible = true; + + // Update the position to match the note skin. + setNotePosition(); + + // Update the animation to match the note skin. + playNoteAnimation(); + + return this.noteData; + } + + function set_noteSkin(value:String):String + { + // 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; + + if (cursorColumn < 0) + cursorColumn = 0; + if (cursorColumn >= (ChartEditorState.STRUMLINE_SIZE * 2 + 1)) + { + cursorColumn = (ChartEditorState.STRUMLINE_SIZE * 2 + 1); + } + else + { + // Invert player and opponent columns. + if (cursorColumn >= ChartEditorState.STRUMLINE_SIZE) + { + cursorColumn -= ChartEditorState.STRUMLINE_SIZE; + } + else + { + cursorColumn += ChartEditorState.STRUMLINE_SIZE; + } + } + this.x = cursorColumn * ChartEditorState.GRID_SIZE; + + // 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. + this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE; + } + + function playNoteAnimation() + { + var animationName = 'tap${this.noteData.getDirectionName()}${this.noteSkin}'; + this.animation.play(animationName); + } + + function resizeNote() + { + this.setGraphicSize(ChartEditorState.GRID_SIZE); + this.updateHitbox(); + + // TODO: Make this an attribute of the note skin. + this.antialiasing = (noteSkin != 'Pixel'); + } +} diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index b52a4fd9a..d87111a86 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -2,111 +2,750 @@ package funkin.ui.debug.charting; import flixel.FlxSprite; import flixel.addons.display.FlxGridOverlay; +import flixel.addons.display.FlxTiledSprite; import flixel.group.FlxSpriteGroup; +import flixel.system.FlxSound; import flixel.util.FlxColor; +import flixel.util.FlxSort; +import funkin.audio.visualize.PolygonSpectogram; +import funkin.play.HealthIcon; +import funkin.play.song.SongData.SongChartData; +import funkin.play.song.SongData.SongEventData; +import funkin.play.song.SongData.SongMetadata; +import funkin.play.song.SongData.SongNoteData; +import funkin.play.song.SongSerializer; +import funkin.ui.debug.charting.ChartEditorCommand; import funkin.ui.haxeui.HaxeUIState; +import haxe.ui.containers.TreeView; import haxe.ui.containers.dialogs.Dialog; +import haxe.ui.containers.menus.Menu.MenuEvent; +import haxe.ui.containers.menus.MenuBar; +import haxe.ui.containers.menus.MenuCheckBox; import haxe.ui.containers.menus.MenuItem; import haxe.ui.core.Component; import haxe.ui.events.MouseEvent; +import haxe.ui.events.UIEvent; import openfl.display.BitmapData; +import openfl.geom.Rectangle; +// Since Haxe 3.1.0, if access is allowed to an interface, it extends to all classes implementing that interface. +// Thus, any ChartEditorCommand has access to any private field. +@:allow(funkin.ui.debug.charting.ChartEditorCommand) class ChartEditorState extends HaxeUIState { + /** + * CONSTANTS + */ + // ============================== + + /** + * The location of the chart editor's HaxeUI XML file. + */ static final CHART_EDITOR_LAYOUT = Paths.ui('chart-editor/main-view'); - /** - * The number of notes on each character's strumline. - * TODO: Refactor this logic for larger strumlines in the future. - */ - static final STRUMLINE_SIZE = 4; + static final DEFAULT_VARIATION = 'default'; + static final DEFAULT_DIFFICULTY = 'normal'; - /** - * The height of the menu bar in pixels. Used for positioning UI components. - */ + // UI Element Sizes + public static final GRID_SIZE:Int = 40; + public static final STRUMLINE_SIZE = 4; static final MENU_BAR_HEIGHT = 32; - - /** - * The width (and height) of each grid square, in pixels. - */ - static final GRID_SIZE:Int = 40; - - /** - * Pixel distance between the menu bar and the start of the chart grid. - */ static final GRID_TOP_PAD:Int = 8; + // UI Element Colors + static final BG_COLOR:FlxColor = 0xFF673AB7; static final GRID_ALTERNATE:Bool = true; static final GRID_COLOR_1:FlxColor = 0xFFE7E6E6; + static final GRID_COLOR_1_DARK:FlxColor = 0xFF181919; static final GRID_COLOR_2:FlxColor = 0xFFD9D5D5; + static final GRID_COLOR_2_DARK:FlxColor = 0xFF262A2A; + static final CURSOR_COLOR:FlxColor = 0xC0FFFFFF; + static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030; + static final PLAYHEAD_COLOR:FlxColor = 0xC0808080; + static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000; + /** + * INSTANCE DATA + */ + // ============================== + + /** + * scrollPosition is the current position in the song, in pixels. + * One pixel is 1/40 of 1 step, and 1 step is 1/4 of a beat. + */ + var scrollPosition(default, set):Float = -1.0; + + /** + * scrollPosition, converted to steps. + * TODO: Handle BPM changes. + */ + var scrollPositionInSteps(get, null):Float; + + function get_scrollPositionInSteps():Float + { + return scrollPosition / GRID_SIZE; + } + + var scrollPositionInMs(get, null):Float; + + /** + * scrollPosition, converted to milliseconds. + * TODO: Handle BPM changes. + */ + function get_scrollPositionInMs():Float + { + return scrollPositionInSteps * Conductor.stepCrochet; + } + + /** + * The position of the playhead, in pixels, relative to the scroll position. + * For example, 0 means the playhead is at the top of the grid, and 40 means the playhead is 1 step farther. + */ + var playheadPosition(default, set):Float; + + var playheadPositionInSteps(get, null):Float; + + /** + * playheadPosition, converted to steps. + */ + function get_playheadPositionInSteps():Float + { + return playheadPosition / GRID_SIZE; + } + + /** + * playheadPosition, converted to milliseconds. + */ + var playheadPositionInMs(get, null):Float; + + function get_playheadPositionInMs():Float + { + return playheadPositionInSteps * Conductor.stepCrochet; + } + + /** + * This is the song's length in PIXELS , same format as scrollPosition. + */ + var songLength:Int; + + /** + * songLength, converted to steps. + */ + var songLengthInSteps(get, null):Float; + + function get_songLengthInSteps():Float + { + return songLength / GRID_SIZE; + } + + /** + * songLength, converted to milliseconds. + */ + var songLengthInMs(get, null):Float; + + function get_songLengthInMs():Float + { + return songLengthInSteps * Conductor.stepCrochet; + } + + /** + * If true, a HaxeUI dialog is open and the user interface underneath should be disabled. + */ + var isModalDialogOpen:Bool = false; + + /** + * The note kind currently being placed. Defaults to `''`. + * Use the input in the sidebar to change this. + */ + var selectedNoteKind:String = ''; + + /** + * Whether to play a metronome sound while the playhead moves. + */ + var shouldPlayMetronome:Bool = true; + + /** + * The current variation ID. + */ + var selectedVariation:String = DEFAULT_VARIATION; + + /** + * The selected difficulty ID. + */ + var selectedDifficulty:String = DEFAULT_DIFFICULTY; + + /** + * Whether the note display render group needs to be updated. + */ + var noteDisplayDirty:Bool = true; + + /** + * Whether the neat note preview graphic needs to be updated (i.e. fully rebuilt). + */ + var notePreviewDirty:Bool = true; + + /** + * Whether the difficulty tree view in the sidebar needs to be updated. + */ + var difficultySelectDirty:Bool = true; + + var isInPatternMode:Bool = false; + var currentPattern:String = ''; + var isInPlaytestMode:Bool = false; + + /** + * The list of command previously performed. Used for undoing previous actions. + */ + var undoHistory:Array = []; + + /** + * The list of commands that have been undone. Used for redoing previous actions. + */ + var redoHistory:Array = []; + + /** + * Whether the undo/redo histories have changed since the last time the UI was updated. + */ + var commandHistoryDirty:Bool = true; + + /** + * AUDIO AND SOUND DATA + */ + /** + * The audio track for the instrumental. + */ + var audioInstTrack:FlxSound; + + /** + * The audio track for the vocals. + * TODO: Replace with a VocalSoundGroup. + */ + var audioVocalTrack:FlxSound; + + /** + * CHART DATA + */ + // ============================== + + /** + * The song metadata. + * - Keys are the variation IDs. At least one (`default`) must exist. + * - Values are the relevant metadata, ready to be serialized to JSON. + */ + var songMetadata:Map; + + /** + * The song chart data. + * - Keys are the variation IDs. At least one (`default`) must exist. + * - Values are the relevant chart data, ready to be serialized to JSON. + */ + var songChartData:Map; + + /** + * Convenience property to get the chart data for the current variation. + */ + var currentSongMetadata(get, set):SongMetadata; + + function get_currentSongMetadata():SongMetadata + { + var result = songMetadata.get(selectedVariation); + if (result == null) + { + result = new SongMetadata('Dad Battle', 'Kawai Sprite', selectedVariation); + songMetadata.set(selectedVariation, result); + } + return result; + } + + function set_currentSongMetadata(value:SongMetadata):SongMetadata + { + songMetadata.set(selectedVariation, value); + return value; + } + + /** + * Convenience property to get the chart data for the current variation. + */ + var currentSongChartData(get, set):SongChartData; + + function get_currentSongChartData():SongChartData + { + var result = songChartData.get(selectedVariation); + if (result == null) + { + result = new SongChartData(1.0, [], []); + songChartData.set(selectedVariation, result); + } + return result; + } + + function set_currentSongChartData(value:SongChartData):SongChartData + { + songChartData.set(selectedVariation, value); + return value; + } + + /** + * Convenience property to get (and set) the scroll speed for the current difficulty. + */ + var currentSongChartScrollSpeed(get, set):Float; + + function get_currentSongChartScrollSpeed():Float + { + var result = currentSongChartData.scrollSpeed.get(selectedDifficulty); + if (result == null) + { + // Initialize to the default value if not set. + currentSongChartData.scrollSpeed.set(selectedDifficulty, 1.0); + return 1.0; + } + return result; + } + + function set_currentSongChartScrollSpeed(value:Float):Float + { + currentSongChartData.scrollSpeed.set(selectedDifficulty, value); + return value; + } + + /** + * Convenience property to get the note data for the current difficulty. + */ + var currentSongChartNoteData(get, null):Array; + + function get_currentSongChartNoteData():Array + { + var result = currentSongChartData.notes.get(selectedDifficulty); + if (result == null) + { + // Initialize to the default value if not set. + result = []; + currentSongChartData.notes.set(selectedDifficulty, result); + return result; + } + return result; + } + + /** + * Convenience property to get the event data for the current difficulty. + */ + var currentSongChartEventData(get, null):Array; + + function get_currentSongChartEventData():Array + { + if (currentSongChartData.events == null) + { + // Initialize to the default value if not set. + currentSongChartData.events = []; + } + return currentSongChartData.events; + } + + var currentSongNoteSkin(get, set):String; + + function get_currentSongNoteSkin():String + { + if (currentSongMetadata.playData.noteSkin == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.noteSkin = 'Normal'; + } + return currentSongMetadata.playData.noteSkin; + } + + function set_currentSongNoteSkin(value:String):String + { + return currentSongMetadata.playData.noteSkin = value; + } + + var currentSongStage(get, set):String; + + function get_currentSongStage():String + { + if (currentSongMetadata.playData.stage == null) + { + // Initialize to the default value if not set. + currentSongMetadata.playData.stage = 'mainStage'; + } + return currentSongMetadata.playData.stage; + } + + function set_currentSongStage(value:String):String + { + return currentSongMetadata.playData.stage = value; + } + + var currentSongName(get, set):String; + + function get_currentSongName():String + { + if (currentSongMetadata.songName == null) + { + // Initialize to the default value if not set. + currentSongMetadata.songName = 'New Song'; + } + return currentSongMetadata.songName; + } + + function set_currentSongName(value:String):String + { + return currentSongMetadata.songName = value; + } + + var currentSongArtist(get, set):String; + + function get_currentSongArtist():String + { + if (currentSongMetadata.artist == null) + { + // Initialize to the default value if not set. + currentSongMetadata.artist = 'Unknown'; + } + return currentSongMetadata.artist; + } + + function set_currentSongArtist(value:String):String + { + return currentSongMetadata.artist = value; + } + + /** + * RENDER OBJECTS + */ + // ============================== + + /** + * The IMAGE used for the grid. + */ var gridBitmap:BitmapData; - var gridSprites:FlxSpriteGroup; - var gridDividerA:FlxSprite; - var gridDividerB:FlxSprite; + /** + * The tiled sprite used to display the grid. + * The height is the length of the song, and scrolling is done by simply the sprite. + */ + var gridTiledSprite:FlxSprite; + + /** + * The playhead representing the current position in the song. + * Can move around on the grid independently of the view. + */ + var gridPlayhead:FlxSpriteGroup; + + /** + * A sprite used to highlight the grid square under the cursor. + */ + var gridCursor:FlxSprite; + + /** + * The waveform which (optionally) displays over the grid, underneath the notes and playhead. + */ + var gridSpectrogram:PolygonSpectogram; + + /** + * The rectangle used for the note preview area. + * Should span the full height of the song. We scribble on this to draw the preview. + */ + var notePreviewBitmap:BitmapData; + + /** + * The sprite used to display the note preview area. + * We move this up and down to scroll the preview. + */ + var notePreviewSprite:FlxSprite; + + /** + * The opponent's health icon. + */ + var healthIconDad:HealthIcon; + + /** + * The player's health icon. + */ + var healthIconBF:HealthIcon; + + /** + * The purple background sprite. + */ var menuBG:FlxSprite; - // TODO: Make the unit of measurement for this non-arbitrary - // to assist with logic later. - var scrollPosition(default, set):Float = -1.0; + /** + * The sprite group containing the note graphics. + * Only displays a subset of the data from `currentSongChartNoteData`, + * and kills notes that are off-screen to be recycled later. + */ + var renderedNotes:FlxTypedSpriteGroup; public function new() { + // Load the HaxeUI XML file. super(CHART_EDITOR_LAYOUT); } override function create() { + // Get rid of any music from the previous state. FlxG.sound.music.stop(); + buildDefaultSongData(); + buildBackground(); buildGrid(); + buildNoteGroup(); + // Add the HaxeUI components after the grid so they're on top. super.create(); - setupMenuListeners(); + // Setup the onClick listeners for the UI after it's been created. + setupUIListeners(); - scrollPosition = 0; + // TODO: We should be loading the music later when the user requests it. + loadMusic(); } + function buildDefaultSongData() + { + selectedVariation = DEFAULT_VARIATION; + selectedDifficulty = DEFAULT_DIFFICULTY; + + // Initialize the song metadata. + songMetadata = new Map(); + + // Initialize the song chart data. + songChartData = new Map(); + } + + /** + * Builds and displays the background sprite. + */ function buildBackground() { menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); add(menuBG); - menuBG.color = 0xFF673ab7; + menuBG.color = BG_COLOR; menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); menuBG.updateHitbox(); menuBG.screenCenter(); menuBG.scrollFactor.set(0, 0); } - function buildGrid() + /** + * Draws the grid texture used for the chart editor, and adds dividing lines to it. + * @param dark Whether to draw the grid in a dark color instead of a light one. + */ + function makeGridBitmap(?dark:Bool = true) { // The checkerboard background image of the chart. // 2 * (Strumline Size) + 1 grid squares wide, by 2 grid squares tall. // This gets reused to fill the screen. - gridBitmap = FlxGridOverlay.createGrid(GRID_SIZE, GRID_SIZE, GRID_SIZE * (STRUMLINE_SIZE * 2 + 1), GRID_SIZE * 2, GRID_ALTERNATE, GRID_COLOR_1, - GRID_COLOR_2); + gridBitmap = FlxGridOverlay.createGrid(GRID_SIZE, GRID_SIZE, GRID_SIZE * (STRUMLINE_SIZE * 2 + 1), GRID_SIZE * 2, GRID_ALTERNATE, + dark ? GRID_COLOR_1_DARK : GRID_COLOR_1, dark ? GRID_COLOR_2_DARK : GRID_COLOR_2); + } - gridSprites = new FlxSpriteGroup(); - add(gridSprites); + /** + * Builds and displays the chart editor grid, including the playhead and cursor. + */ + function buildGrid() + { + makeGridBitmap(false); - for (i in 0...10) + // Draw dividers between the strumlines. + var dividerLineAX = GRID_SIZE * (STRUMLINE_SIZE) - 1; + gridBitmap.fillRect(new Rectangle(dividerLineAX, 0, 2, gridBitmap.height), 0xFF000000); + var dividerLineBX = GRID_SIZE * (STRUMLINE_SIZE * 2) - 1; + gridBitmap.fillRect(new Rectangle(dividerLineBX, 0, 2, gridBitmap.height), 0xFF000000); + + gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true); + gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid. + gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar. + add(gridTiledSprite); + + /* + buildSpectrogram(audioVocalTrack); + */ + + // The cursor that appears when hovering over the grid. + gridCursor = new FlxSprite().makeGraphic(GRID_SIZE, GRID_SIZE, CURSOR_COLOR); + add(gridCursor); + + // The playhead that show the current position in the song. + gridPlayhead = new FlxSpriteGroup(); + add(gridPlayhead); + + var playheadWidth = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 10 + 10; + var playheadBaseYPos = MENU_BAR_HEIGHT + GRID_TOP_PAD; + gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos); + var playheadSprite = new FlxSprite().makeGraphic(playheadWidth, Std.int(GRID_SIZE / 4), PLAYHEAD_COLOR); + playheadSprite.x = -10; + playheadSprite.y = 0; + gridPlayhead.add(playheadSprite); + + // Character icons. + healthIconDad = new HealthIcon('dad'); + healthIconDad.autoUpdate = false; + healthIconDad.size.set(0.5, 0.5); + healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5); + healthIconDad.y = gridTiledSprite.y + 5; + add(healthIconDad); + + healthIconBF = new HealthIcon('bf'); + healthIconBF.autoUpdate = false; + healthIconBF.size.set(0.5, 0.5); + healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; + healthIconBF.y = gridTiledSprite.y + 5; + healthIconBF.flipX = true; + add(healthIconBF); + } + + function buildSpectrogram(target:FlxSound) + { + gridSpectrogram = new PolygonSpectogram(target, SPECTROGRAM_COLOR, FlxG.height / 2, Math.floor(FlxG.height / 2)); + gridSpectrogram.x = 0; + gridSpectrogram.y = 0; + gridSpectrogram.waveAmplitude = 50; + gridSpectrogram.scrollFactor.set(0, 0); + // musSpec.visType = FREQUENCIES; + add(gridSpectrogram); + } + + /** + * Builds the group that will hold all the notes. + */ + function buildNoteGroup() + { + renderedNotes = new FlxTypedSpriteGroup(); + renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); + add(renderedNotes); + + /* + var sustainSprite:SustainTrail = new SustainTrail(0, 600, Paths.image('NOTE_hold_assets'), 0.9, false); + sustainSprite.scrollFactor.set(0, 0); + sustainSprite.x = gridTiledSprite.x; + sustainSprite.y = gridTiledSprite.y + 32; + sustainSprite.zoom *= 0.258; // 0.77; + add(sustainSprite); + */ + } + + /** + * Sets up the onClick listeners for the UI. + */ + function setupUIListeners():Void + { + // Make sure clicking on the menu doesn't affect the grid behind it while it's open. + var menubarComponent:MenuBar = findComponent('menubar', MenuBar); + if (menubarComponent != null) { - var gridSprite = new FlxSprite().loadGraphic(gridBitmap); - gridSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid. - gridSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD + (i * gridSprite.height); // Push down to account for the menu bar. - gridSprites.add(gridSprite); + menubarComponent.onMenuOpened = (e:MenuEvent) -> + { + isModalDialogOpen = true; + } + menubarComponent.onMenuClosed = (e:MenuEvent) -> + { + isModalDialogOpen = false; + } } - // The black divider between the two halves of the chart. - gridDividerA = new FlxSprite(gridSprites.members[0].x + GRID_SIZE * STRUMLINE_SIZE, - MENU_BAR_HEIGHT).makeGraphic(2, FlxG.height - MENU_BAR_HEIGHT, FlxColor.BLACK); - add(gridDividerA); - gridDividerB = new FlxSprite(gridSprites.members[0].x + GRID_SIZE * STRUMLINE_SIZE * 2, - MENU_BAR_HEIGHT).makeGraphic(2, FlxG.height - MENU_BAR_HEIGHT, FlxColor.BLACK); - add(gridDividerB); + // Add functionality to the menu items. + + addUIClickListener('menubarItemUndo', (event:MouseEvent) -> undoLastCommand()); + + addUIClickListener('menubarItemRedo', (event:MouseEvent) -> redoLastCommand()); + + addUIClickListener('menubarItemAbout', (event:MouseEvent) -> openDialog('chart-editor/dialogs/about')); + + addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> openDialog('chart-editor/dialogs/user-guide')); + + addUIChangeListener('menubarItemToggleSidebar', (event:UIEvent) -> + { + var sidebar:MenuCheckBox = findComponent('sidebar', MenuCheckBox); + + if (event.value) + { + sidebar.show(); + } + }); + + addUIChangeListener('menubarItemMetronomeEnabled', (event:UIEvent) -> + { + shouldPlayMetronome = event.value; + }); + var metronomeEnabledCheckbox:MenuCheckBox = findComponent('menubarItemMetronomeEnabled', MenuCheckBox); + if (metronomeEnabledCheckbox != null) + { + metronomeEnabledCheckbox.selected = shouldPlayMetronome; + } + + addUIChangeListener('menubarItemVolumeInstrumental', (event:UIEvent) -> + { + var volume:Float = event.value / 100.0; + audioInstTrack.volume = volume; + }); + + addUIChangeListener('menubarItemVolumeVocals', (event:UIEvent) -> + { + var volume:Float = event.value / 100.0; + audioVocalTrack.volume = volume; + }); + + addUIClickListener('sidebarSaveMetadata', (event:MouseEvent) -> + { + // Save metadata for current variation. + SongSerializer.exportSongMetadata(currentSongMetadata); + }); + + addUIClickListener('sidebarSaveChart', (event:MouseEvent) -> + { + // Save chart data for current variation. + SongSerializer.exportSongChartData(currentSongChartData); + }); + + addUIClickListener('sidebarLoadMetadata', (event:MouseEvent) -> + { + // Replace metadata for current variation. + SongSerializer.importSongMetadataAsync(function(songMetadata:SongMetadata) + { + currentSongMetadata = songMetadata; + }); + }); + + addUIClickListener('sidebarLoadChart', (event:MouseEvent) -> + { + // Replace chart data for current variation. + SongSerializer.importSongChartDataAsync(function(songChartData:SongChartData) + { + currentSongChartData = songChartData; + + noteDisplayDirty = true; + }); + }); + + addUIChangeListener('sidebarSongName', (event:UIEvent) -> + { + // Set song name (for current variation) + currentSongName = event.value; + }); + setUIValue('sidebarSongName', currentSongName); + + addUIChangeListener('sidebarSongArtist', (event:UIEvent) -> + { + currentSongArtist = event.value; + }); + setUIValue('sidebarSongArtist', currentSongArtist); + + addUIChangeListener('sidebarStage', (event:UIEvent) -> + { + currentSongStage = event.value; + }); + setUIValue('sidebarStage', currentSongStage); + + addUIChangeListener('sidebarNoteSkin', (event:UIEvent) -> + { + currentSongNoteSkin = event.value; + }); + setUIValue('sidebarNoteSkin', currentSongNoteSkin); + + // TODO: Pass specific HaxeUI components to add context menus to them. + registerContextMenu(null, Paths.ui('chart-editor/context/test')); } public override function update(elapsed:Float) @@ -115,123 +754,898 @@ class ChartEditorState extends HaxeUIState FlxG.mouse.visible = true; - handleScroll(); + // These ones happen even if the modal dialog is open. + handleMusicPlayback(); + handleNoteDisplay(); - if (FlxG.keys.justPressed.B) - toggleSidebar(); + if (!isModalDialogOpen) + { + // These ones only happen if the modal dialog is not open. + handleScrollKeybinds(); + handleCursor(); + + handlePlayheadKeybinds(); + + handleMenubar(); + handleSidebar(); + + handleFileKeybinds(); + handleEditKeybinds(); + handleViewKeybinds(); + handleHelpKeybinds(); + } + + // DEBUG + if (FlxG.keys.justPressed.A) + { + performCommand(new SwitchDifficultyCommand(selectedDifficulty, 'easy', selectedVariation, 'default')); + } + if (FlxG.keys.justPressed.S) + { + performCommand(new SwitchDifficultyCommand(selectedDifficulty, 'normal', selectedVariation, 'default')); + } + if (FlxG.keys.justPressed.D) + { + performCommand(new SwitchDifficultyCommand(selectedDifficulty, 'hard', selectedVariation, 'default')); + } + if (FlxG.keys.justPressed.F) + { + performCommand(new SwitchDifficultyCommand(selectedDifficulty, 'erect', selectedVariation, 'erect')); + } + + // Right align the BF health icon. + + // Base X position to the right of the grid. + var baseHealthIconXPos = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; + // Will be 0 when not bopping. When bopping, will increase to push the icon left. + var healthIconOffset = healthIconBF.width - (HealthIcon.HEALTH_ICON_SIZE * 0.5); + healthIconBF.x = baseHealthIconXPos - healthIconOffset; } - function handleScroll() + /** + * Beat hit while the song is playing. + */ + override function beatHit():Bool { - var scrollAmount:Float = 0; + if (!super.beatHit()) + return false; + if (shouldPlayMetronome && audioInstTrack.playing) + { + playMetronomeTick(Conductor.currentBeat % 4 == 0); + } + + return true; + } + + /** + * Step hit while the song is playing. + */ + override function stepHit():Bool + { + if (!super.stepHit()) + return false; + + if (audioInstTrack.playing) + { + healthIconDad.onStepHit(Conductor.currentStep); + healthIconBF.onStepHit(Conductor.currentStep); + } + + // if (shouldPlayMetronome) + // playMetronomeTick(false); + + return true; + } + + /** + * Handle keybinds for scrolling the chart editor grid. + **/ + function handleScrollKeybinds() + { + // Amount to scroll the grid. + var scrollAmount:Float = 0; + // Amount to scroll the playhead relative to the grid. + var playheadAmount:Float = 0; + + // Up Arrow = Scroll Up if (FlxG.keys.justPressed.UP) { - scrollAmount = -10; + scrollAmount = -GRID_SIZE * 0.25; } + // Down Arrow = Scroll Down if (FlxG.keys.justPressed.DOWN) { - scrollAmount = 10; + scrollAmount = GRID_SIZE * 0.25; } + + // PAGE UP = Jump Up 1 Measure + if (FlxG.keys.justPressed.PAGEUP) + { + scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure; + } + + // PAGE DOWN = Jump Down 1 Measure + if (FlxG.keys.justPressed.PAGEDOWN) + { + scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure; + } + + // Mouse Wheel = Scroll if (FlxG.mouse.wheel != 0) { scrollAmount = -10 * FlxG.mouse.wheel; } + // Middle Mouse + Drag = Scroll but move the playhead the same amount. + if (FlxG.mouse.pressedMiddle) + { + if (FlxG.mouse.diffY != 0) + { + // Scroll down by the amount dragged. + scrollAmount += -FlxG.mouse.diffY; + // Move the playhead by the same amount in the other direction so it is stationary. + playheadAmount += FlxG.mouse.diffY; + } + } + + // SHIFT + Scroll = Scroll Fast if (FlxG.keys.pressed.SHIFT) { scrollAmount *= 10; } + // CONTROL + Scroll = Scroll Precise if (FlxG.keys.pressed.CONTROL) { scrollAmount /= 10; } + // ALT = Move playhead instead. + if (FlxG.keys.pressed.ALT) + { + playheadAmount = scrollAmount; + scrollAmount = 0; + } + + // HOME = Scroll to Top + if (FlxG.keys.justPressed.HOME) + { + // Scroll amount is the difference between the current position and the top. + scrollAmount = 0 - this.scrollPosition; + } + + // END = Scroll to Bottom + if (FlxG.keys.justPressed.END) + { + // Scroll amount is the difference between the current position and the bottom. + scrollAmount = this.songLength - this.scrollPosition; + } + + // Apply the scroll amount. this.scrollPosition += scrollAmount; + this.playheadPosition += playheadAmount; + + // Resync the conductor and audio tracks. + if (scrollAmount != 0 || playheadAmount != 0) + moveSongToScrollPosition(); + } + + /** + * Handle display of the mouse cursor. + */ + function handleCursor() + { + // Note: If a menu is open in HaxeUI, don't handle cursor behavior. + if (FlxG.mouse.overlaps(gridTiledSprite) && (!isModalDialogOpen)) + { + // Cursor position relative to the grid. + var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x; + var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y; + + // The song position of the cursor, in steps. + var cursorFractionalStep:Float = cursorY / GRID_SIZE; + var cursorStep:Int = Math.floor(cursorFractionalStep); + // The direction value for the column at the cursor. + var cursorColumn:Int = Math.floor(cursorX / GRID_SIZE); + if (cursorColumn < 0) + cursorColumn = 0; + if (cursorColumn >= (STRUMLINE_SIZE * 2 + 1 - 1)) + { + // Don't invert the event column. + cursorColumn = (STRUMLINE_SIZE * 2 + 1 - 1); + } + else + { + // Invert player and opponent columns. + if (cursorColumn >= STRUMLINE_SIZE) + { + cursorColumn -= STRUMLINE_SIZE; + } + else + { + cursorColumn += STRUMLINE_SIZE; + } + } + + gridCursor.visible = true; + // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square. + gridCursor.x = Math.floor(cursorX / GRID_SIZE) * GRID_SIZE + gridTiledSprite.x; + gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y; + + // Handle clicks. + + // Left click. + if (FlxG.mouse.justPressed) + { + var eventColumn = (STRUMLINE_SIZE * 2 + 1) - 1; + if (cursorColumn == eventColumn) + { + // Place an event. + + /* + var newEventData:SongEvent = new SongEventData(cursorMs, cursorColumn, 0, selectedNoteKind); + currentSongChartEventData.push(newEventData); + sortChartData(); + */ + } + else + { + // Create a note and place it in the chart. + var cursorMs = cursorStep * Conductor.stepCrochet; + + var newNoteData:SongNoteData = new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind); + + performCommand(new AddNoteCommand(newNoteData)); + } + } + + // Right click. + if (FlxG.mouse.justPressedRight) + { + for (noteSprite in renderedNotes.members) + { + if (noteSprite == null || !noteSprite.exists || !noteSprite.visible) + continue; + + if (noteSprite.overlapsPoint(FlxG.mouse.getPosition())) + { + performCommand(new RemoveNoteCommand(noteSprite.noteData)); + } + } + } + } + else + { + gridCursor.visible = false; + gridCursor.x = -9999; + gridCursor.y = -9999; + } + } + + /** + * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`. + */ + function handleNoteDisplay() + { + if (noteDisplayDirty) + { + noteDisplayDirty = false; + + // Calculate the view bounds. + var viewAreaTop:Float = this.scrollPosition - GRID_TOP_PAD; + var viewHeight:Float = (FlxG.height - MENU_BAR_HEIGHT); + var viewAreaBottom:Float = this.scrollPosition + viewHeight; + + // Remove notes that are no longer visible and list the ones that are. + var displayedNoteData:Array = []; + for (noteSprite in renderedNotes.members) + { + if (noteSprite == null || !noteSprite.exists || !noteSprite.visible) + continue; + + if (noteSprite.y + noteSprite.height < viewAreaTop || noteSprite.y > viewAreaBottom) + { + // This sprite is off-screen. + // Kill the note sprite and recycle it. + noteSprite.noteData = null; + } + else if (currentSongChartNoteData.indexOf(noteSprite.noteData) == -1) + { + // This note was deleted. + // Kill the note sprite and recycle it. + noteSprite.noteData = null; + } + else + { + displayedNoteData.push(noteSprite.noteData); + } + } + + // Add notes that are now visible. + for (noteData in currentSongChartNoteData) + { + // Remember if we are already displaying this note. + if (displayedNoteData.indexOf(noteData) != -1) + continue; + + var noteTimePixels:Float = noteData.time / Conductor.stepCrochet * GRID_SIZE; + + // Make sure the note appears when scrolling up. + var modifiedViewAreaTop = viewAreaTop - GRID_SIZE; + + if (noteTimePixels < modifiedViewAreaTop || noteTimePixels > viewAreaBottom) + continue; + + // Else, this note is visible and we need to render it! + + // Get a note sprite from the pool. + // If we can reuse a deleted note, do so. + // If a new note is needed, call buildNoteSprite. + var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(ChartEditorNoteSprite); + + // The note sprite handles animation playback and positioning. + noteSprite.noteData = noteData; + + // Setting note data resets position relative to the grid so we fix that. + noteSprite.x += renderedNotes.x; + noteSprite.y += renderedNotes.y; + } + } + } + + /** + * Handle keybinds for File menu items. + */ + function handleFileKeybinds() + { + // CTRL + Q = Quit to Menu + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q) + { + FlxG.switchState(new MainMenuState()); + } + } + + /** + * Handle keybinds for edit menu items. + */ + function handleEditKeybinds() + { + // CTRL + Z = Undo + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Z) + { + undoLastCommand(); + } + + // CTRL + Y = Redo + if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Y) + { + redoLastCommand(); + } + } + + /** + * Handle keybinds for View menu items. + */ + function handleViewKeybinds() + { + // B = Toggle Sidebar + if (FlxG.keys.justPressed.B) + toggleSidebar(); + } + + /** + * Handle keybinds for Help menu items. + */ + function handleHelpKeybinds() + { + // F1 = Open Help + if (FlxG.keys.justPressed.F1) + openDialog('chart-editor/dialogs/user-guide'); + } + + function handleSidebar() + { + if (difficultySelectDirty) + { + difficultySelectDirty = false; + + // Manage the Select Difficulty tree view. + var treeView:TreeView = findComponent('sidebarDifficulties'); + + if (treeView != null) + { + var treeSong = treeView.addNode({id: 'stv_song_dadbattle', text: "S: Dad Battle", icon: "haxeui-core/styles/default/haxeui_tiny.png"}); + treeSong.expanded = true; + + var treeVariationDefault = treeSong.addNode({ + id: 'stv_variation_default', + text: "V: Default", + icon: "haxeui-core/styles/default/haxeui_tiny.png" + }); + var treeVariationErect = treeSong.addNode({id: 'stv_variation_erect', text: "V: Erect", icon: "haxeui-core/styles/default/haxeui_tiny.png"}); + + var treeDifficultyEasy = treeVariationDefault.addNode({ + id: 'stv_difficulty_easy', + text: "D: Easy", + icon: "haxeui-core/styles/default/haxeui_tiny.png" + }); + var treeDifficultyNormal = treeVariationDefault.addNode({ + id: 'stv_difficulty_normal', + text: "D: Normal", + icon: "haxeui-core/styles/default/haxeui_tiny.png" + }); + var treeDifficultyHard = treeVariationDefault.addNode({ + id: 'stv_difficulty_hard', + text: "D: Hard", + icon: "haxeui-core/styles/default/haxeui_tiny.png" + }); + + var treeDifficultyErect = treeVariationErect.addNode({ + id: 'stv_difficulty_erect', + text: "D: Erect", + icon: "haxeui-core/styles/default/haxeui_tiny.png" + }); + } + } + } + + /** + * Handle the player preview/gameplay test area on the left side. + */ + function handlePlayerDisplay() + { + } + + /** + * Handles the note preview/scroll area on the right side. + * Notes are rendered here as small bars. + * This function also handles: + * - Moving the viewport preview box around based on its current position. + * - Scrolling the note preview area down if the note preview is taller than the screen, + * and the viewport nears the end of the visible area. + */ + function handleNotePreview() + { + // + if (notePreviewDirty) + { + notePreviewDirty = false; + + var PREVIEW_WIDTH:Int = GRID_SIZE * 2; + var STEP_HEIGHT:Int = 1; + var PREVIEW_HEIGHT:Int = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * STEP_HEIGHT); + + notePreviewBitmap = new BitmapData(PREVIEW_WIDTH, PREVIEW_HEIGHT, true); + notePreviewBitmap.fillRect(new Rectangle(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT), PREVIEW_BG_COLOR); + } + } + + /** + * Perform a spot update on the note preview, by editing the note preview + * only where necessary. More efficient than a full update. + */ + function updateNotePreview(note:SongNoteData, ?deleteNote:Bool = false) + { + } + + /** + * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status. + * Does not handle onClick ACTIONS of the menubar. + */ + function handleMenubar() + { + if (commandHistoryDirty) + { + commandHistoryDirty = false; + + // Update the Undo and Redo buttons. + var undoButton:MenuItem = findComponent('menubarItemUndo', MenuItem); + + if (undoButton != null) + { + if (undoHistory.length == 0) + { + // Disable the Undo button. + undoButton.disabled = true; + undoButton.text = "Undo"; + } + else + { + // Change the label to the last command. + undoButton.disabled = false; + undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}'; + } + } + else + { + trace("undoButton is null"); + } + + var redoButton:MenuItem = findComponent('menubarItemRedo', MenuItem); + + if (redoButton != null) + { + if (redoHistory.length == 0) + { + // Disable the Redo button. + redoButton.disabled = true; + redoButton.text = "Redo"; + } + else + { + // Change the label to the last command. + redoButton.disabled = false; + redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}'; + } + } + else + { + trace("redoButton is null"); + } + } + } + + /** + * Handle syncronizing the conductor with the music playback. + */ + function handleMusicPlayback() + { + if (audioInstTrack.playing) + { + if (FlxG.mouse.pressedMiddle) + { + // If middle mouse panning during song playback, move ONLY the playhead. + + var oldStepTime = Conductor.currentStepTime; + Conductor.update(audioInstTrack.time); + var diffStepTime = Conductor.currentStepTime - oldStepTime; + + // Move the playhead. + playheadPosition += diffStepTime * GRID_SIZE; + + // We don't move the song to scroll position, or update the note sprites. + } + else + { + // Else, move the entire view. + + Conductor.update(audioInstTrack.time); + + // We need time in fractional steps here to allow the song to actually play. + // Also account for a potentially offset playhead. + scrollPosition = Conductor.currentStepTime * GRID_SIZE - playheadPosition; + + // DO NOT move song to scroll position here specifically. + + // We need to update the note sprites. + noteDisplayDirty = true; + } + } + + if (FlxG.keys.justPressed.SPACE) + { + if (audioInstTrack.playing) + { + audioInstTrack.pause(); + audioVocalTrack.pause(); + } + else + { + audioInstTrack.play(); + audioVocalTrack.play(); + } + } + } + + function handlePlayheadKeybinds() + { + // Place notes at the playhead. + // TODO: Add the ability to switch modes. + if (true) + { + if (FlxG.keys.justPressed.ONE) + placeNoteAtPlayhead(0); + if (FlxG.keys.justPressed.TWO) + placeNoteAtPlayhead(1); + if (FlxG.keys.justPressed.THREE) + placeNoteAtPlayhead(2); + if (FlxG.keys.justPressed.FOUR) + placeNoteAtPlayhead(3); + if (FlxG.keys.justPressed.FIVE) + placeNoteAtPlayhead(4); + if (FlxG.keys.justPressed.SIX) + placeNoteAtPlayhead(5); + if (FlxG.keys.justPressed.SEVEN) + placeNoteAtPlayhead(6); + if (FlxG.keys.justPressed.EIGHT) + placeNoteAtPlayhead(7); + } + } + + function placeNoteAtPlayhead(column:Int):Void + { + var gridSnappedPlayheadPos = scrollPosition - (scrollPosition % GRID_SIZE); } function set_scrollPosition(value:Float):Float { - // TODO: Calculate this. - var MAX_SCROLL = 10000; - if (value == scrollPosition || value < 0 || value > MAX_SCROLL) - return scrollPosition; + if (value < 0) + { + // If we're scrolling up, and we hit the top, + // but the playhead is in the middle, move the playhead up. + if (playheadPosition > 0) + { + var amount = scrollPosition - value; + playheadPosition -= amount; + } + + value = 0; + } + + if (value > songLength) + value = songLength; + + if (value == scrollPosition) + return value; this.scrollPosition = value; - trace('SCROLL: $scrollPosition'); - - // Move the grid sprites to the correct position. - gridSprites.y = -scrollPosition; - - // Nudge the grid dividers down if needed. - if (-gridSprites.y < GRID_TOP_PAD) - { - gridDividerA.y = MENU_BAR_HEIGHT + GRID_TOP_PAD + gridSprites.y; - gridDividerB.y = MENU_BAR_HEIGHT + GRID_TOP_PAD + gridSprites.y; - } - else - { - gridDividerA.y = MENU_BAR_HEIGHT; - gridDividerB.y = MENU_BAR_HEIGHT; - } - - // Rearrange grid sprites so they stay on screen. - gridSprites.forEachAlive(function(sprite:FlxSprite) - { - // If this grid sprite is off the top of the screen... - if (sprite.y + sprite.height < MENU_BAR_HEIGHT) - { - // Move it to the bottom of the screen. - sprite.y += sprite.height * gridSprites.length; - } - // If this grid sprite is off the bottom of the screen... - if (sprite.y > FlxG.height) - { - // Move it to the top of the screen. - sprite.y -= sprite.height * gridSprites.length; - } - }); - - // TODO: Add a clip rectangle to the FlxSpriteGroup to hide the grid sprites that got moved up, - // when we scroll back to the top of the chart. - // Note that clip rectangles on sprite groups are borken right now, so we'll have to wait for that to be fixed. + // Move the grid sprite to the correct position. + gridTiledSprite.y = -scrollPosition + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + // Move the rendered notes to the correct position. + renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); return this.scrollPosition; } - function toggleSidebar() + function set_playheadPosition(value:Float):Float { - var sidebar:Component = this.component.findComponent('sidebar', Component); + // Make sure playhead doesn't go outside the song. + if (value + scrollPosition < 0) + value = -scrollPosition; + if (value + scrollPosition > songLength) + value = songLength - scrollPosition; - sidebar.visible = !sidebar.visible; + this.playheadPosition = value; + + // Move the playhead sprite to the correct position. + gridPlayhead.y = this.playheadPosition + (MENU_BAR_HEIGHT + GRID_TOP_PAD); + + return this.playheadPosition; } + /** + * Show the sidebar if it's hidden, or hide it if it's shown. + */ + function toggleSidebar() + { + var sidebar:Component = findComponent('sidebar', Component); + + // Set visibility while syncing the checkbox. + if (sidebar != null) + { + sidebar.hidden = setUIValue('menubarItemToggleSidebar', !sidebar.hidden); + } + } + + /** + * Opens a dialog. + * @param modal Makes the background uninteractable. + */ function openDialog(key:String, modal:Bool = true) { var dialog:Dialog = cast buildComponent(Paths.ui(key)); - // modal = true makes the background unclickable - dialog.showDialog(modal); - } - - function setupMenuListeners() - { - addMenuListener('menubarItemToggleSidebar', (event:MouseEvent) -> toggleSidebar()); - addMenuListener('menubarItemAbout', (event:MouseEvent) -> openDialog('chart-editor/dialogs/about')); - addMenuListener('menubarItemUserGuide', (event:MouseEvent) -> openDialog('chart-editor/dialogs/user-guide')); - } - - function addMenuListener(key:String, callback:MouseEvent->Void) - { - var menuItem:MenuItem = this.component.findComponent(key, MenuItem); - if (menuItem == null) + dialog.onDialogClosed = function(e:DialogEvent) { + if (modal) + { + isModalDialogOpen = false; + } + } + dialog.showDialog(modal); + + isModalDialogOpen = modal; + } + + /** + * Load a music track for playback. + */ + function loadMusic() + { + // TODO: How to load music by selecting with a file dialog? + audioInstTrack = FlxG.sound.play(Paths.inst('dadbattle'), 1.0, false); + audioInstTrack.autoDestroy = false; + audioInstTrack.pause(); + + // Prevent the time from skipping back to 0 when the song ends. + audioInstTrack.onComplete = function() + { + audioInstTrack.pause(); + audioVocalTrack.pause(); + }; + + audioVocalTrack = FlxG.sound.play(Paths.voices('dadbattle'), 1.0, false); + audioVocalTrack.autoDestroy = false; + audioVocalTrack.pause(); + + // TODO: Make sure Conductor works properly with changing BPMs. + var DAD_BATTLE_BPM = 180; + var BOPEEBO_BPM = 100; + Conductor.forceBPM(DAD_BATTLE_BPM); + + songLength = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * GRID_SIZE); + + gridTiledSprite.height = songLength; + if (gridSpectrogram != null) + gridSpectrogram.setSound(audioVocalTrack); + + scrollPosition = 0; + playheadPosition = 0; + moveSongToScrollPosition(); + } + + /** + * When setting the scroll position, except when automatically scrolling during song playback, + * we need to update the conductor's current step time and the timestamp of the audio tracks. + */ + function moveSongToScrollPosition() + { + // Update the songPosition in the Conductor. + Conductor.update(scrollPositionInMs); + + // Update the songPosition in the audio tracks. + audioInstTrack.time = scrollPositionInMs + playheadPositionInMs; + audioVocalTrack.time = scrollPositionInMs + playheadPositionInMs; + + // We need to update the note sprites because we changed the scroll position. + noteDisplayDirty = true; + } + + /** + * 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 { - menuItem.onClick = callback; + target.onClick = callback; } } + + /** + * Add an onChange listener to a HaxeUI menu bar item such as a slider. + */ + 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. + */ + function setUIValue(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; + } + } + + /** + * Perform (or redo) a command, then add it to the undo stack. + * @param command The command to perform. + * @param purgeRedoStack If true, the redo stack will be cleared. + */ + function performCommand(command:ChartEditorCommand, ?purgeRedoStack:Bool = true):Void + { + command.execute(this); + undoHistory.push(command); + commandHistoryDirty = true; + if (purgeRedoStack) + redoHistory = []; + } + + /** + * Undo a command, then add it to the redo stack. + * @param command The command to undo. + */ + function undoCommand(command:ChartEditorCommand):Void + { + command.undo(this); + redoHistory.push(command); + commandHistoryDirty = true; + } + + /** + * Undo the last command in the undo stack, then add it to the redo stack. + */ + function undoLastCommand():Void + { + if (undoHistory.length == 0) + { + trace('No actions to undo.'); + return; + } + + var command = undoHistory.pop(); + undoCommand(command); + } + + /** + * Redo the last command in the redo stack, then add it to the undo stack. + */ + function redoLastCommand():Void + { + if (redoHistory.length == 0) + { + trace('No actions to redo.'); + return; + } + + var command = redoHistory.pop(); + performCommand(command, false); + } + + function sortChartData() + { + currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int + { + return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); + }); + + currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int + { + return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); + }); + } + + function playMetronomeTick(?high:Bool = false) + { + playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}')); + } + + /** + * Play a sound effect. + * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance. + */ + function playSound(path:String) + { + var snd:FlxSound = FlxG.sound.list.recycle(FlxSound); + snd.loadEmbedded(FlxG.sound.cache(path)); + snd.autoDestroy = true; + FlxG.sound.list.add(snd); + snd.play(); + } + + override function destroy() + { + super.destroy(); + + @:privateAccess + ChartEditorNoteSprite.noteFrameCollection = null; + } } diff --git a/source/funkin/ui/haxeui/HaxeUIState.hx b/source/funkin/ui/haxeui/HaxeUIState.hx index e893e7f4a..488ca07cb 100644 --- a/source/funkin/ui/haxeui/HaxeUIState.hx +++ b/source/funkin/ui/haxeui/HaxeUIState.hx @@ -2,6 +2,9 @@ package funkin.ui.haxeui; import haxe.ui.RuntimeComponentBuilder; import haxe.ui.core.Component; +import haxe.ui.core.Screen; +import haxe.ui.events.MouseEvent; +import lime.app.Application; class HaxeUIState extends MusicBeatState { @@ -33,16 +36,77 @@ class HaxeUIState extends MusicBeatState } catch (e) { - trace('[ERROR] Failed to build component from asset: ' + assetPath); - trace(e); + Application.current.window.alert('Error building component "$assetPath": $e', 'HaxeUI Parsing Error'); + // trace('[ERROR] Failed to build component from asset: ' + assetPath); + // trace(e); + return null; } } + /** + * The currently active context menu. + */ + public var contextMenu:Component; + + /** + * This function is called when right clicking on a component, to display a context menu. + */ + function showContextMenu(assetPath:String, xPos:Float, yPos:Float):Component + { + if (contextMenu != null) + contextMenu.destroy(); + + contextMenu = buildComponent(assetPath); + + if (contextMenu != null) + { + // Move the context menu to the mouse position. + contextMenu.left = xPos; + contextMenu.top = yPos; + Screen.instance.addComponent(contextMenu); + } + + return contextMenu; + } + + /** + * Register a context menu to display when right clicking. + * @param component Only display the menu when clicking this component. If null, display the menu when right clicking anywhere. + * @param assetPath The asset path to the context menu XML. + */ + public function registerContextMenu(target:Null, assetPath:String):Void + { + if (target == null) + { + Screen.instance.registerEvent(MouseEvent.RIGHT_CLICK, function(e:MouseEvent) + { + showContextMenu(assetPath, e.screenX, e.screenY); + }); + } + else + { + target.registerEvent(MouseEvent.RIGHT_CLICK, function(e:MouseEvent) + { + showContextMenu(assetPath, e.screenX, e.screenY); + }); + } + } + + public function findComponent(criteria:String = null, type:Class = null, recursive:Null = null, searchType:String = "id"):Null + { + if (component == null) + return null; + + return component.findComponent(criteria, type, recursive, searchType); + } + override function destroy() { if (component != null) remove(component); component = null; + + super.destroy(); } } diff --git a/source/funkin/ui/haxeui/components/TabSideBar.hx b/source/funkin/ui/haxeui/components/TabSideBar.hx deleted file mode 100644 index c2d04c4aa..000000000 --- a/source/funkin/ui/haxeui/components/TabSideBar.hx +++ /dev/null @@ -1,101 +0,0 @@ -package funkin.ui.haxeui.components; - -import haxe.ui.Toolkit; -import haxe.ui.containers.SideBar; -import haxe.ui.core.Component; -import haxe.ui.core.Screen; -import haxe.ui.styles.elements.AnimationKeyFrame; -import haxe.ui.styles.elements.AnimationKeyFrames; -import haxe.ui.styles.elements.Directive; - -class TabSideBar extends SideBar -{ - var closeButton:Component; - - public function new() - { - super(); - } - - inline function getCloseButton() - { - if (closeButton == null) - { - closeButton = findComponent("closeSideBar", Component); - } - return closeButton; - } - - public override function hide() - { - var animation = Toolkit.styleSheet.findAnimation("sideBarRestoreContent"); - var first:AnimationKeyFrame = animation.keyFrames[0]; - var last:AnimationKeyFrame = animation.keyFrames[animation.keyFrames.length - 1]; - var rootComponent = Screen.instance.rootComponents[0]; - - first.set(new Directive("left", Value.VDimension(Dimension.PX(rootComponent.left)))); - first.set(new Directive("top", Value.VDimension(Dimension.PX(rootComponent.top)))); - first.set(new Directive("width", Value.VDimension(Dimension.PX(rootComponent.width)))); - first.set(new Directive("height", Value.VDimension(Dimension.PX(rootComponent.height)))); - - last.set(new Directive("left", Value.VDimension(Dimension.PX(0)))); - last.set(new Directive("top", Value.VDimension(Dimension.PX(0)))); - last.set(new Directive("width", Value.VDimension(Dimension.PX(Screen.instance.width)))); - last.set(new Directive("height", Value.VDimension(Dimension.PX(Screen.instance.height)))); - - for (r in Screen.instance.rootComponents) - { - if (r.classes.indexOf("sidebar") == -1) - { - r.swapClass("sideBarRestoreContent", "sideBarModifyContent"); - r.onAnimationEnd = function(_) - { - r.restorePercentSizes(); - r.onAnimationEnd = null; - rootComponent.removeClass("sideBarRestoreContent"); - } - } - } - - hideSideBar(); - } - - private override function hideSideBar() - { - var showSideBarClass = null; - var hideSideBarClass = null; - if (position == "left") - { - showSideBarClass = "showSideBarLeft"; - hideSideBarClass = "hideSideBarLeft"; - } - else if (position == "right") - { - showSideBarClass = "showSideBarRight"; - hideSideBarClass = "hideSideBarRight"; - } - else if (position == "top") - { - showSideBarClass = "showSideBarTop"; - hideSideBarClass = "hideSideBarTop"; - } - else if (position == "bottom") - { - showSideBarClass = "showSideBarBottom"; - hideSideBarClass = "hideSideBarBottom"; - } - - this.onAnimationEnd = function(_) - { - this.removeClass(hideSideBarClass); - // onHideAnimationEnd(); - } - - this.swapClass(hideSideBarClass, showSideBarClass); - - if (modal == true) - { - hideModalOverlay(); - } - } -} diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx new file mode 100644 index 000000000..12597aebc --- /dev/null +++ b/source/funkin/util/SerializerUtil.hx @@ -0,0 +1,58 @@ +package funkin.util; + +import haxe.Json; +import thx.semver.Version; + +class SerializerUtil +{ + static final INDENT_CHAR = "\t"; + + /** + * Convert a Haxe object to a JSON string. + **/ + public static function toJSON(input:Dynamic, ?pretty:Bool = true):String + { + return Json.stringify(input, replacer, pretty ? INDENT_CHAR : null); + } + + /** + * Convert a JSON string to a Haxe object of the chosen type. + */ + public static function fromJSONTyped(input:String, type:Class):T + { + return cast Json.parse(input); + } + + /** + * Convert a JSON string to a Haxe object. + */ + public static function fromJSON(input:String):Dynamic + { + return Json.parse(input); + } + + /** + * Customize how certain types are serialized when converting to JSON. + */ + static function replacer(key:String, value:Dynamic):Dynamic + { + // Hacky because you can't use `isOfType` on a struct. + if (key == "version") + { + if (Std.isOfType(value, String)) + return value; + + // Stringify Version objects. + var valueVersion:thx.semver.Version = cast value; + var result = '${valueVersion.major}.${valueVersion.minor}.${valueVersion.patch}'; + if (valueVersion.hasPre) + result += '-${valueVersion.pre}'; + if (valueVersion.hasBuild) + result += '+${valueVersion.build}'; + return result; + } + + // Else, return the value as-is. + return value; + } +} diff --git a/source/funkin/util/macro/GitCommit.hx b/source/funkin/util/macro/GitCommit.hx index 35700e2f2..a9b6a6b65 100644 --- a/source/funkin/util/macro/GitCommit.hx +++ b/source/funkin/util/macro/GitCommit.hx @@ -20,7 +20,7 @@ class GitCommit var commitHash:String = process.stdout.readLine(); var commitHashSplice:String = commitHash.substr(0, 7); - trace('Git Commit ID ${commitHashSplice}'); + trace('Git Commit ID: ${commitHashSplice}'); // Generates a string expression return macro $v{commitHashSplice}; @@ -46,7 +46,7 @@ class GitCommit } var branchName:String = branchProcess.stdout.readLine(); - trace('Current Working Branch: ${branchName}'); + trace('Git Branch Name: ${branchName}'); // Generates a string expression return macro $v{branchName};