Chart editor basic difficulty switching

This commit is contained in:
Eric Myllyoja 2022-10-07 00:37:21 -04:00
parent 8a2e4687b2
commit 36a49affee
25 changed files with 3830 additions and 288 deletions

File diff suppressed because it is too large Load diff

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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));

View file

@ -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');

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -1,4 +1,4 @@
package funkin.rendering;
package funkin.graphics.rendering;
import flixel.FlxStrip;
import flixel.util.FlxColor;

View file

@ -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<Float> = new DrawData<Float>();
/**
* A `Vector` of integers or indexes, where every three indexes define a triangle.
*/
private var indices:DrawData<Int> = new DrawData<Int>();
/**
* A `Vector` of normalized coordinates used to apply texture mapping.
*/
private var uvtData:DrawData<Float> = new DrawData<Float>();
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<Int>(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);
}
}

View file

@ -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([]);

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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
{

View file

@ -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);
}
}
}
}

View file

@ -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<String>;
@ -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<Float>, events:Array<SongEventData>, notes:DynamicAccess<Array<SongNoteData>>)
public function new(scrollSpeed:Float, events:Array<SongEventData>, notes:Array<SongNoteData>)
{
this = {
version: SongMigrator.CHART_VERSION,
events: events,
notes: notes,
scrollSpeed: scrollSpeed,
notes: {
normal: notes
},
scrollSpeed: {
normal: scrollSpeed
},
generatedBy: SongValidator.DEFAULT_GENERATEDBY
}
}

View file

@ -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);
}
}

View file

@ -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';
}
}

View file

@ -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');
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<Component>, 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<T:Component>(criteria:String = null, type:Class<T> = null, recursive:Null<Bool> = null, searchType:String = "id"):Null<T>
{
if (component == null)
return null;
return component.findComponent(criteria, type, recursive, searchType);
}
override function destroy()
{
if (component != null)
remove(component);
component = null;
super.destroy();
}
}

View file

@ -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();
}
}
}

View file

@ -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<T>(input:String, type:Class<T>):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;
}
}

View file

@ -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};