1
0
Fork 0
mirror of https://github.com/ninjamuffin99/Funkin.git synced 2024-11-09 16:24:42 +00:00

Merge remote-tracking branch 'origin/rewrite/master' into feature/chart-editor-gamepad-support

This commit is contained in:
EliteMasterEric 2024-02-13 03:12:57 -05:00
commit c901763ad8
109 changed files with 6257 additions and 1647 deletions

View file

@ -31,7 +31,7 @@ jobs:
sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev
- name: build game
run: |
haxelib run lime build html5 -release --times
haxelib run lime build html5 -release --times -DGITHUB_BUILD
ls
- uses: ./.github/actions/upload-itch
with:
@ -68,7 +68,7 @@ jobs:
key: ${{ runner.os }}-build-win-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
- name: build game
run: |
haxelib run lime build windows -release -DNO_REDIRECT_ASSETS_FOLDER
haxelib run lime build windows -release -DNO_REDIRECT_ASSETS_FOLDER -DGITHUB_BUILD
dir
env:
HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache"
@ -110,7 +110,7 @@ jobs:
key: ${{ runner.os }}-build-mac-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
- name: Build game
run: |
haxelib run lime build macos -release --times
haxelib run lime build macos -release --times -DGITHUB_BUILD
ls
env:
HXCPP_COMPILE_CACHE: "${{ runner.temp }}/hxcpp_cache"
@ -119,7 +119,7 @@ jobs:
butler-key: ${{ secrets.BUTLER_API_KEY}}
build-dir: export/release/macos/bin
target: macos
# test-unit-win:
# needs: create-nightly-win
# runs-on: windows-latest

10
.vscode/settings.json vendored
View file

@ -110,6 +110,11 @@
"target": "windows",
"args": ["-debug", "-DSONG=bopeebo -DDIFFICULTY=normal"]
},
{
"label": "Windows / Debug (Conversation Test)",
"target": "windows",
"args": ["-debug", "-DDIALOGUE"]
},
{
"label": "Windows / Debug (Straight to Chart Editor)",
"target": "windows",
@ -125,6 +130,11 @@
"target": "windows",
"args": ["-debug", "-DLATENCY"]
},
{
"label": "Windows / Debug (Waveform Test)",
"target": "windows",
"args": ["-debug", "-DWAVEFORM"]
},
{
"label": "HTML5 / Debug",
"target": "html5",

View file

@ -52,6 +52,7 @@
<library name="week7" preload="false" />
<library name="weekend1" preload="false" />
</section>
<library name="art" preload="false" />
<assets path="assets/songs" library="songs" exclude="*.fla|*.ogg" if="web" />
<assets path="assets/songs" library="songs" exclude="*.fla|*.mp3" unless="web" />
<assets path="assets/shared" library="shared" exclude="*.fla|*.ogg" if="web" />
@ -82,14 +83,16 @@
If we can exclude the `mods` folder from the manifest, we can re-enable this line.
<assets path='example_mods' rename='mods' embed='false' exclude="*.md" />
-->
<assets path="art/readme.txt" rename="do NOT readme.txt" />
<assets path="CHANGELOG.md" rename="changelog.txt" />
<assets path="art/readme.txt" rename="do NOT readme.txt" library="art"/>
<assets path="CHANGELOG.md" rename="changelog.txt" library="art"/>
<!-- NOTE FOR FUTURE SELF SINCE FONTS ARE ALWAYS FUCKY
TO FIX ONE OF THEM, I CONVERTED IT TO OTF. DUNNO IF YOU NEED TO
THEN UHHH I USED THE NAME OF THE FONT WITH SETFORMAT() ON THE TEXT!!!
NOT USING A DIRECT THING TO THE ASSET!!!
-->
<assets path="assets/fonts" embed="true" />
<!-- If compiled via github actions, enable force debug -->
<set name="FORCE_DEBUG_VERSION" if="GITHUB_BUILD"/>
<!-- _______________________________ Libraries ______________________________ -->
<haxelib name="lime" /> <!-- Game engine backend -->
<haxelib name="openfl" /> <!-- Game engine backend -->
@ -108,7 +111,6 @@
<haxelib name="hxCodec" /> <!-- Video playback -->
<haxelib name="json2object" /> <!-- JSON parsing -->
<haxelib name="tink_json" /> <!-- JSON parsing (DEPRECATED) -->
<haxelib name="thx.semver" /> <!-- Version string handling -->
<haxelib name="hmm" /> <!-- Read library version data at compile time so it can be baked into logs -->
@ -117,15 +119,15 @@
<!--Disable the Flixel core focus lost screen-->
<haxedef name="FLX_NO_FOCUS_LOST_SCREEN" />
<!--Disable the Flixel core debugger. Automatically gets set whenever you compile in release mode!-->
<haxedef name="FLX_NO_DEBUG" unless="debug" />
<haxedef name="FLX_NO_DEBUG" unless="debug || FORCE_DEBUG_VERSION" />
<!--Enable this for Nape release builds for a serious peformance improvement-->
<haxedef name="NAPE_RELEASE_BUILD" unless="debug" />
<!--
Hide deprecation warnings until they're fixed.
TODO: REMOVE THIS!!!!
<haxeflag name="-w" value="-WDeprecated" />
-->
<haxeflag name="-w" value="-WDeprecated" />
<!-- Haxe 4.3.0+: Enable pretty syntax errors and stuff. -->
<haxedef name="message.reporting" value="pretty" />
@ -210,15 +212,8 @@
<haxedef name="openfl-enable-handle-error" />
</section>
<section>
<!-- TODO: Add a flag to Github Actions to turn this on or something. -->
<!-- Forces the version string to include the Git hash even on release builds (which are used for performance reasons). -->
<haxedef name="FORCE_DEBUG_VERSION" />
</section>
<!-- Run a script before and after building. -->
<postbuild haxe="source/Prebuild.hx"/> -->
<prebuild haxe="source/Prebuild.hx"/> -->
<postbuild haxe="source/Postbuild.hx"/> -->
<!-- Enable this on platforms which do not support dropping files onto the window. -->

View file

@ -13,7 +13,7 @@ This game was made with love to Newgrounds and it's community. Extra love to Tom
**PLEASE USE THE LINKS ABOVE IF YOU JUST WANT TO PLAY THE GAME**
To learn how to install the necessary dependencies and compile the game from source, please check out our [building the game]() guide.
To learn how to install the necessary dependencies and compile the game from source, please check out our [building the game](/docs/compiling.md) guide.
# Contributing

2
assets

@ -1 +1 @@
Subproject commit b2f8b6a780316959d0a79adc6dbf61f9e4ca675f
Subproject commit 599169f4786d1feb62a089d25112035977647805

19
docs/COMPILING.md Normal file
View file

@ -0,0 +1,19 @@
# Compiling Friday Night Funkin'
0. Setup
- Download Haxe from [Haxe.org](https://haxe.org)
1. Cloning the Repository: Make sure when you clone, you clone the submodules to get the assets repo:
- `git clone --recurse-submodules https://github.com/FunkinCrew/funkin-secret.git`
- If you accidentally cloned without the `assets` submodule (aka didn't follow the step above), you can run `git submodule update --init --recursive` to get the assets in a foolproof way.
2. Install `hmm` (run `haxelib --global install hmm` and then `haxelib --global run hmm setup`)
3. Install all haxelibs of the current branch by running `hmm install`
4. Platform setup
- For Windows, download the [Visual Studio Build Tools](https://aka.ms/vs/17/release/vs_BuildTools.exe)
- When prompted, select "Individual Components" and make sure to download the following:
- MSVC v143 VS 2022 C++ x64/x86 build tools
- Windows 10/11 SDK
- Mac: [`lime setup mac` Documentation](https://lime.openfl.org/docs/advanced-setup/macos/)
- Linux: [`lime setup linux` Documentation](https://lime.openfl.org/docs/advanced-setup/linux/)
- HTML5: Compiles without any extra setup
5. If you are targeting for native, you likely need to run `lime rebuild PLATFORM` and `lime rebuild PLATFORM -debug`
6. `lime test PLATFORM` !

View file

@ -10,3 +10,6 @@
- `Class lists not properly generated. Try cleaning out your export folder, restarting your IDE, and rebuilding your project.`
- This is a bug specific to HTML5. Simply perform the steps listed (don't forget to restart the IDE too).
- `LINK : fatal error LNK1201: error writing to program database ''; check for insufficient disk space, invalid path, or insufficient privilege`
- This error occurs if the PDB file located in your `export` folder is in use or exceeds 4 GB. Try deleting the `export` folder and building again from scratch.

View file

@ -11,14 +11,14 @@
"name": "flixel",
"type": "git",
"dir": null,
"ref": "07c6018008801972d12275690fc144fcc22e3de6",
"ref": "4d054bd10b05bb1309a0ba3427ffa5378e0b4b99",
"url": "https://github.com/FunkinCrew/flixel"
},
{
"name": "flixel-addons",
"type": "git",
"dir": null,
"ref": "fd3aecdeb5635fa0428dffee204fc78fc26b5885",
"ref": "a523c3b56622f0640933944171efed46929e360e",
"url": "https://github.com/FunkinCrew/flixel-addons"
},
{
@ -54,7 +54,7 @@
"name": "haxeui-core",
"type": "git",
"dir": null,
"ref": "5b2d5b8e7e470cf637953e1369c80a1f42016a75",
"ref": "8a7846b",
"url": "https://github.com/haxeui/haxeui-core"
},
{
@ -88,8 +88,10 @@
},
{
"name": "hxcpp-debug-server",
"type": "haxelib",
"version": "1.2.4"
"type": "git",
"dir": "hxcpp-debug-server",
"ref": "147294123f983e35f50a966741474438069a7a8f",
"url": "https://github.com/FunkinCrew/hxcpp-debugger"
},
{
"name": "hxp",
@ -149,18 +151,20 @@
"name": "polymod",
"type": "git",
"dir": null,
"ref": "cb11a95d0159271eb3587428cf4b9602e46dc469",
"ref": "0b53e478bc375ec51b760b650201ac7a965d2ef4",
"url": "https://github.com/larsiusprime/polymod"
},
{
"name": "thx.core",
"type": "git",
"dir": null,
"ref": "22605ff44f01971d599641790d6bae4869f7d9f4",
"url": "https://github.com/FunkinCrew/thx.core"
},
{
"name": "thx.semver",
"type": "haxelib",
"version": "0.2.2"
},
{
"name": "tink_json",
"type": "haxelib",
"version": "0.11.0"
}
]
}

View file

@ -33,6 +33,10 @@ class Main extends Sprite
public static function main():Void
{
// We need to make the crash handler LITERALLY FIRST so nothing EVER gets past it.
CrashHandler.initialize();
CrashHandler.queryStatus();
Lib.current.addChild(new Main());
}
@ -40,7 +44,12 @@ class Main extends Sprite
{
super();
// TODO: Replace this with loadEnabledMods().
// Initialize custom logging.
haxe.Log.trace = funkin.util.logging.AnsiTrace.trace;
funkin.util.logging.AnsiTrace.traceBF();
// Load mods to override assets.
// TODO: Replace with loadEnabledMods() once the user can configure the mod list.
funkin.modding.PolymodHandler.loadAllMods();
if (stage != null)
@ -80,10 +89,6 @@ class Main extends Sprite
* -Eric
*/
CrashHandler.initialize();
CrashHandler.queryStatus();
initHaxeUI();
fpsCounter = new FPS(10, 3, 0xFFFFFF);

View file

@ -16,7 +16,7 @@ import flixel.addons.transition.FlxTransitionableState;
* var in:TransitionData = new TransitionData(...); // add your data where "..." is
* var out:TransitionData = new TransitionData(...);
*
* FlxG.switchState(new FooState(in,out));
* FlxG.switchState(() -> new FooState(in,out));
* ```
*
* Method 2:
@ -25,7 +25,7 @@ import flixel.addons.transition.FlxTransitionableState;
* FlxTransitionableSubState.defaultTransIn = new TransitionData(...);
* FlxTransitionableSubState.defaultTransOut = new TransitionData(...);
*
* FlxG.switchState(new FooState());
* FlxG.switchState(() -> new FooState());
* ```
*/
class FlxTransitionableSubState extends FlxSubState

View file

@ -21,9 +21,9 @@ import funkin.data.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.data.event.SongEventRegistry;
import funkin.data.stage.StageRegistry;
import funkin.play.cutscene.dialogue.ConversationDataParser;
import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
import funkin.play.cutscene.dialogue.SpeakerDataParser;
import funkin.data.dialogue.ConversationRegistry;
import funkin.data.dialogue.DialogueBoxRegistry;
import funkin.data.dialogue.SpeakerRegistry;
import funkin.data.song.SongRegistry;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.modding.module.ModuleHandler;
@ -89,7 +89,7 @@ class InitState extends FlxState
//
// FLIXEL DEBUG SETUP
//
#if debug
#if (debug || FORCE_DEBUG_VERSION)
// Disable using ~ to open the console (we use that for the Editor menu)
FlxG.debugger.toggleKeys = [F2];
@ -141,16 +141,14 @@ class InitState extends FlxState
FlxG.sound.music.pause();
FlxG.sound.music.time += FlxG.elapsed * 1000;
});
#end
// Make errors and warnings less annoying.
// TODO: Disable this so we know to fix warnings.
if (false)
{
LogStyle.ERROR.openConsole = false;
LogStyle.ERROR.errorSound = null;
LogStyle.WARNING.openConsole = false;
LogStyle.WARNING.errorSound = null;
}
#if FORCE_DEBUG_VERSION
LogStyle.ERROR.openConsole = false;
LogStyle.ERROR.errorSound = null;
LogStyle.WARNING.openConsole = false;
LogStyle.WARNING.errorSound = null;
#end
//
@ -208,22 +206,29 @@ class InitState extends FlxState
// GAME DATA PARSING
//
// NOTE: Registries and data parsers must be imported and not referenced with fully qualified names,
// NOTE: Registries must be imported and not referenced with fully qualified names,
// to ensure build macros work properly.
trace('Parsing game data...');
var perfStart = haxe.Timer.stamp();
SongEventRegistry.loadEventCache(); // SongEventRegistry is structured differently so it's not a BaseRegistry.
SongRegistry.instance.loadEntries();
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
SongEventRegistry.loadEventCache();
ConversationDataParser.loadConversationCache();
DialogueBoxDataParser.loadDialogueBoxCache();
SpeakerDataParser.loadSpeakerCache();
ConversationRegistry.instance.loadEntries();
DialogueBoxRegistry.instance.loadEntries();
SpeakerRegistry.instance.loadEntries();
StageRegistry.instance.loadEntries();
CharacterDataParser.loadCharacterCache();
// TODO: CharacterDataParser doesn't use json2object, so it's way slower than the other parsers.
CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
ModuleHandler.buildModuleCallbacks();
ModuleHandler.loadModuleCache();
ModuleHandler.callOnCreate();
var perfEnd = haxe.Timer.stamp();
trace('Parsing game data took ${Math.floor((perfEnd - perfStart) * 1000)}ms.');
}
/**
@ -240,17 +245,21 @@ class InitState extends FlxState
#elseif LEVEL // -DLEVEL=week1 -DDIFFICULTY=hard
startLevel(defineLevel(), defineDifficulty());
#elseif FREEPLAY // -DFREEPLAY
FlxG.switchState(new FreeplayState());
FlxG.switchState(() -> new funkin.ui.freeplay.FreeplayState());
#elseif DIALOGUE // -DDIALOGUE
FlxG.switchState(() -> new funkin.ui.debug.dialogue.ConversationDebugState());
#elseif ANIMATE // -DANIMATE
FlxG.switchState(new funkin.ui.debug.anim.FlxAnimateTest());
FlxG.switchState(() -> new funkin.ui.debug.anim.FlxAnimateTest());
#elseif WAVEFORM // -DWAVEFORM
FlxG.switchState(() -> new funkin.ui.debug.WaveformTestState());
#elseif CHARTING // -DCHARTING
FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState());
FlxG.switchState(() -> new funkin.ui.debug.charting.ChartEditorState());
#elseif STAGEBUILD // -DSTAGEBUILD
FlxG.switchState(new funkin.ui.debug.stage.StageBuilderState());
FlxG.switchState(() -> new funkin.ui.debug.stage.StageBuilderState());
#elseif ANIMDEBUG // -DANIMDEBUG
FlxG.switchState(new funkin.ui.debug.anim.DebugBoundingState());
FlxG.switchState(() -> new funkin.ui.debug.anim.DebugBoundingState());
#elseif LATENCY // -DLATENCY
FlxG.switchState(new funkin.LatencyState());
FlxG.switchState(() -> new funkin.LatencyState());
#else
startGameNormally();
#end
@ -266,7 +275,7 @@ class InitState extends FlxState
if (params.chart.shouldLoadChart)
{
FlxG.switchState(new ChartEditorState(
FlxG.switchState(() -> new ChartEditorState(
{
fnfcTargetPath: params.chart.chartPath,
}));
@ -274,7 +283,7 @@ class InitState extends FlxState
else
{
FlxG.sound.cache(Paths.music('freakyMenu/freakyMenu'));
FlxG.switchState(new TitleState());
FlxG.switchState(() -> new TitleState());
}
}
@ -297,7 +306,7 @@ class InitState extends FlxState
// TODO: Do this in the loading state.
songData.cacheCharts(true);
LoadingState.loadAndSwitchState(new funkin.play.PlayState(
LoadingState.loadAndSwitchState(() -> new funkin.play.PlayState(
{
targetSong: songData,
targetDifficulty: difficultyId,
@ -327,7 +336,7 @@ class InitState extends FlxState
var targetSong:funkin.play.song.Song = SongRegistry.instance.fetchEntry(targetSongId);
LoadingState.loadAndSwitchState(new funkin.play.PlayState(
LoadingState.loadAndSwitchState(() -> new funkin.play.PlayState(
{
targetSong: targetSong,
targetDifficulty: difficultyId,

View file

@ -7,6 +7,10 @@ import flash.utils.ByteArray;
import flixel.sound.FlxSound;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.system.FlxAssets.FlxSoundAsset;
import funkin.util.tools.ICloneable;
import funkin.audio.waveform.WaveformData;
import funkin.audio.waveform.WaveformDataParser;
import flixel.math.FlxMath;
import openfl.Assets;
#if (openfl >= "8.0.0")
import openfl.utils.AssetType;
@ -17,10 +21,38 @@ import openfl.utils.AssetType;
* - Delayed playback via negative song position.
*/
@:nullSafety
class FunkinSound extends FlxSound
class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
{
static final MAX_VOLUME:Float = 2.0;
static var cache(default, null):FlxTypedGroup<FunkinSound> = new FlxTypedGroup<FunkinSound>();
public var muted(default, set):Bool = false;
function set_muted(value:Bool):Bool
{
if (value == muted) return value;
muted = value;
updateTransform();
return value;
}
override function set_volume(value:Float):Float
{
// Uncap the volume.
fixMaxVolume();
_volume = FlxMath.bound(value, 0.0, MAX_VOLUME);
updateTransform();
return _volume;
}
public var paused(get, never):Bool;
function get_paused():Bool
{
return this._paused;
}
public var isPlaying(get, never):Bool;
function get_isPlaying():Bool
@ -28,6 +60,24 @@ class FunkinSound extends FlxSound
return this.playing || this._shouldPlay;
}
/**
* Waveform data for this sound.
* This is lazily loaded, so it will be built the first time it is accessed.
*/
public var waveformData(get, never):WaveformData;
var _waveformData:Null<WaveformData> = null;
function get_waveformData():WaveformData
{
if (_waveformData == null)
{
_waveformData = WaveformDataParser.interpretFlxSound(this);
if (_waveformData == null) throw 'Could not interpret waveform data!';
}
return _waveformData;
}
/**
* Are we in a state where the song should play but time is negative?
*/
@ -63,6 +113,30 @@ class FunkinSound extends FlxSound
}
}
public function togglePlayback():FunkinSound
{
if (playing)
{
pause();
}
else
{
resume();
}
return this;
}
function fixMaxVolume():Void
{
#if lime_openal
// This code is pretty fragile, it reaches through 5 layers of private access.
@:privateAccess
var handle = this?._channel?.__source?.__backend?.handle;
if (handle == null) return;
lime.media.openal.AL.sourcef(handle, lime.media.openal.AL.MAX_GAIN, MAX_VOLUME);
#end
}
public override function play(forceRestart:Bool = false, startTime:Float = 0, ?endTime:Float):FunkinSound
{
if (!exists) return this;
@ -140,6 +214,37 @@ class FunkinSound extends FlxSound
return this;
}
/**
* Call after adjusting the volume to update the sound channel's settings.
*/
@:allow(flixel.sound.FlxSoundGroup)
override function updateTransform():Void
{
_transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
(group != null ? group.volume : 1) * _volume * _volumeAdjust;
if (_channel != null) _channel.soundTransform = _transform;
}
public function clone():FunkinSound
{
var sound:FunkinSound = new FunkinSound();
// Clone the sound by creating one with the same data buffer.
// Reusing the `Sound` object directly causes issues with playback.
@:privateAccess
sound._sound = openfl.media.Sound.fromAudioBuffer(this._sound.__buffer);
// Call init to ensure the FlxSound is properly initialized.
sound.init(this.looped, this.autoDestroy, this.onComplete);
// Oh yeah, the waveform data is the same too!
@:privateAccess
sound._waveformData = this._waveformData;
return sound;
}
/**
* Creates a new `FunkinSound` object.
*

View file

@ -3,6 +3,7 @@ package funkin.audio;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.sound.FlxSound;
import funkin.audio.FunkinSound;
import flixel.tweens.FlxTween;
/**
* A group of FunkinSounds that are all synced together.
@ -14,8 +15,12 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
public var volume(get, set):Float;
public var muted(get, set):Bool;
public var pitch(get, set):Float;
public var playing(get, never):Bool;
public function new()
{
super();
@ -122,6 +127,26 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
});
}
/**
* Fade in all the sounds in the group.
*/
public function fadeIn(duration:Float, ?from:Float = 0.0, ?to:Float = 1.0, ?onComplete:FlxTween->Void):Void
{
forEachAlive(function(sound:FunkinSound) {
sound.fadeIn(duration, from, to, onComplete);
});
}
/**
* Fade out all the sounds in the group.
*/
public function fadeOut(duration:Float, ?to:Float = 0.0, ?onComplete:FlxTween->Void):Void
{
forEachAlive(function(sound:FunkinSound) {
sound.fadeOut(duration, to, onComplete);
});
}
/**
* Stop all the sounds in the group.
*/
@ -165,6 +190,13 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
return time;
}
function get_playing():Bool
{
if (getFirstAlive != null) return getFirstAlive().playing;
else
return false;
}
function get_volume():Float
{
if (getFirstAlive() != null) return getFirstAlive().volume;
@ -182,6 +214,22 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
return volume;
}
function get_muted():Bool
{
if (getFirstAlive() != null) return getFirstAlive().muted;
else
return false;
}
function set_muted(muted:Bool):Bool
{
forEachAlive(function(snd:FunkinSound) {
snd.muted = muted;
});
return muted;
}
function get_pitch():Float
{
#if FLX_PITCH

View file

@ -2,6 +2,8 @@ package funkin.audio;
import funkin.audio.FunkinSound;
import flixel.group.FlxGroup.FlxTypedGroup;
import funkin.audio.waveform.WaveformData;
import funkin.audio.waveform.WaveformDataParser;
class VoicesGroup extends SoundGroup
{
@ -104,6 +106,50 @@ class VoicesGroup extends SoundGroup
return opponentVolume = volume;
}
public function getPlayerVoice(index:Int = 0):Null<FunkinSound>
{
return playerVoices.members[index];
}
public function getOpponentVoice(index:Int = 0):Null<FunkinSound>
{
return opponentVoices.members[index];
}
public function getPlayerVoiceWaveform():Null<WaveformData>
{
if (playerVoices.members.length == 0) return null;
return playerVoices.members[0].waveformData;
}
public function getOpponentVoiceWaveform():Null<WaveformData>
{
if (opponentVoices.members.length == 0) return null;
return opponentVoices.members[0].waveformData;
}
/**
* The length of the player's vocal track, in milliseconds.
*/
public function getPlayerVoiceLength():Float
{
if (playerVoices.members.length == 0) return 0.0;
return playerVoices.members[0].length;
}
/**
* The length of the opponent's vocal track, in milliseconds.
*/
public function getOpponentVoiceLength():Float
{
if (opponentVoices.members.length == 0) return 0.0;
return opponentVoices.members[0].length;
}
public override function clear():Void
{
playerVoices.clear();

View file

@ -102,7 +102,7 @@ class PolygonSpectogram extends MeshRender
coolPoint.x = (curAud.balanced * waveAmplitude);
coolPoint.y = (i / funnyPixels * daHeight);
add_quad(prevPoint.x, prevPoint.y, prevPoint.x
build_quad(prevPoint.x, prevPoint.y, prevPoint.x
+ thickness, prevPoint.y, coolPoint.x, coolPoint.y, coolPoint.x
+ thickness, coolPoint.y
+ thickness);

View file

@ -0,0 +1,336 @@
package funkin.audio.waveform;
import funkin.util.MathUtil;
@:nullSafety
class WaveformData
{
static final DEFAULT_VERSION:Int = 2;
/**
* The version of the waveform data format.
* @default `2` (-1 if not specified/invalid)
*/
public var version(default, null):Int = -1;
/**
* The number of channels in the waveform.
*/
public var channels(default, null):Int = 1;
@:alias('sample_rate')
public var sampleRate(default, null):Int = 44100;
/**
* Number of input audio samples per output waveform data point.
* At base zoom level this is number of samples per pixel.
* Lower values can more accurately represent the waveform when zoomed in, but take more data.
*/
@:alias('samples_per_pixel')
public var samplesPerPoint(default, null):Int = 256;
/**
* Number of bits to use for each sample value. Valid values are `8` and `16`.
*/
public var bits(default, null):Int = 16;
/**
* The length of the data array, in points.
*/
public var length(default, null):Int = 0;
/**
* Array of Int16 values representing the waveform.
* TODO: Use an `openfl.Vector` for performance.
*/
public var data(default, null):Array<Int> = [];
@:jignored
var channelData:Null<Array<WaveformDataChannel>> = null;
public function new(?version:Int, channels:Int, sampleRate:Int, samplesPerPoint:Int, bits:Int, length:Int, data:Array<Int>)
{
this.version = version ?? DEFAULT_VERSION;
this.channels = channels;
this.sampleRate = sampleRate;
this.samplesPerPoint = samplesPerPoint;
this.bits = bits;
this.length = length;
this.data = data;
}
function buildChannelData():Array<WaveformDataChannel>
{
channelData = [];
for (i in 0...channels)
{
channelData.push(new WaveformDataChannel(this, i));
}
return channelData;
}
public function channel(index:Int)
{
return (channelData == null) ? buildChannelData()[index] : channelData[index];
}
public function get(index:Int):Int
{
return data[index] ?? 0;
}
public function set(index:Int, value:Int)
{
data[index] = value;
}
/**
* Maximum possible value for a waveform data point.
* The minimum possible value is (-1 * maxSampleValue)
*/
public function maxSampleValue():Int
{
if (_maxSampleValue != 0) return _maxSampleValue;
return _maxSampleValue = Std.int(Math.pow(2, bits));
}
/**
* Cache the value because `Math.pow` is expensive and the value gets used a lot.
*/
@:jignored
var _maxSampleValue:Int = 0;
/**
* @return The length of the waveform in samples.
*/
public function lenSamples():Int
{
return length * samplesPerPoint;
}
/**
* @return The length of the waveform in seconds.
*/
public function lenSeconds():Float
{
return inline lenSamples() / sampleRate;
}
/**
* Given the time in seconds, return the waveform data point index.
*/
public function secondsToIndex(seconds:Float):Int
{
return Std.int(seconds * inline pointsPerSecond());
}
/**
* Given a waveform data point index, return the time in seconds.
*/
public function indexToSeconds(index:Int):Float
{
return index / inline pointsPerSecond();
}
/**
* The number of data points this waveform data provides per second of audio.
*/
public inline function pointsPerSecond():Float
{
return sampleRate / samplesPerPoint;
}
/**
* Given the percentage progress through the waveform, return the waveform data point index.
*/
public function percentToIndex(percent:Float):Int
{
return Std.int(percent * length);
}
/**
* Given a waveform data point index, return the percentage progress through the waveform.
*/
public function indexToPercent(index:Int):Float
{
return index / length;
}
/**
* Resample the waveform data to create a new WaveformData object matching the desired `samplesPerPoint` value.
* This is useful for zooming in/out of the waveform in a performant manner.
*
* @param newSamplesPerPoint The new value for `samplesPerPoint`.
*/
public function resample(newSamplesPerPoint:Int):WaveformData
{
var result = this.clone();
var ratio = newSamplesPerPoint / samplesPerPoint;
if (ratio == 1) return result;
if (ratio < 1) trace('[WARNING] Downsampling will result in a low precision.');
var inputSampleCount = this.lenSamples();
var outputSampleCount = Std.int(inputSampleCount * ratio);
var inputPointCount = this.length;
var outputPointCount = Std.int(inputPointCount / ratio);
var outputChannelCount = this.channels;
// TODO: Actually figure out the dumbass logic for this.
return result;
}
/**
* Create a new WaveformData whose data represents the two waveforms overlayed.
*/
public function merge(that:WaveformData):WaveformData
{
var result = this.clone([]);
for (channelIndex in 0...this.channels)
{
var thisChannel = this.channel(channelIndex);
var thatChannel = that.channel(channelIndex);
var resultChannel = result.channel(channelIndex);
for (index in 0...this.length)
{
var thisMinSample = thisChannel.minSample(index);
var thatMinSample = thatChannel.minSample(index);
var thisMaxSample = thisChannel.maxSample(index);
var thatMaxSample = thatChannel.maxSample(index);
resultChannel.setMinSample(index, Std.int(Math.min(thisMinSample, thatMinSample)));
resultChannel.setMaxSample(index, Std.int(Math.max(thisMaxSample, thatMaxSample)));
}
}
@:privateAccess
result.length = this.length;
return result;
}
/**
* Create a new WaveformData whose parameters match the current object.
*/
public function clone(?newData:Array<Int> = null):WaveformData
{
if (newData == null)
{
newData = this.data.clone();
}
var clone = new WaveformData(this.version, this.channels, this.sampleRate, this.samplesPerPoint, this.bits, newData.length, newData);
return clone;
}
}
@:nullSafety
class WaveformDataChannel
{
var parent:WaveformData;
var channelId:Int;
public function new(parent:WaveformData, channelId:Int)
{
this.parent = parent;
this.channelId = channelId;
}
/**
* Retrieve a given minimum point at an index.
*/
public function minSample(i:Int)
{
var offset = (i * parent.channels + this.channelId) * 2;
return inline parent.get(offset);
}
/**
* Mapped to a value between 0 and 1.
*/
public function minSampleMapped(i:Int)
{
return inline minSample(i) / inline parent.maxSampleValue();
}
/**
* Minimum value within the range of samples.
* NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead.
*/
public function minSampleRange(start:Int, end:Int)
{
var min = inline parent.maxSampleValue();
for (i in start...end)
{
var sample = inline minSample(i);
if (sample < min) min = sample;
}
return min;
}
/**
* Maximum value within the range of samples, mapped to a value between 0 and 1.
*/
public function minSampleRangeMapped(start:Int, end:Int)
{
return inline minSampleRange(start, end) / inline parent.maxSampleValue();
}
/**
* Retrieve a given maximum point at an index.
*/
public function maxSample(i:Int)
{
var offset = (i * parent.channels + this.channelId) * 2 + 1;
return inline parent.get(offset);
}
/**
* Mapped to a value between 0 and 1.
*/
public function maxSampleMapped(i:Int)
{
return inline maxSample(i) / inline parent.maxSampleValue();
}
/**
* Maximum value within the range of samples.
* NOTE: Inefficient for large ranges. Use `WaveformData.remap` instead.
*/
public function maxSampleRange(start:Int, end:Int)
{
var max = -(inline parent.maxSampleValue());
for (i in start...end)
{
var sample = inline maxSample(i);
if (sample > max) max = sample;
}
return max;
}
/**
* Maximum value within the range of samples, mapped to a value between 0 and 1.
*/
public function maxSampleRangeMapped(start:Int, end:Int)
{
return inline maxSampleRange(start, end) / inline parent.maxSampleValue();
}
public function setMinSample(i:Int, value:Int)
{
var offset = (i * parent.channels + this.channelId) * 2;
inline parent.set(offset, value);
}
public function setMaxSample(i:Int, value:Int)
{
var offset = (i * parent.channels + this.channelId) * 2 + 1;
inline parent.set(offset, value);
}
}

View file

@ -0,0 +1,145 @@
package funkin.audio.waveform;
class WaveformDataParser
{
static final INT16_MAX:Int = 32767;
static final INT16_MIN:Int = -32768;
static final INT8_MAX:Int = 127;
static final INT8_MIN:Int = -128;
public static function interpretFlxSound(sound:flixel.sound.FlxSound):Null<WaveformData>
{
if (sound == null) return null;
// Method 1. This only works if the sound has been played before.
@:privateAccess
var soundBuffer:Null<lime.media.AudioBuffer> = sound?._channel?.__source?.buffer;
if (soundBuffer == null)
{
// Method 2. This works if the sound has not been played before.
@:privateAccess
soundBuffer = sound?._sound?.__buffer;
if (soundBuffer == null)
{
trace('[WAVEFORM] Failed to interpret FlxSound: ${sound}');
return null;
}
else
{
// trace('[WAVEFORM] Method 2 worked.');
}
}
else
{
// trace('[WAVEFORM] Method 1 worked.');
}
return interpretAudioBuffer(soundBuffer);
}
public static function interpretAudioBuffer(soundBuffer:lime.media.AudioBuffer):Null<WaveformData>
{
var sampleRate = soundBuffer.sampleRate;
var channels = soundBuffer.channels;
var bitsPerSample = soundBuffer.bitsPerSample;
var samplesPerPoint:Int = 256; // I don't think we need to configure this.
var pointsPerSecond:Float = sampleRate / samplesPerPoint; // 172 samples per second for most songs is plenty precise while still being performant..
// TODO: Make this work better on HTML5.
var soundData:lime.utils.Int16Array = cast soundBuffer.data;
var soundDataRawLength:Int = soundData.length;
var soundDataSampleCount:Int = Std.int(Math.ceil(soundDataRawLength / channels / (bitsPerSample == 16 ? 2 : 1)));
var outputPointCount:Int = Std.int(Math.ceil(soundDataSampleCount / samplesPerPoint));
// trace('Interpreting audio buffer:');
// trace(' sampleRate: ${sampleRate}');
// trace(' channels: ${channels}');
// trace(' bitsPerSample: ${bitsPerSample}');
// trace(' samplesPerPoint: ${samplesPerPoint}');
// trace(' pointsPerSecond: ${pointsPerSecond}');
// trace(' soundDataRawLength: ${soundDataRawLength}');
// trace(' soundDataSampleCount: ${soundDataSampleCount}');
// trace(' soundDataRawLength/4: ${soundDataRawLength / 4}');
// trace(' outputPointCount: ${outputPointCount}');
var minSampleValue:Int = bitsPerSample == 16 ? INT16_MIN : INT8_MIN;
var maxSampleValue:Int = bitsPerSample == 16 ? INT16_MAX : INT8_MAX;
var outputData:Array<Int> = [];
var perfStart = haxe.Timer.stamp();
for (pointIndex in 0...outputPointCount)
{
// minChannel1, maxChannel1, minChannel2, maxChannel2, ...
var values:Array<Int> = [];
for (i in 0...channels)
{
values.push(bitsPerSample == 16 ? INT16_MAX : INT8_MAX);
values.push(bitsPerSample == 16 ? INT16_MIN : INT8_MIN);
}
var rangeStart = pointIndex * samplesPerPoint;
var rangeEnd = rangeStart + samplesPerPoint;
if (rangeEnd > soundDataSampleCount) rangeEnd = soundDataSampleCount;
for (sampleIndex in rangeStart...rangeEnd)
{
for (channelIndex in 0...channels)
{
var sampleIndex:Int = sampleIndex * channels + channelIndex;
var sampleValue = soundData[sampleIndex];
if (sampleValue < values[channelIndex * 2]) values[(channelIndex * 2)] = sampleValue;
if (sampleValue > values[channelIndex * 2 + 1]) values[(channelIndex * 2) + 1] = sampleValue;
}
}
// We now have the min and max values for the range.
for (value in values)
outputData.push(value);
}
var outputDataLength:Int = Std.int(outputData.length / channels / 2);
var result = new WaveformData(null, channels, sampleRate, samplesPerPoint, bitsPerSample, outputPointCount, outputData);
var perfEnd = haxe.Timer.stamp();
trace('[WAVEFORM] Interpreted audio buffer in ${perfEnd - perfStart} seconds.');
return result;
}
public static function parseWaveformData(path:String):Null<WaveformData>
{
var rawJson:String = openfl.Assets.getText(path).trim();
return parseWaveformDataString(rawJson, path);
}
public static function parseWaveformDataString(contents:String, ?fileName:String):Null<WaveformData>
{
var parser = new json2object.JsonParser<WaveformData>();
parser.ignoreUnknownVariables = false;
trace('[WAVEFORM] Parsing waveform data: ${contents}');
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value;
}
static function printErrors(errors:Array<json2object.Error>, id:String = ''):Void
{
trace('[WAVEFORM] Failed to parse waveform data: ${id}');
for (error in errors)
funkin.data.DataError.printError(error);
}
}

View file

@ -0,0 +1,449 @@
package funkin.audio.waveform;
import funkin.audio.waveform.WaveformData;
import funkin.audio.waveform.WaveformDataParser;
import funkin.graphics.rendering.MeshRender;
import flixel.util.FlxColor;
class WaveformSprite extends MeshRender
{
static final DEFAULT_COLOR:FlxColor = FlxColor.WHITE;
static final DEFAULT_DURATION:Float = 5.0;
static final DEFAULT_ORIENTATION:WaveformOrientation = HORIZONTAL;
static final DEFAULT_X:Float = 0.0;
static final DEFAULT_Y:Float = 0.0;
static final DEFAULT_WIDTH:Float = 100.0;
static final DEFAULT_HEIGHT:Float = 100.0;
/**
* Set this to true to tell the waveform to rebuild itself.
* Do this any time the data or drawable area of the waveform changes.
* This often (but not always) needs to be done every frame.
*/
var isWaveformDirty:Bool = true;
/**
* If true, force the waveform to redraw every frame.
* Useful if the waveform's clipRect is constantly changing.
*/
public var forceUpdate:Bool = false;
public var waveformData(default, set):Null<WaveformData>;
function set_waveformData(value:Null<WaveformData>):Null<WaveformData>
{
if (waveformData == value) return value;
waveformData = value;
isWaveformDirty = true;
return waveformData;
}
/**
* The color to render the waveform with.
*/
public var waveformColor(default, set):FlxColor;
function set_waveformColor(value:FlxColor):FlxColor
{
if (waveformColor == value) return value;
waveformColor = value;
// We don't need to dirty the waveform geometry, just rebuild the texture.
rebuildGraphic();
return waveformColor;
}
public var orientation(default, set):WaveformOrientation;
function set_orientation(value:WaveformOrientation):WaveformOrientation
{
if (orientation == value) return value;
orientation = value;
isWaveformDirty = true;
return orientation;
}
/**
* Time, in seconds, at which the waveform starts.
*/
public var time(default, set):Float;
function set_time(value:Float)
{
if (time == value) return value;
time = value;
isWaveformDirty = true;
return time;
}
/**
* The duration, in seconds, that the waveform represents.
* The section of waveform from `time` to `time + duration` and `width` are used to determine how many samples each pixel represents.
*/
public var duration(default, set):Float;
function set_duration(value:Float)
{
if (duration == value) return value;
duration = value;
isWaveformDirty = true;
return duration;
}
/**
* Set the physical size of the waveform with `this.height = value`.
*/
override function set_height(value:Float):Float
{
if (height == value) return super.set_height(value);
isWaveformDirty = true;
return super.set_height(value);
}
/**
* Set the physical size of the waveform with `this.width = value`.
*/
override function set_width(value:Float):Float
{
if (width == value) return super.set_width(value);
isWaveformDirty = true;
return super.set_width(value);
}
/**
* The minimum size, in pixels, that a waveform will display with.
* Useful for preventing the waveform from becoming too small to see.
*
* NOTE: This is technically doubled since it's applied above and below the center of the waveform.
*/
public var minWaveformSize:Int = 1;
/**
* A multiplier on the size of the waveform.
* Still capped at the width and height set for the sprite.
*/
public var amplitude:Float = 1.0;
public function new(?waveformData:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
{
super(DEFAULT_X, DEFAULT_Y, DEFAULT_COLOR);
this.waveformColor = color ?? DEFAULT_COLOR;
this.width = DEFAULT_WIDTH;
this.height = DEFAULT_HEIGHT;
this.waveformData = waveformData;
this.orientation = orientation ?? DEFAULT_ORIENTATION;
this.time = 0.0;
this.duration = duration ?? DEFAULT_DURATION;
this.forceUpdate = false;
}
/**
* Manually tell the waveform to rebuild itself, even if none of its properties have changed.
*/
public function markDirty():Void
{
isWaveformDirty = true;
}
public override function update(elapsed:Float)
{
super.update(elapsed);
if (forceUpdate || isWaveformDirty)
{
// Recalculate the waveform vertices.
drawWaveform();
isWaveformDirty = false;
}
}
function rebuildGraphic():Void
{
// The waveform is rendered using a single colored pixel as a texture.
// If you want something more elaborate, make sure to modify `build_vertex` below to use the UVs you want.
makeGraphic(1, 1, this.waveformColor);
}
/**
* @param offsetX Horizontal offset to draw the waveform at, in samples.
*/
function drawWaveform():Void
{
// For each sample in the waveform...
// Add a MAX vertex and a MIN vertex.
// If previous MAX/MIN is empty, store.
// If previous MAX/MIN is not empty, draw a quad using current and previous MAX/MIN. Then store current MAX/MIN.
// Continue until end of waveform.
this.clear();
if (waveformData == null) return;
// Center point of the waveform. When horizontal this is half the height, when vertical this is half the width.
var waveformCenterPos:Int = orientation == HORIZONTAL ? Std.int(this.height / 2) : Std.int(this.width / 2);
var oneSecondInIndices:Int = waveformData.secondsToIndex(1);
var startTime:Float = time;
var endTime:Float = time + duration;
var startIndex:Int = waveformData.secondsToIndex(startTime);
var endIndex:Int = waveformData.secondsToIndex(endTime);
var pixelsPerIndex:Float = (orientation == HORIZONTAL ? this.width : this.height) / (endIndex - startIndex);
var indexesPerPixel:Float = 1 / pixelsPerIndex;
var topLeftVertexIndex:Int = -1;
var topRightVertexIndex:Int = -1;
var bottomLeftVertexIndex:Int = -1;
var bottomRightVertexIndex:Int = -1;
if (clipRect != null)
{
topLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y);
topRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y);
bottomLeftVertexIndex = this.build_vertex(clipRect.x, clipRect.y + clipRect.height);
bottomRightVertexIndex = this.build_vertex(clipRect.x + clipRect.width, clipRect.y + clipRect.height);
}
if (pixelsPerIndex >= 1.0)
{
// Each index is at least one pixel wide, so we render each index.
var prevVertexTopIndex:Int = -1;
var prevVertexBottomIndex:Int = -1;
for (i in startIndex...endIndex)
{
var pixelPos:Int = Std.int((i - startIndex) * pixelsPerIndex);
var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y);
if (isBeforeClipRect) continue;
var isAfterClipRect:Bool = (clipRect != null)
&& ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height));
if (isAfterClipRect)
{
break;
};
var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleMapped(i) * amplitude, 1.0);
var sampleMin:Float = Math.max(waveformData.channel(0).minSampleMapped(i) * amplitude, -1.0);
var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2;
if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize;
var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2;
if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize;
var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize);
var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize);
if (vertexBottomY - vertexTopY < minWaveformSize) vertexTopY = vertexBottomY - minWaveformSize;
var vertexTopIndex:Int = -1;
var vertexBottomIndex:Int = -1;
if (clipRect != null)
{
if (orientation == HORIZONTAL)
{
vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
bottomRightVertexIndex);
}
else
{
vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
bottomRightVertexIndex);
}
}
else
{
if (orientation == HORIZONTAL)
{
vertexTopIndex = this.build_vertex(pixelPos, vertexTopY);
vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY);
}
else
{
vertexTopIndex = this.build_vertex(vertexTopY, pixelPos);
vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos);
}
}
// Don't render if we don't have a previous different set of vertices to create a quad from.
if (prevVertexTopIndex != -1
&& prevVertexBottomIndex != -1
&& prevVertexTopIndex != vertexTopIndex
&& prevVertexBottomIndex != vertexBottomIndex)
{
switch (orientation) // the line of code that makes you gay
{
case HORIZONTAL:
this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex);
case VERTICAL:
this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex);
}
}
prevVertexTopIndex = vertexTopIndex;
prevVertexBottomIndex = vertexBottomIndex;
}
}
else
{
// Indexes are less than one pixel wide, so for each pixel we render the maximum of the samples that fall within it.
var prevVertexTopIndex:Int = -1;
var prevVertexBottomIndex:Int = -1;
var waveformLengthPixels:Int = orientation == HORIZONTAL ? Std.int(this.width) : Std.int(this.height);
for (i in 0...waveformLengthPixels)
{
var pixelPos:Int = i;
var isBeforeClipRect:Bool = (clipRect != null) && ((orientation == HORIZONTAL) ? pixelPos < clipRect.x : pixelPos < clipRect.y);
if (isBeforeClipRect) continue;
var isAfterClipRect:Bool = (clipRect != null)
&& ((orientation == HORIZONTAL) ? pixelPos > (clipRect.x + clipRect.width) : pixelPos > (clipRect.y + clipRect.height));
if (isAfterClipRect)
{
break;
};
// Wrap Std.int around the whole range calculation, not just indexesPerPixel, otherwise you get weird issues with zooming.
var rangeStart:Int = Std.int(i * indexesPerPixel + startIndex);
var rangeEnd:Int = Std.int((i + 1) * indexesPerPixel + startIndex);
var sampleMax:Float = Math.min(waveformData.channel(0).maxSampleRangeMapped(rangeStart, rangeEnd) * amplitude, 1.0);
var sampleMin:Float = Math.max(waveformData.channel(0).minSampleRangeMapped(rangeStart, rangeEnd) * amplitude, -1.0);
var sampleMaxSize:Float = sampleMax * (orientation == HORIZONTAL ? this.height : this.width) / 2;
if (sampleMaxSize < minWaveformSize) sampleMaxSize = minWaveformSize;
var sampleMinSize:Float = sampleMin * (orientation == HORIZONTAL ? this.height : this.width) / 2;
if (sampleMinSize > -minWaveformSize) sampleMinSize = -minWaveformSize;
var vertexTopY:Int = Std.int(waveformCenterPos - sampleMaxSize);
var vertexBottomY:Int = Std.int(waveformCenterPos - sampleMinSize);
var vertexTopIndex:Int = -1;
var vertexBottomIndex:Int = -1;
if (clipRect != null)
{
if (orientation == HORIZONTAL)
{
vertexTopIndex = buildClippedVertex(pixelPos, vertexTopY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
vertexBottomIndex = buildClippedVertex(pixelPos, vertexBottomY, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
bottomRightVertexIndex);
}
else
{
vertexTopIndex = buildClippedVertex(vertexTopY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex, bottomRightVertexIndex);
vertexBottomIndex = buildClippedVertex(vertexBottomY, pixelPos, topLeftVertexIndex, topRightVertexIndex, bottomLeftVertexIndex,
bottomRightVertexIndex);
}
}
else
{
if (orientation == HORIZONTAL)
{
vertexTopIndex = this.build_vertex(pixelPos, vertexTopY);
vertexBottomIndex = this.build_vertex(pixelPos, vertexBottomY);
}
else
{
vertexTopIndex = this.build_vertex(vertexTopY, pixelPos);
vertexBottomIndex = this.build_vertex(vertexBottomY, pixelPos);
}
}
if (prevVertexTopIndex != -1 && prevVertexBottomIndex != -1)
{
switch (orientation)
{
case HORIZONTAL:
this.add_quad(prevVertexTopIndex, vertexTopIndex, vertexBottomIndex, prevVertexBottomIndex);
case VERTICAL:
this.add_quad(prevVertexBottomIndex, prevVertexTopIndex, vertexTopIndex, vertexBottomIndex);
}
}
prevVertexTopIndex = vertexTopIndex;
prevVertexBottomIndex = vertexBottomIndex;
}
}
}
function buildClippedVertex(x:Int, y:Int, topLeftVertexIndex:Int, topRightVertexIndex:Int, bottomLeftVertexIndex:Int, bottomRightVertexIndex:Int):Int
{
var shouldClipXLeft = x < clipRect.x;
var shouldClipXRight = x > (clipRect.x + clipRect.width);
var shouldClipYTop = y < clipRect.y;
var shouldClipYBottom = y > (clipRect.y + clipRect.height);
// If the vertex is fully outside the clipRect, use a pre-existing vertex.
// Else, if the vertex is outside the clipRect on one axis, create a new vertex constrained on that axis.
// Else, create a whole new vertex.
if (shouldClipXLeft && shouldClipYTop)
{
return topLeftVertexIndex;
}
else if (shouldClipXRight && shouldClipYTop)
{
return topRightVertexIndex;
}
else if (shouldClipXLeft && shouldClipYBottom)
{
return bottomLeftVertexIndex;
}
else if (shouldClipXRight && shouldClipYBottom)
{
return bottomRightVertexIndex;
}
else if (shouldClipXLeft)
{
return this.build_vertex(clipRect.x, y);
}
else if (shouldClipXRight)
{
return this.build_vertex(clipRect.x + clipRect.width, y);
}
else if (shouldClipYTop)
{
return this.build_vertex(x, clipRect.y);
}
else if (shouldClipYBottom)
{
return this.build_vertex(x, clipRect.y + clipRect.height);
}
else
{
return this.build_vertex(x, y);
}
}
public static function buildFromWaveformData(data:WaveformData, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
{
return new WaveformSprite(data, orientation, color, duration);
}
public static function buildFromFunkinSound(sound:FunkinSound, ?orientation:WaveformOrientation, ?color:FlxColor, ?duration:Float)
{
// TODO: Build waveform data from FunkinSound.
var data = null;
return buildFromWaveformData(data, orientation, color, duration);
}
}
enum WaveformOrientation
{
HORIZONTAL;
VERTICAL;
}

View file

@ -46,6 +46,9 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
this.entries = new Map<String, T>();
}
/**
* TODO: Create a `loadEntriesAsync()` function.
*/
public function loadEntries():Void
{
clearEntries();
@ -54,7 +57,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
// SCRIPTED ENTRIES
//
var scriptedEntryClassNames:Array<String> = getScriptedClassNames();
log('Registering ${scriptedEntryClassNames.length} scripted entries...');
log('Parsing ${scriptedEntryClassNames.length} scripted entries...');
for (entryCls in scriptedEntryClassNames)
{
@ -78,7 +81,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool {
return !entries.exists(entryId);
});
log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
log('Parsing ${unscriptedEntryIds.length} unscripted entries...');
for (entryId in unscriptedEntryIds)
{
try

View file

@ -120,6 +120,71 @@ class DataParse
}
}
public static function backdropData(json:Json, name:String):funkin.data.dialogue.ConversationData.BackdropData
{
switch (json.value)
{
case JObject(fields):
var result:Dynamic = {};
var backdropType:String = '';
for (field in fields)
{
switch (field.name)
{
case 'type':
backdropType = Tools.getValue(field.value);
}
Reflect.setField(result, field.name, Tools.getValue(field.value));
}
switch (backdropType)
{
case 'solid':
return SOLID(result);
default:
throw 'Expected Backdrop property $name to be specify a valid "type", but it was "${backdropType}".';
}
return null;
default:
throw 'Expected property $name to be an object, but it was ${json.value}.';
}
}
public static function outroData(json:Json, name:String):Null<funkin.data.dialogue.ConversationData.OutroData>
{
switch (json.value)
{
case JObject(fields):
var result:Dynamic = {};
var outroType:String = '';
for (field in fields)
{
switch (field.name)
{
case 'type':
outroType = Tools.getValue(field.value);
}
Reflect.setField(result, field.name, Tools.getValue(field.value));
}
switch (outroType)
{
case 'none':
return NONE(result);
case 'fade':
return FADE(result);
default:
throw 'Expected Outro property $name to be specify a valid "type", but it was "${outroType}".';
}
return null;
default:
throw 'Expected property $name to be an object, but it was ${json.value}.';
}
}
/**
* Parser which outputs a `Either<Float, LegacyScrollSpeeds>`.
* Used by the FNF legacy JSON importer.

View file

@ -0,0 +1,168 @@
package funkin.data.dialogue;
import funkin.data.animation.AnimationData;
/**
* A type definition for the data for a specific conversation.
* It includes things like what dialogue boxes to use, what text to display, and what animations to play.
* @see https://lib.haxe.org/p/json2object/
*/
typedef ConversationData =
{
/**
* Semantic version for conversation data.
*/
public var version:String;
/**
* Data on the backdrop for the conversation.
*/
@:jcustomparse(funkin.data.DataParse.backdropData)
public var backdrop:BackdropData;
/**
* Data on the outro for the conversation.
*/
@:jcustomparse(funkin.data.DataParse.outroData)
@:optional
public var outro:Null<OutroData>;
/**
* Data on the music for the conversation.
*/
@:optional
public var music:Null<MusicData>;
/**
* Data for each line of dialogue in the conversation.
*/
public var dialogue:Array<DialogueEntryData>;
}
/**
* Data on the backdrop for the conversation, behind the dialogue box.
* A custom parser distinguishes between backdrop types based on the `type` field.
*/
enum BackdropData
{
SOLID(data:BackdropData_Solid); // 'solid'
}
/**
* Data for a Solid color backdrop.
*/
typedef BackdropData_Solid =
{
/**
* Used to distinguish between backdrop types. Should always be `solid` for this type.
*/
var type:String;
/**
* The color of the backdrop.
*/
var color:String;
/**
* Fade-in time for the backdrop.
* @default No fade-in
*/
@:optional
@:default(0.0)
var fadeTime:Float;
};
enum OutroData
{
NONE(data:OutroData_None); // 'none'
FADE(data:OutroData_Fade); // 'fade'
}
typedef OutroData_None =
{
/**
* Used to distinguish between outro types. Should always be `none` for this type.
*/
var type:String;
}
typedef OutroData_Fade =
{
/**
* Used to distinguish between outro types. Should always be `fade` for this type.
*/
var type:String;
/**
* The time to fade out the conversation.
* @default 1 second
*/
@:optional
@:default(1.0)
var fadeTime:Float;
}
typedef MusicData =
{
/**
* The asset to play for the music.
*/
var asset:String;
/**
* The time to fade in the music.
*/
@:optional
@:default(0.0)
var fadeTime:Float;
@:optional
@:default(false)
var looped:Bool;
};
/**
* Data on a single line of dialogue in a conversation.
*/
typedef DialogueEntryData =
{
/**
* Which speaker is speaking.
* @see `SpeakerData.hx`
*/
public var speaker:String;
/**
* The animation the speaker should play for this line of dialogue.
*/
public var speakerAnimation:String;
/**
* Which dialogue box to use for this line of dialogue.
* @see `DialogueBoxData.hx`
*/
public var box:String;
/**
* Which animation to play for the dialogue box.
*/
public var boxAnimation:String;
/**
* The text that will display for this line of dialogue.
* Text will automatically wrap.
* When the user advances the dialogue, the next entry in the array will concatenate on.
* Advancing when the last entry is displayed will move to the next `DialogueEntryData`,
* or end the conversation if there are no more.
*/
public var text:Array<String>;
/**
* The relative speed at which text gets "typed out".
* Setting `speed` to `1.5` would make it look like the character is speaking quickly,
* and setting `speed` to `0.5` would make it look like the character is emphasizing each word.
*/
@:optional
@:default(1.0)
public var speed:Float;
};

View file

@ -0,0 +1,81 @@
package funkin.data.dialogue;
import funkin.play.cutscene.dialogue.Conversation;
import funkin.data.dialogue.ConversationData;
import funkin.play.cutscene.dialogue.ScriptedConversation;
class ConversationRegistry extends BaseRegistry<Conversation, ConversationData>
{
/**
* The current version string for the dialogue box data format.
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateConversationData()` function.
*/
public static final CONVERSATION_DATA_VERSION:thx.semver.Version = "1.0.0";
public static final CONVERSATION_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
public static final instance:ConversationRegistry = new ConversationRegistry();
public function new()
{
super('CONVERSATION', 'dialogue/conversations', CONVERSATION_DATA_VERSION_RULE);
}
/**
* Read, parse, and validate the JSON data and produce the corresponding data object.
*/
public function parseEntryData(id:String):Null<ConversationData>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<ConversationData>();
parser.ignoreUnknownVariables = false;
switch (loadEntryFile(id))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
}
if (parser.errors.length > 0)
{
printErrors(parser.errors, id);
return null;
}
return parser.value;
}
/**
* Parse and validate the JSON data and produce the corresponding data object.
*
* NOTE: Must be implemented on the implementation class.
* @param contents The JSON as a string.
* @param fileName An optional file name for error reporting.
*/
public function parseEntryDataRaw(contents:String, ?fileName:String):Null<ConversationData>
{
var parser = new json2object.JsonParser<ConversationData>();
parser.ignoreUnknownVariables = false;
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value;
}
function createScriptedEntry(clsName:String):Conversation
{
return ScriptedConversation.init(clsName, "unknown");
}
function getScriptedClassNames():Array<String>
{
return ScriptedConversation.listScriptClasses();
}
}

View file

@ -0,0 +1,128 @@
package funkin.data.dialogue;
import funkin.data.animation.AnimationData;
/**
* A type definition for the data for a conversation text box.
* It includes things like the sprite to use, and the font and color for the text.
* The actual text is included in the ConversationData.
* @see https://lib.haxe.org/p/json2object/
*/
typedef DialogueBoxData =
{
/**
* Semantic version for dialogue box data.
*/
public var version:String;
/**
* A human readable name for the dialogue box type.
*/
public var name:String;
/**
* The asset path for the sprite to use for the dialogue box.
* Takes a static sprite or a sprite sheet.
*/
public var assetPath:String;
/**
* Whether to horizontally flip the dialogue box sprite.
*/
@:optional
@:default(false)
public var flipX:Bool;
/**
* Whether to vertically flip the dialogue box sprite.
*/
@:optional
@:default(false)
public var flipY:Bool;
/**
* Whether to disable anti-aliasing for the dialogue box sprite.
*/
@:optional
@:default(false)
public var isPixel:Bool;
/**
* The relative horizontal and vertical offsets for the dialogue box sprite.
*/
@:optional
@:default([0, 0])
public var offsets:Array<Float>;
/**
* Info about how to display text in the dialogue box.
*/
public var text:DialogueBoxTextData;
/**
* Multiply the size of the dialogue box sprite.
*/
@:optional
@:default(1)
public var scale:Float;
/**
* If using a spritesheet for the dialogue box, the animations to use.
*/
@:optional
@:default([])
public var animations:Array<AnimationData>;
}
typedef DialogueBoxTextData =
{
/**
* The position of the text in teh box.
*/
@:optional
@:default([0, 0])
var offsets:Array<Float>;
/**
* The width of the
*/
@:optional
@:default(300)
var width:Int;
/**
* The font size to use for the text.
*/
@:optional
@:default(32)
var size:Int;
/**
* The color to use for the text.
* Use a string that can be translated to a color, like `#FF0000` for red.
*/
@:optional
@:default("#000000")
var color:String;
/**
* The font to use for the text.
* @since v1.1.0
* @default `Arial`, make sure to switch this!
*/
@:optional
@:default("Arial")
var fontFamily:String;
/**
* The color to use for the shadow of the text. Use transparent to disable.
*/
var shadowColor:String;
/**
* The width of the shadow of the text.
*/
@:optional
@:default(0)
var shadowWidth:Int;
};

View file

@ -0,0 +1,81 @@
package funkin.data.dialogue;
import funkin.play.cutscene.dialogue.DialogueBox;
import funkin.data.dialogue.DialogueBoxData;
import funkin.play.cutscene.dialogue.ScriptedDialogueBox;
class DialogueBoxRegistry extends BaseRegistry<DialogueBox, DialogueBoxData>
{
/**
* The current version string for the dialogue box data format.
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateDialogueBoxData()` function.
*/
public static final DIALOGUEBOX_DATA_VERSION:thx.semver.Version = "1.1.0";
public static final DIALOGUEBOX_DATA_VERSION_RULE:thx.semver.VersionRule = "1.1.x";
public static final instance:DialogueBoxRegistry = new DialogueBoxRegistry();
public function new()
{
super('DIALOGUEBOX', 'dialogue/boxes', DIALOGUEBOX_DATA_VERSION_RULE);
}
/**
* Read, parse, and validate the JSON data and produce the corresponding data object.
*/
public function parseEntryData(id:String):Null<DialogueBoxData>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<DialogueBoxData>();
parser.ignoreUnknownVariables = false;
switch (loadEntryFile(id))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
}
if (parser.errors.length > 0)
{
printErrors(parser.errors, id);
return null;
}
return parser.value;
}
/**
* Parse and validate the JSON data and produce the corresponding data object.
*
* NOTE: Must be implemented on the implementation class.
* @param contents The JSON as a string.
* @param fileName An optional file name for error reporting.
*/
public function parseEntryDataRaw(contents:String, ?fileName:String):Null<DialogueBoxData>
{
var parser = new json2object.JsonParser<DialogueBoxData>();
parser.ignoreUnknownVariables = false;
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value;
}
function createScriptedEntry(clsName:String):DialogueBox
{
return ScriptedDialogueBox.init(clsName, "unknown");
}
function getScriptedClassNames():Array<String>
{
return ScriptedDialogueBox.listScriptClasses();
}
}

View file

@ -0,0 +1,68 @@
package funkin.data.dialogue;
import funkin.data.animation.AnimationData;
/**
* A type definition for a specific speaker in a conversation.
* It includes things like what sprite to use and its available animations.
* @see https://lib.haxe.org/p/json2object/
*/
typedef SpeakerData =
{
/**
* Semantic version of the speaker data.
*/
public var version:String;
/**
* A human-readable name for the speaker.
*/
public var name:String;
/**
* The path to the asset to use for the speaker's sprite.
*/
public var assetPath:String;
/**
* Whether the sprite should be flipped horizontally.
*/
@:optional
@:default(false)
public var flipX:Bool;
/**
* Whether the sprite should be flipped vertically.
*/
@:optional
@:default(false)
public var flipY:Bool;
/**
* Whether to disable anti-aliasing for the dialogue box sprite.
*/
@:optional
@:default(false)
public var isPixel:Bool;
/**
* The offsets to apply to the sprite's position.
*/
@:optional
@:default([0, 0])
public var offsets:Array<Float>;
/**
* The scale to apply to the sprite.
*/
@:optional
@:default(1.0)
public var scale:Float;
/**
* The available animations for the speaker.
*/
@:optional
@:default([])
public var animations:Array<AnimationData>;
}

View file

@ -0,0 +1,81 @@
package funkin.data.dialogue;
import funkin.play.cutscene.dialogue.Speaker;
import funkin.data.dialogue.SpeakerData;
import funkin.play.cutscene.dialogue.ScriptedSpeaker;
class SpeakerRegistry extends BaseRegistry<Speaker, SpeakerData>
{
/**
* The current version string for the speaker data format.
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateSpeakerData()` function.
*/
public static final SPEAKER_DATA_VERSION:thx.semver.Version = "1.0.0";
public static final SPEAKER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
public static final instance:SpeakerRegistry = new SpeakerRegistry();
public function new()
{
super('SPEAKER', 'dialogue/speakers', SPEAKER_DATA_VERSION_RULE);
}
/**
* Read, parse, and validate the JSON data and produce the corresponding data object.
*/
public function parseEntryData(id:String):Null<SpeakerData>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<SpeakerData>();
parser.ignoreUnknownVariables = false;
switch (loadEntryFile(id))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
}
if (parser.errors.length > 0)
{
printErrors(parser.errors, id);
return null;
}
return parser.value;
}
/**
* Parse and validate the JSON data and produce the corresponding data object.
*
* NOTE: Must be implemented on the implementation class.
* @param contents The JSON as a string.
* @param fileName An optional file name for error reporting.
*/
public function parseEntryDataRaw(contents:String, ?fileName:String):Null<SpeakerData>
{
var parser = new json2object.JsonParser<SpeakerData>();
parser.ignoreUnknownVariables = false;
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value;
}
function createScriptedEntry(clsName:String):Speaker
{
return ScriptedSpeaker.init(clsName, "unknown");
}
function getScriptedClassNames():Array<String>
{
return ScriptedSpeaker.listScriptClasses();
}
}

View file

@ -6,9 +6,14 @@ import funkin.data.song.SongData.SongEventData;
import funkin.util.macro.ClassMacro;
import funkin.play.event.ScriptedSongEvent;
@:forward(name, tittlte, type, keys, min, max, step, defaultValue, iterator)
@:forward(name, title, type, keys, min, max, step, units, defaultValue, iterator)
abstract SongEventSchema(SongEventSchemaRaw)
{
/**
* These units look better when placed immediately next to the value, rather than after a space.
*/
static final NO_SPACE_UNITS:Array<String> = ['x', '°', '%'];
public function new(?fields:Array<SongEventSchemaField>)
{
this = fields;
@ -42,7 +47,7 @@ abstract SongEventSchema(SongEventSchemaRaw)
return this[k] = v;
}
public function stringifyFieldValue(name:String, value:Dynamic):String
public function stringifyFieldValue(name:String, value:Dynamic, addUnits:Bool = true):String
{
var field:SongEventSchemaField = getByName(name);
if (field == null) return 'Unknown';
@ -52,21 +57,36 @@ abstract SongEventSchema(SongEventSchemaRaw)
case SongEventFieldType.STRING:
return Std.string(value);
case SongEventFieldType.INTEGER:
return Std.string(value);
var returnValue:String = Std.string(value);
if (addUnits) return addUnitsToString(returnValue, field);
return returnValue;
case SongEventFieldType.FLOAT:
return Std.string(value);
var returnValue:String = Std.string(value);
if (addUnits) return addUnitsToString(returnValue, field);
return returnValue;
case SongEventFieldType.BOOL:
return Std.string(value);
case SongEventFieldType.ENUM:
var valueString:String = Std.string(value);
for (key in field.keys.keys())
{
if (field.keys.get(key) == value) return key;
// Comparing these values as strings because comparing Dynamic variables is jank.
if (Std.string(field.keys.get(key)) == valueString) return key;
}
return Std.string(value);
return valueString;
default:
return 'Unknown';
}
}
function addUnitsToString(value:String, field:SongEventSchemaField)
{
if (field.units == null || field.units == '') return value;
var unit:String = field.units;
return value + (NO_SPACE_UNITS.contains(unit) ? '' : ' ') + '${unit}';
}
}
typedef SongEventSchemaRaw = Array<SongEventSchemaField>;
@ -115,6 +135,12 @@ typedef SongEventSchemaField =
*/
?step:Float,
/**
* Used for INTEGER and FLOAT values.
* The units that the value is expressed in (pixels, percent, etc).
*/
?units:String,
/**
* An optional default value for the field.
*/

View file

@ -418,10 +418,10 @@ class SongPlayData implements ICloneable<SongPlayData>
/**
* The difficulty ratings for this song as displayed in Freeplay.
* Key is a difficulty ID or `default`.
* Key is a difficulty ID.
*/
@:optional
@:default(['default' => 1])
@:default(['normal' => 0])
public var ratings:Map<String, Int>;
/**
@ -431,6 +431,24 @@ class SongPlayData implements ICloneable<SongPlayData>
@:optional
public var album:Null<String>;
/**
* The start time for the audio preview in Freeplay.
* Defaults to 0 seconds in.
* @since `2.2.2`
*/
@:optional
@:default(0)
public var previewStart:Int;
/**
* The end time for the audio preview in Freeplay.
* Defaults to 15 seconds in.
* @since `2.2.2`
*/
@:optional
@:default(15000)
public var previewEnd:Int;
public function new()
{
ratings = new Map<String, Int>();
@ -438,6 +456,7 @@ class SongPlayData implements ICloneable<SongPlayData>
public function clone():SongPlayData
{
// TODO: This sucks! If you forget to update this you get weird behavior.
var result:SongPlayData = new SongPlayData();
result.songVariations = this.songVariations.clone();
result.difficulties = this.difficulties.clone();
@ -446,6 +465,8 @@ class SongPlayData implements ICloneable<SongPlayData>
result.noteStyle = this.noteStyle;
result.ratings = this.ratings.clone();
result.album = this.album;
result.previewStart = this.previewStart;
result.previewEnd = this.previewEnd;
return result;
}
@ -777,7 +798,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
var title = eventSchema.getByName(key)?.title ?? 'UnknownField';
if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value));
// if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value));
var valueStr = eventSchema.stringifyFieldValue(key, value) ?? 'UnknownValue';
result += '\n- ${title}: ${valueStr}';
@ -915,6 +936,28 @@ class SongNoteDataRaw implements ICloneable<SongNoteDataRaw>
return SongNoteData.buildDirectionName(this.data, strumlineSize);
}
/**
* The strumline index of the note, if applicable.
* Strips the direction from the data.
*
* 0 = player, 1 = opponent, etc.
*/
public function getStrumlineIndex(strumlineSize:Int = 4):Int
{
return Math.floor(this.data / strumlineSize);
}
/**
* Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side).
* TODO: The name of this function is a little misleading; what about mines?
* @param strumlineSize Defaults to 4.
* @return True if it's Boyfriend's note.
*/
public function getMustHitNote(strumlineSize:Int = 4):Bool
{
return getStrumlineIndex(strumlineSize) == 0;
}
@:jignored
var _stepTime:Null<Float> = null;
@ -1003,28 +1046,6 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
}
}
/**
* The strumline index of the note, if applicable.
* Strips the direction from the data.
*
* 0 = player, 1 = opponent, etc.
*/
public inline function getStrumlineIndex(strumlineSize:Int = 4):Int
{
return Math.floor(this.data / strumlineSize);
}
/**
* Returns true if the note is one that Boyfriend should try to hit (i.e. it's on his side).
* TODO: The name of this function is a little misleading; what about mines?
* @param strumlineSize Defaults to 4.
* @return True if it's Boyfriend's note.
*/
public inline function getMustHitNote(strumlineSize:Int = 4):Bool
{
return getStrumlineIndex(strumlineSize) == 0;
}
@:jignored
public var isHoldNote(get, never):Bool;

View file

@ -153,8 +153,8 @@ class SongDataUtils
public static function buildNoteClipboard(notes:Array<SongNoteData>, ?timeOffset:Int = null):Array<SongNoteData>
{
if (notes.length == 0) return notes;
if (timeOffset == null) timeOffset = -Std.int(notes[0].time);
return offsetSongNoteData(sortNotes(notes), timeOffset);
if (timeOffset == null) timeOffset = Std.int(notes[0].time);
return offsetSongNoteData(sortNotes(notes), -timeOffset);
}
/**
@ -165,8 +165,8 @@ class SongDataUtils
public static function buildEventClipboard(events:Array<SongEventData>, ?timeOffset:Int = null):Array<SongEventData>
{
if (events.length == 0) return events;
if (timeOffset == null) timeOffset = -Std.int(events[0].time);
return offsetSongEventData(sortEvents(events), timeOffset);
if (timeOffset == null) timeOffset = Std.int(events[0].time);
return offsetSongEventData(sortEvents(events), -timeOffset);
}
/**

View file

@ -20,7 +20,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateStageData()` function.
*/
public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.1";
public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.2";
public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
@ -58,7 +58,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
// SCRIPTED ENTRIES
//
var scriptedEntryClassNames:Array<String> = getScriptedClassNames();
log('Registering ${scriptedEntryClassNames.length} scripted entries...');
log('Parsing ${scriptedEntryClassNames.length} scripted entries...');
for (entryCls in scriptedEntryClassNames)
{
@ -84,7 +84,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
var unscriptedEntryIds:Array<String> = entryIdList.filter(function(entryId:String):Bool {
return !entries.exists(entryId);
});
log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
log('Parsing ${unscriptedEntryIds.length} unscripted entries...');
for (entryId in unscriptedEntryIds)
{
try

View file

@ -0,0 +1,257 @@
package funkin.graphics;
import flash.geom.ColorTransform;
import flixel.FlxCamera;
import flixel.graphics.FlxGraphic;
import flixel.graphics.frames.FlxFrame;
import flixel.math.FlxMatrix;
import flixel.math.FlxRect;
import flixel.system.FlxAssets.FlxShader;
import funkin.graphics.shaders.RuntimeCustomBlendShader;
import funkin.graphics.framebuffer.BitmapDataUtil;
import funkin.graphics.framebuffer.FixedBitmapData;
import openfl.Lib;
import openfl.display.BitmapData;
import openfl.display.BlendMode;
import openfl.display3D.textures.TextureBase;
import openfl.filters.BitmapFilter;
import openfl.filters.ShaderFilter;
/**
* A FlxCamera with additional powerful features:
* - Grab the camera screen as a `BitmapData` and use it as a texture
* - Support `sprite.blend = DARKEN/HARDLIGHT/LIGHTEN/OVERLAY` to apply visual effects using certain sprites
* - NOTE: Several other blend modes work without FunkinCamera. Some still do not work.
* - NOTE: Framerate-independent camera tweening is fixed in Flixel 6.x. Rest in peace, SwagCamera.
*/
@:access(openfl.display.DisplayObject)
@:access(openfl.display.BitmapData)
@:access(openfl.display3D.Context3D)
@:access(openfl.display3D.textures.TextureBase)
@:access(flixel.graphics.FlxGraphic)
@:access(flixel.graphics.frames.FlxFrame)
class FunkinCamera extends FlxCamera
{
final grabbed:Array<BitmapData> = [];
final texturePool:Array<TextureBase> = [];
final bgTexture:TextureBase;
final bgBitmap:BitmapData;
final bgFrame:FlxFrame;
final customBlendShader:RuntimeCustomBlendShader;
final customBlendFilter:ShaderFilter;
var filtersApplied:Bool = false;
var bgItemCount:Int = 0;
public var shouldDraw:Bool = true;
public function new(x:Int = 0, y:Int = 0, width:Int = 0, height:Int = 0, zoom:Float = 0)
{
super(x, y, width, height, zoom);
bgTexture = pickTexture(width, height);
bgBitmap = FixedBitmapData.fromTexture(bgTexture);
bgFrame = new FlxFrame(new FlxGraphic('', null));
bgFrame.parent.bitmap = bgBitmap;
bgFrame.frame = new FlxRect();
customBlendShader = new RuntimeCustomBlendShader();
customBlendFilter = new ShaderFilter(customBlendShader);
}
/**
* Grabs the camera screen and returns it as a `BitmapData`. The returned bitmap
* will not be referred by the camera so, changing it will not affect the scene.
* The returned bitmap **will be reused in the next frame**, so the content is available
* only in the current frame.
* @param applyFilters if this is `true`, the camera's filters will be applied to the grabbed bitmap,
* and the camera's filters will be disabled until the beginning of the next frame
* @param isolate if this is `true`, sprites to be rendered will only be rendered to the grabbed bitmap,
* and the grabbed bitmap will not include any previously rendered sprites
* @return the grabbed bitmap data
*/
public function grabScreen(applyFilters:Bool, isolate:Bool = false):BitmapData
{
final texture = pickTexture(width, height);
final bitmap = FixedBitmapData.fromTexture(texture);
squashTo(bitmap, applyFilters, isolate);
grabbed.push(bitmap);
return bitmap;
}
/**
* Applies the filter immediately to the camera. This will be done independently from
* the camera's filters. This method can only be called after the first `grabScreen`
* in the frame.
* @param filter the filter
*/
public function applyFilter(filter:BitmapFilter):Void
{
if (grabbed.length == 0)
{
FlxG.log.error('grab screen before you can apply a filter!');
return;
}
BitmapDataUtil.applyFilter(bgBitmap, filter);
}
function squashTo(bitmap:BitmapData, applyFilters:Bool, isolate:Bool, clearScreen:Bool = false):Void
{
if (applyFilters && isolate)
{
FlxG.log.error('cannot apply filters while isolating!');
}
if (filtersApplied && applyFilters)
{
FlxG.log.warn('filters already applied!');
}
static final matrix = new FlxMatrix();
// resize the background bitmap if needed
if (bgTexture.__width != width || bgTexture.__height != height)
{
BitmapDataUtil.resizeTexture(bgTexture, width, height);
bgBitmap.__resize(width, height);
bgFrame.parent.bitmap = bgBitmap;
}
// grab the bitmap
renderSkipping(isolate ? bgItemCount : 0);
bitmap.fillRect(bitmap.rect, 0);
matrix.setTo(1, 0, 0, 1, flashSprite.x, flashSprite.y);
if (applyFilters)
{
bitmap.draw(flashSprite, matrix);
flashSprite.filters = null;
filtersApplied = true;
}
else
{
final tmp = flashSprite.filters;
flashSprite.filters = null;
bitmap.draw(flashSprite, matrix);
flashSprite.filters = tmp;
}
if (!isolate)
{
// also copy to the background bitmap
bgBitmap.fillRect(bgBitmap.rect, 0);
bgBitmap.draw(bitmap);
}
if (clearScreen)
{
// clear graphics data
super.clearDrawStack();
canvas.graphics.clear();
}
// render the background bitmap
bgFrame.frame.set(0, 0, width, height);
matrix.setTo(viewWidth / width, 0, 0, viewHeight / height, viewMarginLeft, viewMarginTop);
drawPixels(bgFrame, matrix);
// count background draw items for future isolation
bgItemCount = 0;
{
var item = _headOfDrawStack;
while (item != null)
{
item = item.next;
bgItemCount++;
}
}
}
function renderSkipping(count:Int):Void
{
var item = _headOfDrawStack;
while (item != null)
{
if (--count < 0) item.render(this);
item = item.next;
}
}
override function drawPixels(?frame:FlxFrame, ?pixels:BitmapData, matrix:FlxMatrix, ?transform:ColorTransform, ?blend:BlendMode, ?smoothing:Bool = false,
?shader:FlxShader):Void
{
if (!shouldDraw) return;
if ( switch blend
{
case DARKEN | HARDLIGHT | LIGHTEN | OVERLAY: true;
case _: false;
})
{
// squash the screen
grabScreen(false);
// render without blend
super.drawPixels(frame, pixels, matrix, transform, null, smoothing, shader);
// get the isolated bitmap
final isolated = grabScreen(false, true);
// apply fullscreen blend
customBlendShader.blend = blend;
customBlendShader.source = isolated;
customBlendShader.updateViewInfo(FlxG.width, FlxG.height, this);
applyFilter(customBlendFilter);
}
else
{
super.drawPixels(frame, pixels, matrix, transform, blend, smoothing, shader);
}
}
override function destroy():Void
{
super.destroy();
disposeTextures();
}
override function clearDrawStack():Void
{
super.clearDrawStack();
// also clear grabbed bitmaps
for (bitmap in grabbed)
{
texturePool.push(bitmap.__texture);
bitmap.dispose(); // this doesn't release the texture
}
grabbed.clear();
// clear filters applied flag
filtersApplied = false;
bgItemCount = 0;
}
function pickTexture(width:Int, height:Int):TextureBase
{
// zero-sized textures will be problematic
width = width < 1 ? 1 : width;
height = height < 1 ? 1 : height;
if (texturePool.length > 0)
{
final res = texturePool.pop();
BitmapDataUtil.resizeTexture(res, width, height);
return res;
}
return Lib.current.stage.context3D.createTexture(width, height, BGRA, true);
}
function disposeTextures():Void
{
trace('disposing textures');
for (bitmap in grabbed)
{
bitmap.dispose();
}
grabbed.clear();
for (texture in texturePool)
{
texture.dispose();
}
texturePool.resize(0);
bgTexture.dispose();
bgBitmap.dispose();
}
}

View file

@ -0,0 +1,123 @@
package funkin.graphics.framebuffer;
import flixel.FlxG;
import openfl.Lib;
import openfl.display.Bitmap;
import openfl.display.BitmapData;
import openfl.display.Sprite;
import openfl.display3D.Context3DTextureFormat;
import openfl.display3D.textures.TextureBase;
import openfl.filters.BitmapFilter;
/**
* Provides cool stuff for `BitmapData`s that have a hardware texture internally.
*/
@:access(openfl.display.BitmapData)
@:access(openfl.display3D.textures.TextureBase)
@:access(openfl.display3D.Context3D)
class BitmapDataUtil
{
static function getCache():{sprite:Sprite, bitmap:Bitmap}
{
static var cache:{sprite:Sprite, bitmap:Bitmap} = null;
if (cache == null)
{
final sprite = new Sprite();
final bitmap = new Bitmap();
sprite.addChild(bitmap);
cache =
{
sprite: sprite,
bitmap: bitmap
}
}
return cache;
}
/**
* Applies a bitmap filter to a bitmap immediately. The bitmap filter may refer
* the bitmap itself as a shader input.
* @param bitmap the bitmap data
* @param filter the bitmap filter
*/
public static function applyFilter(bitmap:BitmapData, filter:BitmapFilter):Void
{
hardwareCheck(bitmap);
final cache = getCache();
cache.bitmap.bitmapData = bitmap;
cache.sprite.filters = [filter];
bitmap.draw(cache.sprite);
}
/**
* Creates a bitmap with a hardware texture.
* @param width the width
* @param height the height
* @param format the format if the internal texture
* @return the bitmap
*/
public static function create(width:Int, height:Int, format:Context3DTextureFormat = BGRA):FixedBitmapData
{
final texture = Lib.current.stage.context3D.createTexture(width, height, format, true);
return FixedBitmapData.fromTexture(texture);
}
/**
* Resizes the bitmap.
* @param bitmap the bitmap data
* @param width the width
* @param height the height
*/
public static function resize(bitmap:BitmapData, width:Int, height:Int):Void
{
hardwareCheck(bitmap);
if (bitmap.width == width && bitmap.height == height) return;
bitmap.width = width;
bitmap.height = height;
resizeTexture(bitmap.__texture, width, height);
}
/**
* Resizes the texture.
* @param texture the texture
* @param width the width
* @param height the height
*/
public static function resizeTexture(texture:TextureBase, width:Int, height:Int):Void
{
if (texture.__width == width && texture.__height == height) return;
texture.__width = width;
texture.__height = height;
final context = texture.__context;
final gl = context.gl;
context.__bindGLTexture2D(texture.__textureID);
gl.texImage2D(gl.TEXTURE_2D, 0, texture.__internalFormat, width, height, 0, texture.__format, gl.UNSIGNED_BYTE, null);
context.__bindGLTexture2D(null);
}
/**
* Copies the content of `src` to `dst`. The destination bitmap `dst` will be resized
* so that it has the same size as `src`.
* @param dst the destination bitmap
* @param src the source bitmap
*/
public static function copy(dst:BitmapData, src:BitmapData):Void
{
hardwareCheck(dst);
hardwareCheck(src);
final cache = getCache();
cache.bitmap.bitmapData = src;
cache.sprite.filters = null;
resize(dst, src.width, src.height);
dst.fillRect(dst.rect, 0);
dst.draw(cache.sprite);
}
static function hardwareCheck(bitmap:BitmapData):Void
{
if (bitmap.readable)
{
FlxG.log.error('do not use `BitmapDataUtil` for non-GPU bitmaps!');
}
}
}

View file

@ -0,0 +1,42 @@
package funkin.graphics.framebuffer;
import openfl.display.BitmapData;
import openfl.display.DisplayObject;
import openfl.display.DisplayObjectContainer;
import openfl.display.IBitmapDrawable;
import openfl.display.OpenGLRenderer;
import openfl.display3D.textures.TextureBase;
/**
* `BitmapData` is kinda broken so I fixed it.
*/
@:access(openfl.display3D.textures.TextureBase)
@:access(openfl.display.OpenGLRenderer)
class FixedBitmapData extends BitmapData
{
override function __drawGL(source:IBitmapDrawable, renderer:OpenGLRenderer):Void
{
if (Std.isOfType(source, DisplayObject))
{
final object:DisplayObjectContainer = cast source;
renderer.__stage = object.stage;
}
super.__drawGL(source, renderer);
}
/**
* Never use `BitmapData.fromTexture`, always use this.
* @param texture the texture
* @return the bitmap data
*/
public static function fromTexture(texture:TextureBase):FixedBitmapData
{
if (texture == null) return null;
final bitmapData = new FixedBitmapData(texture.__width, texture.__height, true, 0);
bitmapData.readable = false;
bitmapData.__texture = texture;
bitmapData.__textureContext = texture.__textureContext;
bitmapData.image = null;
return bitmapData;
}
}

View file

@ -0,0 +1,132 @@
package funkin.graphics.framebuffer;
import flixel.FlxSprite;
import flixel.FlxCamera;
import flixel.util.FlxColor;
import openfl.Lib;
import openfl.display.BitmapData;
import openfl.display3D.textures.TextureBase;
import openfl.geom.Matrix;
import openfl.geom.Rectangle;
/**
* A single frame buffer. Used by `FrameBufferManager`.
*/
class FrameBuffer
{
/**
* The bitmap data of the frame buffer.
*/
public var bitmap(default, null):BitmapData = null;
var texture:TextureBase;
final camera:FlxCamera;
final spriteCopies:Array<SpriteCopy> = [];
public function new()
{
camera = new FlxCamera();
camera.antialiasing = false;
camera.bgColor = FlxColor.TRANSPARENT;
@:privateAccess camera.flashSprite.stage = Lib.current.stage;
}
/**
* Creates a frame buffer with the given size.
* @param width the width
* @param height the height
* @param bgColor the background color
*/
public function create(width:Int, height:Int, bgColor:FlxColor):Void
{
dispose();
texture = Lib.current.stage.context3D.createTexture(width, height, BGRA, true);
bitmap = FixedBitmapData.fromTexture(texture);
camera.bgColor = bgColor;
}
/**
* Makes the internal camera follows the target camera.
* @param target the target camera
*/
public function follow(target:FlxCamera):Void
{
camera.x = target.x;
camera.y = target.y;
camera.width = target.width;
camera.height = target.height;
camera.scroll.x = target.scroll.x;
camera.scroll.y = target.scroll.y;
camera.setScale(target.scaleX, target.scaleY);
}
/**
* Locks the frame buffer and clears the buffer.
*/
@:access(flixel.FlxCamera)
public function lock():Void
{
camera.clearDrawStack();
camera.canvas.graphics.clear();
camera.fill(camera.bgColor.to24Bit(), camera.useBgAlphaBlending, camera.bgColor.alphaFloat);
#if FLX_DEBUG
camera.debugLayer.graphics.clear();
#end
}
/**
* Renders all sprite copies.
*/
@:access(flixel.FlxCamera)
public function render():Void
{
for (spriteCopy in spriteCopies)
{
spriteCopy.render(camera);
}
camera.render();
}
/**
* Unlocks the frame buffer and makes the bitmap ready to use.
*/
public function unlock():Void
{
bitmap.fillRect(new Rectangle(0, 0, bitmap.width, bitmap.height), 0);
bitmap.draw(camera.flashSprite, new Matrix(1, 0, 0, 1, camera.flashSprite.x, camera.flashSprite.y));
}
/**
* Diposes stuff. Call `create` again if you want to reuse the instance.
*/
public function dispose():Void
{
if (texture != null)
{
texture.dispose();
texture = null;
bitmap.dispose();
bitmap = null;
}
spriteCopies.resize(0);
}
/**
* Adds a sprite copy to the frame buffer.
* @param spriteCopy the sprite copy
*/
public function addSpriteCopy(spriteCopy:SpriteCopy):Void
{
spriteCopies.push(spriteCopy);
}
/**
* Adds the sprite to the frame buffer. The sprite will only be seen from
* the frame buffer.
* @param sprite the sprite
*/
public function moveSprite(sprite:FlxSprite):Void
{
sprite.cameras = [camera];
}
}

View file

@ -0,0 +1,127 @@
package funkin.graphics.framebuffer;
import flixel.FlxCamera;
import flixel.FlxG;
import flixel.FlxSprite;
import flixel.util.FlxColor;
import openfl.display.BitmapData;
/**
* Manages frame buffers and gives access to each frame buffer.
*/
class FrameBufferManager
{
final camera:FlxCamera;
final frameBufferMap:Map<String, FrameBuffer> = [];
/**
* Creates a frame buffer manager that targets `camera`.
* @param camera the target camera.
*/
public function new(camera:FlxCamera)
{
this.camera = camera;
}
/**
* Creates a new frame buffer with a name.
* @param name the name
* @param bgColor the background color
* @return the bitmap data of the frame buffer. the bitmap data instance
* will not be changed through frame buffer updates.
*/
public function createFrameBuffer(name:String, bgColor:FlxColor):BitmapData
{
if (frameBufferMap.exists(name))
{
FlxG.log.warn('frame buffer "$name" already exists');
frameBufferMap[name].dispose();
frameBufferMap.remove(name);
}
final fb = new FrameBuffer();
fb.create(camera.width, camera.height, bgColor);
frameBufferMap[name] = fb;
return fb.bitmap;
}
/**
* Adds a copy of the sprite to the frame buffer.
* @param name the name of the frame buffer
* @param sprite the sprite
* @param color if this is not `null`, the sprite will be filled with the color.
* if this is `null`, the sprite will keep its original color.
*/
public function copySpriteTo(name:String, sprite:FlxSprite, color:Null<FlxColor> = null):Void
{
if (!frameBufferMap.exists(name))
{
FlxG.log.warn('frame buffer "$name" does not exist');
return;
}
frameBufferMap[name].addSpriteCopy(new SpriteCopy(sprite, color));
}
/**
* Adds the sprite to the frame buffer. The sprite will only be seen from the frame buffer.
* @param name the name of the frame buffer
* @param sprite the sprite
*/
public function moveSpriteTo(name:String, sprite:FlxSprite):Void
{
if (!frameBufferMap.exists(name))
{
FlxG.log.warn('frame buffer "$name" does not exist');
return;
}
frameBufferMap[name].moveSprite(sprite);
}
/**
* Call this before drawing anything.
*/
public function lock():Void
{
for (_ => fb in frameBufferMap)
{
fb.follow(camera);
fb.lock();
}
}
/**
* Unlocks the frame buffers. This updates the bitmap data of each frame buffer.
*/
public function unlock():Void
{
for (_ => fb in frameBufferMap)
{
fb.render();
}
for (_ => fb in frameBufferMap)
{
fb.unlock();
}
}
/**
* Returns the bitmap data of the frame buffer
* @param name the name of the frame buffer
* @return the bitmap data
*/
public function getFrameBuffer(name:String):BitmapData
{
return frameBufferMap[name].bitmap;
}
/**
* Disposes all frame buffers. The instance can be reused.
*/
public function dispose():Void
{
for (_ => fb in frameBufferMap)
{
fb.dispose();
}
frameBufferMap.clear();
}
}

View file

@ -0,0 +1,59 @@
package funkin.graphics.framebuffer;
import flixel.FlxCamera;
import flixel.FlxSprite;
import flixel.util.FlxColor;
/**
* A copy of a `FlxSprite` with a specified color. Used to render the sprite to a frame buffer.
*/
class SpriteCopy
{
final sprite:FlxSprite;
final color:Null<FlxColor>;
public function new(sprite:FlxSprite, color:Null<FlxColor>)
{
this.sprite = sprite;
this.color = color;
}
/**
* Renders the copy to the camera.
* @param camera the camera
*/
@:access(flixel.FlxSprite)
public function render(camera:FlxCamera):Void
{
if (color == null)
{
final tmpCameras = sprite._cameras;
sprite._cameras = [camera];
sprite.draw();
sprite._cameras = tmpCameras;
}
else
{
final rMult = sprite.colorTransform.redMultiplier;
final gMult = sprite.colorTransform.greenMultiplier;
final bMult = sprite.colorTransform.blueMultiplier;
final aMult = sprite.colorTransform.alphaMultiplier;
final rOff = Std.int(sprite.colorTransform.redOffset);
final gOff = Std.int(sprite.colorTransform.greenOffset);
final bOff = Std.int(sprite.colorTransform.blueOffset);
final aOff = Std.int(sprite.colorTransform.alphaOffset);
final tmpCameras = sprite._cameras;
final tmpShader = sprite.shader;
sprite._cameras = [camera];
sprite.shader = null;
sprite.setColorTransform(0, 0, 0, 1, color.red, color.green, color.blue, 0);
sprite.draw();
sprite._cameras = tmpCameras;
sprite.shader = tmpShader;
sprite.setColorTransform(rMult, gMult, bMult, aMult, rOff, gOff, bOff, aOff);
}
}
}

View file

@ -12,22 +12,19 @@ class MeshRender extends FlxStrip
public var vertex_count(default, null):Int = 0;
public var index_count(default, null):Int = 0;
var tri_offset:Int = 0;
public function new(x, y, ?col:FlxColor = FlxColor.WHITE)
{
super(x, y);
makeGraphic(1, 1, col);
}
public inline function start()
/**
* Add a vertex.
*/
public inline function build_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0):Int
{
tri_offset = vertex_count;
}
public inline function add_vertex(x:Float, y:Float, u:Float = 0, v:Float = 0)
{
final pos = vertex_count << 1;
final index = vertex_count;
final pos = index << 1;
vertices[pos] = x;
vertices[pos + 1] = y;
@ -36,48 +33,72 @@ class MeshRender extends FlxStrip
uvtData[pos + 1] = v;
vertex_count++;
return index;
}
public function add_tri(a:Int, b:Int, c:Int)
/**
* Build a triangle from three vertex indexes.
* @param a
* @param b
* @param c
*/
public function add_tri(a:Int, b:Int, c:Int):Void
{
indices[index_count] = a + tri_offset;
indices[index_count + 1] = b + tri_offset;
indices[index_count + 2] = c + tri_offset;
indices[index_count] = a;
indices[index_count + 1] = b;
indices[index_count + 2] = c;
index_count += 3;
}
/**
*
* top left - a
*
* top right - b
*
* bottom left - c
*
* bottom right - d
*/
public function add_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0,
bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0)
public function build_tri(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, au:Float = 0, av:Float = 0, bu:Float = 0, bv:Float = 0, cu:Float = 0,
cv:Float = 0):Void
{
start();
// top left
add_vertex(bx, by, bu, bv);
// top right
add_vertex(ax, ay, au, av);
// bottom left
add_vertex(cx, cy, cu, cv);
// bottom right
add_vertex(dx, dy, du, dv);
add_tri(build_vertex(ax, ay, au, av), build_vertex(bx, by, bu, bv), build_vertex(cx, cy, cu, cv));
}
add_tri(0, 1, 2);
add_tri(0, 2, 3);
/**
* @param a top left vertex
* @param b top right vertex
* @param c bottom right vertex
* @param d bottom left vertex
*/
public function add_quad(a:Int, b:Int, c:Int, d:Int):Void
{
add_tri(a, b, c);
add_tri(a, c, d);
}
/**
* Build a quad from four points.
*
* top right - a
* top left - b
* bottom right - c
* bottom left - d
*/
public function build_quad(ax:Float, ay:Float, bx:Float, by:Float, cx:Float, cy:Float, dx:Float, dy:Float, au:Float = 0, av:Float = 0, bu:Float = 0,
bv:Float = 0, cu:Float = 0, cv:Float = 0, du:Float = 0, dv:Float = 0):Void
{
// top left
var b = build_vertex(bx, by, bu, bv);
// top right
var a = build_vertex(ax, ay, au, av);
// bottom left
var c = build_vertex(cx, cy, cu, cv);
// bottom right
var d = build_vertex(dx, dy, du, dv);
add_tri(a, b, c);
add_tri(a, c, d);
}
public function clear()
{
vertices.length = 0;
indices.length = 0;
uvtData.length = 0;
colors.length = 0;
vertex_count = 0;
index_count = 0;
}

View file

@ -0,0 +1,12 @@
package funkin.graphics.shaders;
import flixel.addons.display.FlxRuntimeShader;
import openfl.Assets;
class PuddleShader extends FlxRuntimeShader
{
public function new()
{
super(Assets.getText(Paths.frag('puddle')));
}
}

View file

@ -0,0 +1,29 @@
package funkin.graphics.shaders;
import openfl.display.BitmapData;
import openfl.display.BlendMode;
import openfl.utils.Assets;
class RuntimeCustomBlendShader extends RuntimePostEffectShader
{
public var source(default, set):BitmapData;
function set_source(value:BitmapData):BitmapData
{
this.setBitmapData("source", value);
return source = value;
}
public var blend(default, set):BlendMode;
function set_blend(value:BlendMode):BlendMode
{
this.setInt("blendMode", cast value);
return blend = value;
}
public function new()
{
super(Assets.getText("assets/shaders/customBlend.frag"));
}
}

View file

@ -0,0 +1,105 @@
package funkin.graphics.shaders;
import flixel.FlxCamera;
import flixel.FlxG;
import flixel.addons.display.FlxRuntimeShader;
import lime.graphics.opengl.GLProgram;
import lime.utils.Log;
class RuntimePostEffectShader extends FlxRuntimeShader
{
@:glVertexHeader('
// normalized screen coord
// (0, 0) is the top left of the window
// (1, 1) is the bottom right of the window
varying vec2 screenCoord;
', true)
@:glVertexBody('
screenCoord = vec2(
openfl_TextureCoord.x > 0.0 ? 1.0 : 0.0,
openfl_TextureCoord.y > 0.0 ? 1.0 : 0.0
);
')
@:glFragmentHeader('
// normalized screen coord
// (0, 0) is the top left of the window
// (1, 1) is the bottom right of the window
varying vec2 screenCoord;
// equals (FlxG.width, FlxG.height)
uniform vec2 uScreenResolution;
// equals (camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom)
uniform vec4 uCameraBounds;
// screen coord -> world coord conversion
// returns world coord in px
vec2 screenToWorld(vec2 screenCoord) {
float left = uCameraBounds.x;
float top = uCameraBounds.y;
float right = uCameraBounds.z;
float bottom = uCameraBounds.w;
vec2 scale = vec2(right - left, bottom - top);
vec2 offset = vec2(left, top);
return screenCoord * scale + offset;
}
// world coord -> screen coord conversion
// returns normalized screen coord
vec2 worldToScreen(vec2 worldCoord) {
float left = uCameraBounds.x;
float top = uCameraBounds.y;
float right = uCameraBounds.z;
float bottom = uCameraBounds.w;
vec2 scale = vec2(right - left, bottom - top);
vec2 offset = vec2(left, top);
return (worldCoord - offset) / scale;
}
// internally used to get the maximum `openfl_TextureCoordv`
vec2 bitmapCoordScale() {
return openfl_TextureCoordv / screenCoord;
}
// internally used to compute bitmap coord
vec2 screenToBitmap(vec2 screenCoord) {
return screenCoord * bitmapCoordScale();
}
// samples the frame buffer using a screen coord
vec4 sampleBitmapScreen(vec2 screenCoord) {
return texture2D(bitmap, screenToBitmap(screenCoord));
}
// samples the frame buffer using a world coord
vec4 sampleBitmapWorld(vec2 worldCoord) {
return sampleBitmapScreen(worldToScreen(worldCoord));
}
', true)
public function new(fragmentSource:String = null, glVersion:String = null)
{
super(fragmentSource, null, glVersion);
uScreenResolution.value = [FlxG.width, FlxG.height];
}
// basically `updateViewInfo(FlxG.width, FlxG.height, FlxG.camera)` is good
public function updateViewInfo(screenWidth:Float, screenHeight:Float, camera:FlxCamera):Void
{
uScreenResolution.value = [screenWidth, screenHeight];
uCameraBounds.value = [camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom];
}
override function __createGLProgram(vertexSource:String, fragmentSource:String):GLProgram
{
try
{
final res = super.__createGLProgram(vertexSource, fragmentSource);
return res;
}
catch (error)
{
Log.warn(error); // prevent the app from dying immediately
return null;
}
}
}

View file

@ -0,0 +1,144 @@
package funkin.graphics.shaders;
import flixel.system.FlxAssets.FlxShader;
import openfl.display.BitmapData;
import openfl.display.ShaderParameter;
import openfl.display.ShaderParameterType;
import openfl.utils.Assets;
typedef Light =
{
var position:Array<Float>;
var color:Array<Float>;
var radius:Float;
}
class RuntimeRainShader extends RuntimePostEffectShader
{
static final MAX_LIGHTS:Int = 8;
public var lights:Array<
{
position:ShaderParameter<Float>,
color:ShaderParameter<Float>,
radius:ShaderParameter<Float>,
}>;
public var time(default, set):Float = 1;
function set_time(value:Float):Float
{
this.setFloat('uTime', value);
return time = value;
}
// The scale of the rain depends on the world coordinate system, so higher resolution makes
// the raindrops smaller. This parameter can be used to adjust the total scale of the scene.
// The size of the raindrops is proportional to the value of this parameter.
public var scale(default, set):Float = 1;
function set_scale(value:Float):Float
{
this.setFloat('uScale', value);
return scale = value;
}
// The intensity of the rain. Zero means no rain and one means the maximum amount of rain.
public var intensity(default, set):Float = 0.5;
function set_intensity(value:Float):Float
{
this.setFloat('uIntensity', value);
return intensity = value;
}
// the y coord of the puddle, used to mirror things
public var puddleY(default, set):Float = 0;
function set_puddleY(value:Float):Float
{
this.setFloat('uPuddleY', value);
return puddleY = value;
}
// the y scale of the puddle, the less this value the more the puddle effects squished
public var puddleScaleY(default, set):Float = 0;
function set_puddleScaleY(value:Float):Float
{
this.setFloat('uPuddleScaleY', value);
return puddleScaleY = value;
}
public var blurredScreen(default, set):BitmapData;
function set_blurredScreen(value:BitmapData):BitmapData
{
this.setBitmapData('uBlurredScreen', value);
return blurredScreen = value;
}
public var mask(default, set):BitmapData;
function set_mask(value:BitmapData):BitmapData
{
this.setBitmapData('uMask', value);
return mask = value;
}
public var lightMap(default, set):BitmapData;
function set_lightMap(value:BitmapData):BitmapData
{
this.setBitmapData('uLightMap', value);
return lightMap = value;
}
public var numLights(default, set):Int = 0;
function set_numLights(value:Int):Int
{
this.setInt('numLights', value);
return numLights = value;
}
public function new()
{
super(Assets.getText(Paths.frag('rain')));
}
public function update(elapsed:Float):Void
{
time += elapsed;
}
override function __processGLData(source:String, storageType:String):Void
{
super.__processGLData(source, storageType);
if (storageType == 'uniform')
{
lights = [
for (i in 0...MAX_LIGHTS)
{
position: addFloatUniform('lights[$i].position', 2),
color: addFloatUniform('lights[$i].color', 3),
radius: addFloatUniform('lights[$i].radius', 1),
}
];
}
}
@:access(openfl.display.ShaderParameter)
function addFloatUniform(name:String, length:Int):ShaderParameter<Float>
{
final res = new ShaderParameter<Float>();
res.name = name;
res.type = [null, FLOAT, FLOAT2, FLOAT3, FLOAT4][length];
res.__arrayLength = 1;
res.__isFloat = true;
res.__isUniform = true;
res.__length = length;
__paramFloat.push(res);
return res;
}
}

View file

@ -6,6 +6,7 @@ import funkin.util.Constants;
import funkin.Paths;
import funkin.Preferences;
import flixel.FlxG; // This one in particular causes a compile error if you're using macros.
import flixel.system.debug.watch.Tracker;
// These are great.
using Lambda;

View file

@ -2,7 +2,6 @@ package funkin.modding;
import funkin.util.macro.ClassMacro;
import funkin.modding.module.ModuleHandler;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.data.song.SongData;
import funkin.data.stage.StageData;
import polymod.Polymod;
@ -13,10 +12,11 @@ import funkin.data.stage.StageRegistry;
import funkin.util.FileUtil;
import funkin.data.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.play.cutscene.dialogue.ConversationDataParser;
import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
import funkin.data.dialogue.ConversationRegistry;
import funkin.data.dialogue.DialogueBoxRegistry;
import funkin.data.dialogue.SpeakerRegistry;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.save.Save;
import funkin.play.cutscene.dialogue.SpeakerDataParser;
import funkin.data.song.SongRegistry;
class PolymodHandler
@ -208,8 +208,8 @@ class PolymodHandler
{
return {
assetLibraryPaths: [
"default" => "preload", "shared" => "", "songs" => "songs", "tutorial" => "tutorial", "week1" => "week1", "week2" => "week2", "week3" => "week3",
"week4" => "week4", "week5" => "week5", "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1",
"default" => "preload", "shared" => "shared", "songs" => "songs", "tutorial" => "tutorial", "week1" => "week1", "week2" => "week2",
"week3" => "week3", "week4" => "week4", "week5" => "week5", "week6" => "week6", "week7" => "week7", "weekend1" => "weekend1",
],
coreAssetRedirect: CORE_FOLDER,
}
@ -273,11 +273,11 @@ class PolymodHandler
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
SongEventRegistry.loadEventCache();
ConversationDataParser.loadConversationCache();
DialogueBoxDataParser.loadDialogueBoxCache();
SpeakerDataParser.loadSpeakerCache();
ConversationRegistry.instance.loadEntries();
DialogueBoxRegistry.instance.loadEntries();
SpeakerRegistry.instance.loadEntries();
StageRegistry.instance.loadEntries();
CharacterDataParser.loadCharacterCache();
CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
ModuleHandler.loadModuleCache();
}
}

View file

@ -0,0 +1,8 @@
package funkin.modding.base;
/**
* A script that can be tied to an FlxAtlasSprite
* Create a scripted class that extends FlxAtlasSprite to use this.
*/
@:hscriptClass
class ScriptedFlxAtlasSprite extends funkin.graphics.adobeanimate.FlxAtlasSprite implements HScriptedClass {}

View file

@ -0,0 +1,8 @@
package funkin.modding.base;
/**
* A script that can be tied to an FlxSprite.
* Create a scripted class that extends FlxSprite to use this.
*/
@:hscriptClass
class ScriptedFunkinSprite extends funkin.graphics.FunkinSprite implements HScriptedClass {}

View file

@ -90,6 +90,7 @@ class GameOverSubState extends MusicBeatSubState
{
animationSuffix = "";
musicSuffix = "";
blueBallSuffix = "";
}
override public function create()
@ -207,11 +208,11 @@ class GameOverSubState extends MusicBeatSubState
}
else if (PlayStatePlaylist.isStoryMode)
{
FlxG.switchState(new StoryMenuState());
FlxG.switchState(() -> new StoryMenuState());
}
else
{
FlxG.switchState(new FreeplayState());
FlxG.switchState(() -> new FreeplayState());
}
}

View file

@ -66,11 +66,11 @@ class GitarooPause extends MusicBeatState
{
FlxTransitionableState.skipNextTransIn = false;
FlxTransitionableState.skipNextTransOut = false;
FlxG.switchState(new PlayState(previousParams));
FlxG.switchState(() -> new PlayState(previousParams));
}
else
{
FlxG.switchState(new MainMenuState());
FlxG.switchState(() -> new MainMenuState());
}
}

View file

@ -38,7 +38,8 @@ class PauseSubState extends MusicBeatSubState
var practiceText:FlxText;
var exitingToMenu:Bool = false;
public var exitingToMenu:Bool = false;
var bg:FlxSprite;
var metaDataGrp:FlxTypedGroup<FlxSprite>;
@ -168,7 +169,7 @@ class PauseSubState extends MusicBeatSubState
var downP = controls.UI_DOWN_P;
var accepted = controls.ACCEPT;
#if debug
#if (debug || FORCE_DEBUG_VERSION)
// to pause the game and get screenshots easy, press H on pause menu!
if (FlxG.keys.justPressed.H)
{
@ -234,11 +235,11 @@ class PauseSubState extends MusicBeatSubState
if (PlayStatePlaylist.isStoryMode)
{
PlayStatePlaylist.reset();
openSubState(new funkin.ui.transition.StickerSubState(null, STORY));
openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.story.StoryMenuState(sticker)));
}
else
{
openSubState(new funkin.ui.transition.StickerSubState(null, FREEPLAY));
openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(null, sticker)));
}
case 'Exit to Chart Editor':

View file

@ -1,68 +1,79 @@
package funkin.play;
import funkin.ui.SwagCamera;
import flixel.addons.transition.FlxTransitionableSubState;
import funkin.ui.debug.charting.ChartEditorState;
import haxe.Int64;
import funkin.play.notes.notestyle.NoteStyle;
import funkin.data.notestyle.NoteStyleData;
import funkin.data.notestyle.NoteStyleRegistry;
import flixel.addons.display.FlxPieDial;
import flixel.addons.transition.Transition;
import flixel.addons.display.FlxPieDial;
import flixel.addons.transition.FlxTransitionableState;
import flixel.addons.transition.FlxTransitionableState;
import flixel.addons.transition.FlxTransitionableSubState;
import flixel.addons.transition.FlxTransitionableSubState;
import flixel.addons.transition.Transition;
import flixel.addons.transition.Transition;
import flixel.FlxCamera;
import flixel.FlxObject;
import flixel.FlxSprite;
import flixel.FlxState;
import flixel.FlxSubState;
import flixel.input.keyboard.FlxKey;
import flixel.math.FlxMath;
import funkin.play.components.ComboMilestone;
import flixel.math.FlxPoint;
import funkin.play.components.HealthIcon;
import funkin.ui.MusicBeatSubState;
import flixel.math.FlxRect;
import flixel.text.FlxText;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
import flixel.ui.FlxBar;
import flixel.util.FlxColor;
import funkin.api.newgrounds.NGio;
import flixel.util.FlxTimer;
import funkin.api.newgrounds.NGio;
import funkin.audio.VoicesGroup;
import funkin.save.Save;
import funkin.audio.VoicesGroup;
import funkin.data.dialogue.ConversationRegistry;
import funkin.data.event.SongEventRegistry;
import funkin.data.notestyle.NoteStyleData;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.data.song.SongData.SongCharacterData;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongRegistry;
import funkin.data.stage.StageRegistry;
import funkin.Highscore.Tallies;
import funkin.input.PreciseInputManager;
import funkin.modding.events.ScriptEvent;
import funkin.ui.mainmenu.MainMenuState;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.character.BaseCharacter;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.components.ComboMilestone;
import funkin.play.components.HealthIcon;
import funkin.play.components.PopUpStuff;
import funkin.play.cutscene.dialogue.Conversation;
import funkin.play.cutscene.dialogue.Conversation;
import funkin.play.cutscene.dialogue.ConversationDataParser;
import funkin.play.cutscene.VanillaCutscenes;
import funkin.play.cutscene.VideoCutscene;
import funkin.data.event.SongEventRegistry;
import funkin.play.notes.NoteSprite;
import funkin.play.notes.NoteDirection;
import funkin.play.notes.NoteSplash;
import funkin.play.notes.NoteSprite;
import funkin.play.notes.NoteSprite;
import funkin.play.notes.notestyle.NoteStyle;
import funkin.play.notes.notestyle.NoteStyle;
import funkin.play.notes.Strumline;
import funkin.play.notes.SustainTrail;
import funkin.play.scoring.Scoring;
import funkin.play.song.Song;
import funkin.data.song.SongRegistry;
import funkin.data.stage.StageRegistry;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongCharacterData;
import funkin.play.stage.Stage;
import funkin.ui.transition.LoadingState;
import funkin.play.components.PopUpStuff;
import funkin.ui.options.PreferencesMenu;
import funkin.save.Save;
import funkin.ui.debug.charting.ChartEditorState;
import funkin.ui.debug.stage.StageOffsetSubState;
import funkin.ui.mainmenu.MainMenuState;
import funkin.ui.MusicBeatSubState;
import funkin.ui.options.PreferencesMenu;
import funkin.ui.story.StoryMenuState;
import funkin.graphics.FunkinCamera;
import funkin.ui.transition.LoadingState;
import funkin.util.SerializerUtil;
import funkin.util.SortUtil;
import haxe.Int64;
import lime.ui.Haptic;
import openfl.display.BitmapData;
import openfl.geom.Rectangle;
import openfl.Lib;
#if discord_rpc
import Discord.DiscordClient;
#end
@ -83,10 +94,16 @@ typedef PlayStateParams =
*/
?targetDifficulty:String,
/**
* The character to play as.
* @default `bf`, or the first character in the song's character list.
* The variation to play on.
* @default `Constants.DEFAULT_VARIATION` .
*/
?targetCharacter:String,
?targetVariation:String,
/**
* The instrumental to play with.
* Significant if the `targetSong` supports alternate instrumentals.
* @default `null`
*/
?targetInstrumental:String,
/**
* Whether the song should start in Practice Mode.
* @default `false`
@ -147,9 +164,9 @@ class PlayState extends MusicBeatSubState
public var currentDifficulty:String = Constants.DEFAULT_DIFFICULTY;
/**
* The player character being used for this level, as a character ID.
* The currently selected variation.
*/
public var currentPlayerId:String = 'bf';
public var currentVariation:String = Constants.DEFAULT_VARIATION;
/**
* The currently active Stage. This is the object containing all the props.
@ -439,6 +456,17 @@ class PlayState extends MusicBeatSubState
return this.subState != null;
}
var isExitingViaPauseMenu(get, never):Bool;
function get_isExitingViaPauseMenu():Bool
{
if (this.subState == null) return false;
if (!Std.isOfType(this.subState, PauseSubState)) return false;
var pauseSubState:PauseSubState = cast this.subState;
return pauseSubState.exitingToMenu;
}
/**
* Data for the current difficulty for the current song.
* Includes chart data, scroll speed, and other information.
@ -448,7 +476,7 @@ class PlayState extends MusicBeatSubState
function get_currentChart():SongDifficulty
{
if (currentSong == null || currentDifficulty == null) return null;
return currentSong.getDifficulty(currentDifficulty);
return currentSong.getDifficulty(currentDifficulty, currentVariation);
}
/**
@ -506,7 +534,7 @@ class PlayState extends MusicBeatSubState
// Apply parameters.
currentSong = params.targetSong;
if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty;
if (params.targetCharacter != null) currentPlayerId = params.targetCharacter;
if (params.targetVariation != null) currentVariation = params.targetVariation;
isPracticeMode = params.practiceMode ?? false;
isMinimalMode = params.minimalMode ?? false;
startTimestamp = params.startTimestamp ?? 0.0;
@ -638,7 +666,7 @@ class PlayState extends MusicBeatSubState
rightWatermarkText.cameras = [camHUD];
// Initialize some debug stuff.
#if debug
#if (debug || FORCE_DEBUG_VERSION)
// Display the version number (and git commit hash) in the bottom right corner.
this.rightWatermarkText.text = Constants.VERSION;
@ -684,9 +712,9 @@ class PlayState extends MusicBeatSubState
{
message = 'The was a critical error selecting a difficulty for this song. Click OK to return to the main menu.';
}
else if (currentSong.getDifficulty(currentDifficulty) == null)
else if (currentChart == null)
{
message = 'The was a critical error retrieving data for this song on "$currentDifficulty" difficulty. Click OK to return to the main menu.';
message = 'The was a critical error retrieving data for this song on "$currentDifficulty" difficulty with variation "$currentVariation". Click OK to return to the main menu.';
}
// Display a popup. This blocks the application until the user clicks OK.
@ -699,7 +727,7 @@ class PlayState extends MusicBeatSubState
}
else
{
FlxG.switchState(new MainMenuState());
FlxG.switchState(() -> new MainMenuState());
}
return false;
}
@ -828,11 +856,11 @@ class PlayState extends MusicBeatSubState
// It's a reference to Gitaroo Man, which doesn't let you pause the game.
if (!isSubState && event.gitaroo)
{
FlxG.switchState(new GitarooPause(
FlxG.switchState(() -> new GitarooPause(
{
targetSong: currentSong,
targetDifficulty: currentDifficulty,
targetCharacter: currentPlayerId,
targetVariation: currentVariation,
}));
}
else
@ -879,7 +907,7 @@ class PlayState extends MusicBeatSubState
// TODO: Add a song event for Handle GF dance speed.
// Handle player death.
if (!isInCutscene && !disableKeys && !_exiting)
if (!isInCutscene && !disableKeys)
{
// RESET = Quick Game Over Screen
if (controls.RESET)
@ -907,7 +935,7 @@ class PlayState extends MusicBeatSubState
// Disable updates, preventing animations in the background from playing.
persistentUpdate = false;
#if debug
#if (debug || FORCE_DEBUG_VERSION)
if (FlxG.keys.pressed.THREE)
{
// TODO: Change the key or delete this?
@ -918,7 +946,7 @@ class PlayState extends MusicBeatSubState
{
#end
persistentDraw = false;
#if debug
#if (debug || FORCE_DEBUG_VERSION)
}
#end
@ -1276,7 +1304,7 @@ class PlayState extends MusicBeatSubState
*/
function initCameras():Void
{
camGame = new SwagCamera();
camGame = new FunkinCamera();
camGame.bgColor = BACKGROUND_COLOR; // Show a pink background behind the stage.
camHUD = new FlxCamera();
camHUD.bgColor.alpha = 0; // Show the game scene behind the camera.
@ -1368,7 +1396,7 @@ class PlayState extends MusicBeatSubState
// Add the stage to the scene.
this.add(currentStage);
#if debug
#if (debug || FORCE_DEBUG_VERSION)
FlxG.console.registerObject('stage', currentStage);
#end
}
@ -1389,7 +1417,7 @@ class PlayState extends MusicBeatSubState
trace('Song difficulty could not be loaded.');
}
var currentCharacterData:SongCharacterData = currentChart.characters; // Switch the character we are playing as by manipulating currentPlayerId.
var currentCharacterData:SongCharacterData = currentChart.characters; // Switch the variation we are playing on by manipulating targetVariation.
//
// GIRLFRIEND
@ -1458,7 +1486,7 @@ class PlayState extends MusicBeatSubState
{
currentStage.addCharacter(girlfriend, GF);
#if debug
#if (debug || FORCE_DEBUG_VERSION)
FlxG.console.registerObject('gf', girlfriend);
#end
}
@ -1467,7 +1495,7 @@ class PlayState extends MusicBeatSubState
{
currentStage.addCharacter(boyfriend, BF);
#if debug
#if (debug || FORCE_DEBUG_VERSION)
FlxG.console.registerObject('bf', boyfriend);
#end
}
@ -1478,7 +1506,7 @@ class PlayState extends MusicBeatSubState
// Camera starts at dad.
cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
#if debug
#if (debug || FORCE_DEBUG_VERSION)
FlxG.console.registerObject('dad', dad);
#end
}
@ -1662,7 +1690,7 @@ class PlayState extends MusicBeatSubState
{
isInCutscene = true;
currentConversation = ConversationDataParser.fetchConversation(conversationId);
currentConversation = ConversationRegistry.instance.fetchEntry(conversationId);
if (currentConversation == null) return;
currentConversation.completeCallback = onConversationComplete;
@ -1733,7 +1761,7 @@ class PlayState extends MusicBeatSubState
*/
function resyncVocals():Void
{
if (_exiting || vocals == null) return;
if (vocals == null) return;
// Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.)
if (!FlxG.sound.music.playing) return;
@ -1959,7 +1987,7 @@ class PlayState extends MusicBeatSubState
// Mute vocals and play miss animation, but don't penalize.
vocals.playerVolume = 0;
currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true);
if (currentStage != null && currentStage.getBoyfriend() != null) currentStage.getBoyfriend().playSingAnimation(holdNote.noteData.getDirection(), true);
}
}
}
@ -2229,7 +2257,7 @@ class PlayState extends MusicBeatSubState
#end
// Eject button
if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState());
if (FlxG.keys.justPressed.F5) debug_refreshModules();
@ -2247,13 +2275,13 @@ class PlayState extends MusicBeatSubState
{
disableKeys = true;
persistentUpdate = false;
FlxG.switchState(new ChartEditorState(
FlxG.switchState(() -> new ChartEditorState(
{
targetSongId: currentSong.id,
}));
}
#if debug
#if (debug || FORCE_DEBUG_VERSION)
// 1: End the song immediately.
if (FlxG.keys.justPressed.ONE) endSong();
@ -2267,7 +2295,7 @@ class PlayState extends MusicBeatSubState
// 9: Toggle the old icon.
if (FlxG.keys.justPressed.NINE) iconP1.toggleOldIcon();
#if debug
#if (debug || FORCE_DEBUG_VERSION)
// PAGEUP: Skip forward two sections.
// SHIFT+PAGEUP: Skip forward twenty sections.
if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 20 : 2);
@ -2513,8 +2541,8 @@ class PlayState extends MusicBeatSubState
{
FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
transIn = FlxTransitionableState.defaultTransIn;
transOut = FlxTransitionableState.defaultTransOut;
// transIn = FlxTransitionableState.defaultTransIn;
// transOut = FlxTransitionableState.defaultTransOut;
// TODO: Rework week unlock logic.
// StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true;
@ -2589,14 +2617,16 @@ class PlayState extends MusicBeatSubState
// TODO: Do this in the loading state.
targetSong.cacheCharts(true);
var nextPlayState:PlayState = new PlayState(
{
targetSong: targetSong,
targetDifficulty: PlayStatePlaylist.campaignDifficulty,
targetCharacter: currentPlayerId,
});
nextPlayState.previousCameraFollowPoint = new FlxSprite(cameraFollowPoint.x, cameraFollowPoint.y);
LoadingState.loadAndSwitchState(nextPlayState);
LoadingState.loadAndSwitchState(() -> {
var nextPlayState:PlayState = new PlayState(
{
targetSong: targetSong,
targetDifficulty: PlayStatePlaylist.campaignDifficulty,
targetVariation: currentVariation,
});
nextPlayState.previousCameraFollowPoint = new FlxSprite(cameraFollowPoint.x, cameraFollowPoint.y);
return nextPlayState;
});
});
}
else
@ -2605,14 +2635,16 @@ class PlayState extends MusicBeatSubState
// Load and cache the song's charts.
// TODO: Do this in the loading state.
targetSong.cacheCharts(true);
var nextPlayState:PlayState = new PlayState(
{
targetSong: targetSong,
targetDifficulty: PlayStatePlaylist.campaignDifficulty,
targetCharacter: currentPlayerId,
});
nextPlayState.previousCameraFollowPoint = new FlxSprite(cameraFollowPoint.x, cameraFollowPoint.y);
LoadingState.loadAndSwitchState(nextPlayState);
LoadingState.loadAndSwitchState(() -> {
var nextPlayState:PlayState = new PlayState(
{
targetSong: targetSong,
targetDifficulty: PlayStatePlaylist.campaignDifficulty,
targetVariation: currentVariation,
});
nextPlayState.previousCameraFollowPoint = new FlxSprite(cameraFollowPoint.x, cameraFollowPoint.y);
return nextPlayState;
});
}
}
}
@ -2650,13 +2682,16 @@ class PlayState extends MusicBeatSubState
{
// Stop the music.
FlxG.sound.music.pause();
vocals.stop();
if (vocals != null) vocals.stop();
}
else
{
FlxG.sound.music.pause();
vocals.pause();
remove(vocals);
if (vocals != null)
{
vocals.pause();
remove(vocals);
}
}
// Remove reference to stage and remove sprites from it to save memory.
@ -2768,7 +2803,7 @@ class PlayState extends MusicBeatSubState
FlxG.camera.focusOn(cameraFollowPoint.getPosition());
}
#if debug
#if (debug || FORCE_DEBUG_VERSION)
/**
* Jumps forward or backward a number of sections in the song.
* Accounts for BPM changes, does not prevent death from skipped notes.

View file

@ -352,11 +352,11 @@ class ResultState extends MusicBeatSubState
{
if (params.storyMode)
{
FlxG.switchState(new StoryMenuState());
openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker)));
}
else
{
FlxG.switchState(new FreeplayState());
openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new FreeplayState(null, sticker)));
}
}

View file

@ -43,7 +43,7 @@ class CharacterDataParser
{
// Clear any stages that are cached if there were any.
clearCharacterCache();
trace('Loading character cache...');
trace('[CHARACTER] Parsing all entries...');
//
// UNSCRIPTED CHARACTERS

View file

@ -148,11 +148,12 @@ class HealthIcon extends FlxSprite
{
if (characterId == 'bf-old')
{
characterId = PlayState.instance.currentPlayerId;
PlayState.instance.currentStage.getBoyfriend().initHealthIcon(false);
}
else
{
characterId = 'bf-old';
loadCharacter(characterId);
}
}

View file

@ -1,8 +1,10 @@
package funkin.play.cutscene.dialogue;
import funkin.data.IRegistryEntry;
import flixel.FlxSprite;
import flixel.group.FlxSpriteGroup;
import flixel.util.FlxColor;
import funkin.graphics.FunkinSprite;
import flixel.tweens.FlxTween;
import flixel.tweens.FlxEase;
import flixel.sound.FlxSound;
@ -13,27 +15,30 @@ import funkin.modding.IScriptedClass.IEventHandler;
import funkin.play.cutscene.dialogue.DialogueBox;
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData;
import flixel.addons.display.FlxPieDial;
import funkin.data.dialogue.ConversationData;
import funkin.data.dialogue.ConversationData.DialogueEntryData;
import funkin.data.dialogue.ConversationRegistry;
import funkin.data.dialogue.SpeakerData;
import funkin.data.dialogue.SpeakerRegistry;
import funkin.data.dialogue.DialogueBoxData;
import funkin.data.dialogue.DialogueBoxRegistry;
/**
* A high-level handler for dialogue.
*
* This shit is great for modders but it's pretty elaborate for how much it'll actually be used, lolol. -Eric
*/
class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry<ConversationData>
{
static final CONVERSATION_SKIP_TIMER:Float = 1.5;
var skipHeldTimer:Float = 0.0;
/**
* DATA
* The ID of the conversation.
*/
/**
* The ID of the associated dialogue.
*/
public final conversationId:String;
public final id:String;
/**
* The current state of the conversation.
@ -41,9 +46,9 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
var state:ConversationState = ConversationState.Start;
/**
* The data for the associated dialogue.
* Conversation data as parsed from the JSON file.
*/
var conversationData:ConversationData;
public final _data:ConversationData;
/**
* The current entry in the dialogue.
@ -54,7 +59,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
function get_currentDialogueEntryCount():Int
{
return conversationData.dialogue.length;
return _data.dialogue.length;
}
/**
@ -73,10 +78,10 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
function get_currentDialogueEntryData():DialogueEntryData
{
if (conversationData == null || conversationData.dialogue == null) return null;
if (currentDialogueEntry < 0 || currentDialogueEntry >= conversationData.dialogue.length) return null;
if (_data == null || _data.dialogue == null) return null;
if (currentDialogueEntry < 0 || currentDialogueEntry >= _data.dialogue.length) return null;
return conversationData.dialogue[currentDialogueEntry];
return _data.dialogue[currentDialogueEntry];
}
var currentDialogueLineString(get, never):String;
@ -94,7 +99,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
/**
* GRAPHICS
*/
var backdrop:FlxSprite;
var backdrop:FunkinSprite;
var currentSpeaker:Speaker;
@ -102,14 +107,17 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
var skipTimer:FlxPieDial;
public function new(conversationId:String)
public function new(id:String)
{
super();
this.conversationId = conversationId;
this.conversationData = ConversationDataParser.parseConversationData(this.conversationId);
this.id = id;
this._data = _fetchData(id);
if (conversationData == null) throw 'Could not load conversation data for conversation ID "$conversationId"';
if (_data == null)
{
throw 'Could not parse conversation data for id: $id';
}
}
public function onCreate(event:ScriptEvent):Void
@ -125,14 +133,14 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
function setupMusic():Void
{
if (conversationData.music == null) return;
if (_data.music == null) return;
music = new FlxSound().loadEmbedded(Paths.music(conversationData.music.asset), true, true);
music = new FlxSound().loadEmbedded(Paths.music(_data.music.asset), true, true);
music.volume = 0;
if (conversationData.music.fadeTime > 0.0)
if (_data.music.fadeTime > 0.0)
{
FlxTween.tween(music, {volume: 1.0}, conversationData.music.fadeTime, {ease: FlxEase.linear});
FlxTween.tween(music, {volume: 1.0}, _data.music.fadeTime, {ease: FlxEase.linear});
}
else
{
@ -145,19 +153,20 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
function setupBackdrop():Void
{
backdrop = new FlxSprite(0, 0);
backdrop = new FunkinSprite(0, 0);
if (conversationData.backdrop == null) return;
if (_data.backdrop == null) return;
// Play intro
switch (conversationData?.backdrop.type)
switch (_data.backdrop)
{
case SOLID:
backdrop.makeGraphic(Std.int(FlxG.width), Std.int(FlxG.height), FlxColor.fromString(conversationData.backdrop.data.color));
if (conversationData.backdrop.data.fadeTime > 0.0)
case SOLID(backdropData):
var targetColor:FlxColor = FlxColor.fromString(backdropData.color);
backdrop.makeSolidColor(Std.int(FlxG.width), Std.int(FlxG.height), targetColor);
if (backdropData.fadeTime > 0.0)
{
backdrop.alpha = 0.0;
FlxTween.tween(backdrop, {alpha: 1.0}, conversationData.backdrop.data.fadeTime, {ease: FlxEase.linear});
FlxTween.tween(backdrop, {alpha: 1.0}, backdropData.fadeTime, {ease: FlxEase.linear});
}
else
{
@ -190,9 +199,9 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
var nextSpeakerId:String = currentDialogueEntryData.speaker;
// Skip the next steps if the current speaker is already displayed.
if (currentSpeaker != null && nextSpeakerId == currentSpeaker.speakerId) return;
if (currentSpeaker != null && nextSpeakerId == currentSpeaker.id) return;
var nextSpeaker:Speaker = SpeakerDataParser.fetchSpeaker(nextSpeakerId);
var nextSpeaker:Speaker = SpeakerRegistry.instance.fetchEntry(nextSpeakerId);
if (currentSpeaker != null)
{
@ -241,7 +250,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
var nextDialogueBoxId:String = currentDialogueEntryData?.box;
// Skip the next steps if the current speaker is already displayed.
if (currentDialogueBox != null && nextDialogueBoxId == currentDialogueBox.dialogueBoxId) return;
if (currentDialogueBox != null && nextDialogueBoxId == currentDialogueBox.id) return;
if (currentDialogueBox != null)
{
@ -250,7 +259,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
currentDialogueBox = null;
}
var nextDialogueBox:DialogueBox = DialogueBoxDataParser.fetchDialogueBox(nextDialogueBoxId);
var nextDialogueBox:DialogueBox = DialogueBoxRegistry.instance.fetchEntry(nextDialogueBoxId);
if (nextDialogueBox == null)
{
@ -378,20 +387,18 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
public function startOutro():Void
{
switch (conversationData?.outro?.type)
switch (_data?.outro)
{
case FADE:
var fadeTime:Float = conversationData?.outro.data.fadeTime ?? 1.0;
outroTween = FlxTween.tween(this, {alpha: 0.0}, fadeTime,
case FADE(outroData):
outroTween = FlxTween.tween(this, {alpha: 0.0}, outroData.fadeTime,
{
type: ONESHOT, // holy shit like the game no way
startDelay: 0,
onComplete: (_) -> endOutro(),
});
FlxTween.tween(this.music, {volume: 0.0}, fadeTime);
case NONE:
FlxTween.tween(this.music, {volume: 0.0}, outroData.fadeTime);
case NONE(_):
// Immediately clean up.
endOutro();
default:
@ -400,7 +407,7 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
}
}
public var completeCallback:Void->Void;
public var completeCallback:() -> Void;
public function endOutro():Void
{
@ -596,7 +603,12 @@ class Conversation extends FlxSpriteGroup implements IDialogueScriptedClass
public override function toString():String
{
return 'Conversation($conversationId)';
return 'Conversation($id)';
}
static function _fetchData(id:String):Null<ConversationData>
{
return ConversationRegistry.instance.parseEntryDataWithMigration(id, ConversationRegistry.instance.fetchEntryVersion(id));
}
}

View file

@ -1,240 +0,0 @@
package funkin.play.cutscene.dialogue;
import funkin.util.SerializerUtil;
/**
* Data about a conversation.
* Includes what speakers are in the conversation, and what phrases they say.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class ConversationData
{
public var version:String;
public var backdrop:BackdropData;
public var outro:OutroData;
public var music:MusicData;
public var dialogue:Array<DialogueEntryData>;
public function new(version:String, backdrop:BackdropData, outro:OutroData, music:MusicData, dialogue:Array<DialogueEntryData>)
{
this.version = version;
this.backdrop = backdrop;
this.outro = outro;
this.music = music;
this.dialogue = dialogue;
}
public static function fromString(i:String):ConversationData
{
if (i == null || i == '') return null;
var data:
{
version:String,
backdrop:Dynamic, // TODO: tink.Json doesn't like when these are typed
?outro:Dynamic, // TODO: tink.Json doesn't like when these are typed
?music:Dynamic, // TODO: tink.Json doesn't like when these are typed
dialogue:Array<Dynamic> // TODO: tink.Json doesn't like when these are typed
} = tink.Json.parse(i);
return fromJson(data);
}
public static function fromJson(j:Dynamic):ConversationData
{
// TODO: Check version and perform migrations if necessary.
if (j == null) return null;
return new ConversationData(j.version, BackdropData.fromJson(j.backdrop), OutroData.fromJson(j.outro), MusicData.fromJson(j.music),
j.dialogue.map(d -> DialogueEntryData.fromJson(d)));
}
public function toJson():Dynamic
{
return {
version: this.version,
backdrop: this.backdrop.toJson(),
dialogue: this.dialogue.map(d -> d.toJson())
};
}
}
/**
* Data about a single dialogue entry.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.DialogueEntryData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class DialogueEntryData
{
/**
* The speaker who says this phrase.
*/
public var speaker:String;
/**
* The animation the speaker will play.
*/
public var speakerAnimation:String;
/**
* The text box that will appear.
*/
public var box:String;
/**
* The animation the dialogue box will play.
*/
public var boxAnimation:String;
/**
* The lines of text that will appear in the text box.
*/
public var text:Array<String>;
/**
* The relative speed at which the text will scroll.
* @default 1.0
*/
public var speed:Float = 1.0;
public function new(speaker:String, speakerAnimation:String, box:String, boxAnimation:String, text:Array<String>, speed:Float = null)
{
this.speaker = speaker;
this.speakerAnimation = speakerAnimation;
this.box = box;
this.boxAnimation = boxAnimation;
this.text = text;
if (speed != null) this.speed = speed;
}
public static function fromJson(j:Dynamic):DialogueEntryData
{
if (j == null) return null;
return new DialogueEntryData(j.speaker, j.speakerAnimation, j.box, j.boxAnimation, j.text, j.speed);
}
public function toJson():Dynamic
{
var result:Dynamic =
{
speaker: this.speaker,
speakerAnimation: this.speakerAnimation,
box: this.box,
boxAnimation: this.boxAnimation,
text: this.text,
};
if (this.speed != 1.0) result.speed = this.speed;
return result;
}
}
/**
* Data about a backdrop.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.BackdropData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class BackdropData
{
public var type:BackdropType;
public var data:Dynamic;
public function new(typeStr:String, data:Dynamic)
{
this.type = typeStr;
this.data = data;
}
public static function fromJson(j:Dynamic):BackdropData
{
if (j == null) return null;
return new BackdropData(j.type, j.data);
}
public function toJson():Dynamic
{
return {
type: this.type,
data: this.data
};
}
}
enum abstract BackdropType(String) from String to String
{
public var SOLID:BackdropType = 'solid';
}
/**
* Data about a music track.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.MusicData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class MusicData
{
public var asset:String;
public var fadeTime:Float;
@:optional
@:default(false)
public var looped:Bool;
public function new(asset:String, looped:Bool, fadeTime:Float = 0.0)
{
this.asset = asset;
this.looped = looped;
this.fadeTime = fadeTime;
}
public static function fromJson(j:Dynamic):MusicData
{
if (j == null) return null;
return new MusicData(j.asset, j.looped, j.fadeTime);
}
public function toJson():Dynamic
{
return {
asset: this.asset,
looped: this.looped,
fadeTime: this.fadeTime
};
}
}
/**
* Data about an outro.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.ConversationData.OutroData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class OutroData
{
public var type:OutroType;
public var data:Dynamic;
public function new(?typeStr:String, data:Dynamic)
{
this.type = typeStr ?? OutroType.NONE;
this.data = data;
}
public static function fromJson(j:Dynamic):OutroData
{
if (j == null) return null;
return new OutroData(j.type, j.data);
}
public function toJson():Dynamic
{
return {
type: this.type,
data: this.data
};
}
}
enum abstract OutroType(String) from String to String
{
public var NONE:OutroType = 'none';
public var FADE:OutroType = 'fade';
}

View file

@ -1,163 +0,0 @@
package funkin.play.cutscene.dialogue;
import openfl.Assets;
import funkin.util.assets.DataAssets;
import funkin.play.cutscene.dialogue.ScriptedConversation;
/**
* Contains utilities for loading and parsing conversation data.
* TODO: Refactor to use the json2object + BaseRegistry system that actually validates things for you.
*/
class ConversationDataParser
{
public static final CONVERSATION_DATA_VERSION:String = '1.0.0';
public static final CONVERSATION_DATA_VERSION_RULE:String = '1.0.x';
static final conversationCache:Map<String, Conversation> = new Map<String, Conversation>();
static final conversationScriptedClass:Map<String, String> = new Map<String, String>();
static final DEFAULT_CONVERSATION_ID:String = 'UNKNOWN';
/**
* Parses and preloads the game's conversation data and scripts when the game starts.
*
* If you want to force conversations to be reloaded, you can just call this function again.
*/
public static function loadConversationCache():Void
{
clearConversationCache();
trace('Loading dialogue conversation cache...');
//
// SCRIPTED CONVERSATIONS
//
var scriptedConversationClassNames:Array<String> = ScriptedConversation.listScriptClasses();
trace(' Instantiating ${scriptedConversationClassNames.length} scripted conversations...');
for (conversationCls in scriptedConversationClassNames)
{
var conversation:Conversation = ScriptedConversation.init(conversationCls, DEFAULT_CONVERSATION_ID);
if (conversation != null)
{
trace(' Loaded scripted conversation: ${conversationCls}');
// Disable the rendering logic for conversation until it's loaded.
// Note that kill() =/= destroy()
conversation.kill();
// Then store it.
conversationCache.set(conversation.conversationId, conversation);
}
else
{
trace(' Failed to instantiate scripted conversation class: ${conversationCls}');
}
}
//
// UNSCRIPTED CONVERSATIONS
//
// Scripts refers to code here, not the actual dialogue.
var conversationIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/conversations/');
// Filter out conversations that are scripted.
var unscriptedConversationIds:Array<String> = conversationIdList.filter(function(conversationId:String):Bool {
return !conversationCache.exists(conversationId);
});
trace(' Fetching data for ${unscriptedConversationIds.length} conversations...');
for (conversationId in unscriptedConversationIds)
{
try
{
var conversation:Conversation = new Conversation(conversationId);
// Say something offensive to kill the conversation.
// We will revive it later.
conversation.kill();
if (conversation != null)
{
trace(' Loaded conversation data: ${conversation.conversationId}');
conversationCache.set(conversation.conversationId, conversation);
}
}
catch (e)
{
trace(e);
continue;
}
}
}
/**
* Fetches data for a conversation and returns a Conversation instance,
* ready to be displayed.
* @param conversationId The ID of the conversation to fetch.
* @return The conversation instance, or null if the conversation was not found.
*/
public static function fetchConversation(conversationId:String):Null<Conversation>
{
if (conversationId != null && conversationId != '' && conversationCache.exists(conversationId))
{
trace('Successfully fetched conversation: ${conversationId}');
var conversation:Conversation = conversationCache.get(conversationId);
// ...ANYway...
conversation.revive();
return conversation;
}
else
{
trace('Failed to fetch conversation, not found in cache: ${conversationId}');
return null;
}
}
static function clearConversationCache():Void
{
if (conversationCache != null)
{
for (conversation in conversationCache)
{
conversation.destroy();
}
conversationCache.clear();
}
}
public static function listConversationIds():Array<String>
{
return conversationCache.keys().array();
}
/**
* Load a conversation's JSON file, parse its data, and return it.
*
* @param conversationId The conversation to load.
* @return The conversation data, or null if validation failed.
*/
public static function parseConversationData(conversationId:String):Null<ConversationData>
{
trace('Parsing conversation data: ${conversationId}');
var rawJson:String = loadConversationFile(conversationId);
try
{
var conversationData:ConversationData = ConversationData.fromString(rawJson);
return conversationData;
}
catch (e)
{
trace('Failed to parse conversation ($conversationId).');
trace(e);
return null;
}
}
static function loadConversationFile(conversationPath:String):String
{
var conversationFilePath:String = Paths.json('dialogue/conversations/${conversationPath}');
var rawJson:String = Assets.getText(conversationFilePath).trim();
while (!rawJson.endsWith('}') && rawJson.length > 0)
{
rawJson = rawJson.substr(0, rawJson.length - 1);
}
return rawJson;
}
}

View file

@ -1,6 +1,7 @@
package funkin.play.cutscene.dialogue;
import flixel.FlxSprite;
import funkin.data.IRegistryEntry;
import flixel.group.FlxSpriteGroup;
import flixel.graphics.frames.FlxFramesCollection;
import flixel.text.FlxText;
@ -9,18 +10,21 @@ import funkin.util.assets.FlxAnimationUtil;
import funkin.modding.events.ScriptEvent;
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
import flixel.util.FlxColor;
import funkin.data.dialogue.DialogueBoxData;
import funkin.data.dialogue.DialogueBoxRegistry;
class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass implements IRegistryEntry<DialogueBoxData>
{
public final dialogueBoxId:String;
public final id:String;
public var dialogueBoxName(get, never):String;
function get_dialogueBoxName():String
{
return boxData?.name ?? 'UNKNOWN';
return _data.name ?? 'UNKNOWN';
}
var boxData:DialogueBoxData;
public final _data:DialogueBoxData;
/**
* Offset the speaker's sprite by this much when playing each animation.
@ -88,13 +92,16 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
return this.speed;
}
public function new(dialogueBoxId:String)
public function new(id:String)
{
super();
this.dialogueBoxId = dialogueBoxId;
this.boxData = DialogueBoxDataParser.parseDialogueBoxData(this.dialogueBoxId);
this.id = id;
this._data = _fetchData(id);
if (boxData == null) throw 'Could not load dialogue box data for box ID "$dialogueBoxId"';
if (_data == null)
{
throw 'Could not parse dialogue box data for id: $id';
}
}
public function onCreate(event:ScriptEvent):Void
@ -115,18 +122,18 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
function loadSpritesheet():Void
{
trace('[DIALOGUE BOX] Loading spritesheet ${boxData.assetPath} for ${dialogueBoxId}');
trace('[DIALOGUE BOX] Loading spritesheet ${_data.assetPath} for ${id}');
var tex:FlxFramesCollection = Paths.getSparrowAtlas(boxData.assetPath);
var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath);
if (tex == null)
{
trace('Could not load Sparrow sprite: ${boxData.assetPath}');
trace('Could not load Sparrow sprite: ${_data.assetPath}');
return;
}
this.boxSprite.frames = tex;
if (boxData.isPixel)
if (_data.isPixel)
{
this.boxSprite.antialiasing = false;
}
@ -135,9 +142,10 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
this.boxSprite.antialiasing = true;
}
this.flipX = boxData.flipX;
this.globalOffsets = boxData.offsets;
this.setScale(boxData.scale);
this.flipX = _data.flipX;
this.flipY = _data.flipY;
this.globalOffsets = _data.offsets;
this.setScale(_data.scale);
}
public function setText(newText:String):Void
@ -184,11 +192,11 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
function loadAnimations():Void
{
trace('[DIALOGUE BOX] Loading ${boxData.animations.length} animations for ${dialogueBoxId}');
trace('[DIALOGUE BOX] Loading ${_data.animations.length} animations for ${id}');
FlxAnimationUtil.addAtlasAnimations(this.boxSprite, boxData.animations);
FlxAnimationUtil.addAtlasAnimations(this.boxSprite, _data.animations);
for (anim in boxData.animations)
for (anim in _data.animations)
{
if (anim.offsets == null)
{
@ -201,7 +209,7 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
}
var animNames:Array<String> = this.boxSprite?.animation?.getNameList() ?? [];
trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${dialogueBoxId}');
trace('[DIALOGUE BOX] Successfully loaded ${animNames.length} animations for ${id}');
boxSprite.animation.callback = this.onAnimationFrame;
boxSprite.animation.finishCallback = this.onAnimationFinished;
@ -234,16 +242,16 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
function loadText():Void
{
textDisplay = new FlxTypeText(0, 0, 300, '', 32);
textDisplay.fieldWidth = boxData.text.width;
textDisplay.setFormat('Pixel Arial 11 Bold', boxData.text.size, FlxColor.fromString(boxData.text.color), LEFT, SHADOW,
FlxColor.fromString(boxData.text.shadowColor ?? '#00000000'), false);
textDisplay.borderSize = boxData.text.shadowWidth ?? 2;
textDisplay.fieldWidth = _data.text.width;
textDisplay.setFormat(_data.text.fontFamily, _data.text.size, FlxColor.fromString(_data.text.color), LEFT, SHADOW,
FlxColor.fromString(_data.text.shadowColor ?? '#00000000'), false);
textDisplay.borderSize = _data.text.shadowWidth ?? 2;
textDisplay.sounds = [FlxG.sound.load(Paths.sound('pixelText'), 0.6)];
textDisplay.completeCallback = onTypingComplete;
textDisplay.x += boxData.text.offsets[0];
textDisplay.y += boxData.text.offsets[1];
textDisplay.x += _data.text.offsets[0];
textDisplay.y += _data.text.offsets[1];
add(textDisplay);
}
@ -374,4 +382,14 @@ class DialogueBox extends FlxSpriteGroup implements IDialogueScriptedClass
}
public function onScriptEvent(event:ScriptEvent):Void {}
public override function toString():String
{
return 'DialogueBox($id)';
}
static function _fetchData(id:String):Null<DialogueBoxData>
{
return DialogueBoxRegistry.instance.parseEntryDataWithMigration(id, DialogueBoxRegistry.instance.fetchEntryVersion(id));
}
}

View file

@ -1,124 +0,0 @@
package funkin.play.cutscene.dialogue;
import funkin.data.animation.AnimationData;
import funkin.util.SerializerUtil;
/**
* Data about a text box.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class DialogueBoxData
{
public var version:String;
public var name:String;
public var assetPath:String;
public var flipX:Bool;
public var flipY:Bool;
public var isPixel:Bool;
public var offsets:Array<Float>;
public var text:DialogueBoxTextData;
public var scale:Float;
public var animations:Array<AnimationData>;
public function new(version:String, name:String, assetPath:String, flipX:Bool = false, flipY:Bool = false, isPixel:Bool = false, offsets:Null<Array<Float>>,
text:DialogueBoxTextData, scale:Float = 1.0, animations:Array<AnimationData>)
{
this.version = version;
this.name = name;
this.assetPath = assetPath;
this.flipX = flipX;
this.flipY = flipY;
this.isPixel = isPixel;
this.offsets = offsets ?? [0, 0];
this.text = text;
this.scale = scale;
this.animations = animations;
}
public static function fromString(i:String):DialogueBoxData
{
if (i == null || i == '') return null;
var data:
{
version:String,
name:String,
assetPath:String,
flipX:Bool,
flipY:Bool,
isPixel:Bool,
?offsets:Array<Float>,
text:Dynamic,
scale:Float,
animations:Array<AnimationData>
} = tink.Json.parse(i);
return fromJson(data);
}
public static function fromJson(j:Dynamic):DialogueBoxData
{
// TODO: Check version and perform migrations if necessary.
if (j == null) return null;
return new DialogueBoxData(j.version, j.name, j.assetPath, j.flipX, j.flipY, j.isPixel, j.offsets, DialogueBoxTextData.fromJson(j.text), j.scale,
j.animations);
}
public function toJson():Dynamic
{
return {
version: this.version,
name: this.name,
assetPath: this.assetPath,
flipX: this.flipX,
flipY: this.flipY,
isPixel: this.isPixel,
offsets: this.offsets,
scale: this.scale,
animations: this.animations
};
}
}
/**
* Data about text in a text box.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.DialogueBoxTextData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class DialogueBoxTextData
{
public var offsets:Array<Float>;
public var width:Int;
public var size:Int;
public var color:String;
public var shadowColor:Null<String>;
public var shadowWidth:Null<Int>;
public function new(offsets:Null<Array<Float>>, ?width:Int, ?size:Int, color:String, ?shadowColor:String, shadowWidth:Null<Int>)
{
this.offsets = offsets ?? [0, 0];
this.width = width ?? 300;
this.size = size ?? 32;
this.color = color;
this.shadowColor = shadowColor;
this.shadowWidth = shadowWidth;
}
public static function fromJson(j:Dynamic):DialogueBoxTextData
{
// TODO: Check version and perform migrations if necessary.
if (j == null) return null;
return new DialogueBoxTextData(j.offsets, j.width, j.size, j.color, j.shadowColor, j.shadowWidth);
}
public function toJson():Dynamic
{
return {
offsets: this.offsets,
width: this.width,
size: this.size,
color: this.color,
shadowColor: this.shadowColor,
shadowWidth: this.shadowWidth,
};
}
}

View file

@ -1,159 +0,0 @@
package funkin.play.cutscene.dialogue;
import openfl.Assets;
import funkin.util.assets.DataAssets;
import funkin.play.cutscene.dialogue.DialogueBox;
import funkin.play.cutscene.dialogue.ScriptedDialogueBox;
/**
* Contains utilities for loading and parsing dialogueBox data.
*/
class DialogueBoxDataParser
{
public static final DIALOGUE_BOX_DATA_VERSION:String = '1.0.0';
public static final DIALOGUE_BOX_DATA_VERSION_RULE:String = '1.0.x';
static final dialogueBoxCache:Map<String, DialogueBox> = new Map<String, DialogueBox>();
static final dialogueBoxScriptedClass:Map<String, String> = new Map<String, String>();
static final DEFAULT_DIALOGUE_BOX_ID:String = 'UNKNOWN';
/**
* Parses and preloads the game's dialogueBox data and scripts when the game starts.
*
* If you want to force dialogue boxes to be reloaded, you can just call this function again.
*/
public static function loadDialogueBoxCache():Void
{
clearDialogueBoxCache();
trace('Loading dialogue box cache...');
//
// SCRIPTED CONVERSATIONS
//
var scriptedDialogueBoxClassNames:Array<String> = ScriptedDialogueBox.listScriptClasses();
trace(' Instantiating ${scriptedDialogueBoxClassNames.length} scripted dialogue boxes...');
for (dialogueBoxCls in scriptedDialogueBoxClassNames)
{
var dialogueBox:DialogueBox = ScriptedDialogueBox.init(dialogueBoxCls, DEFAULT_DIALOGUE_BOX_ID);
if (dialogueBox != null)
{
trace(' Loaded scripted dialogue box: ${dialogueBox.dialogueBoxName}');
// Disable the rendering logic for dialogueBox until it's loaded.
// Note that kill() =/= destroy()
dialogueBox.kill();
// Then store it.
dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox);
}
else
{
trace(' Failed to instantiate scripted dialogueBox class: ${dialogueBoxCls}');
}
}
//
// UNSCRIPTED CONVERSATIONS
//
// Scripts refers to code here, not the actual dialogue.
var dialogueBoxIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/boxes/');
// Filter out dialogue boxes that are scripted.
var unscriptedDialogueBoxIds:Array<String> = dialogueBoxIdList.filter(function(dialogueBoxId:String):Bool {
return !dialogueBoxCache.exists(dialogueBoxId);
});
trace(' Fetching data for ${unscriptedDialogueBoxIds.length} dialogue boxes...');
for (dialogueBoxId in unscriptedDialogueBoxIds)
{
try
{
var dialogueBox:DialogueBox = new DialogueBox(dialogueBoxId);
if (dialogueBox != null)
{
trace(' Loaded dialogueBox data: ${dialogueBox.dialogueBoxName}');
dialogueBoxCache.set(dialogueBox.dialogueBoxId, dialogueBox);
}
}
catch (e)
{
trace(e);
continue;
}
}
}
/**
* Fetches data for a dialogueBox and returns a DialogueBox instance,
* ready to be displayed.
* @param dialogueBoxId The ID of the dialogueBox to fetch.
* @return The dialogueBox instance, or null if the dialogueBox was not found.
*/
public static function fetchDialogueBox(dialogueBoxId:String):Null<DialogueBox>
{
if (dialogueBoxId != null && dialogueBoxId != '' && dialogueBoxCache.exists(dialogueBoxId))
{
trace('Successfully fetched dialogueBox: ${dialogueBoxId}');
var dialogueBox:DialogueBox = dialogueBoxCache.get(dialogueBoxId);
dialogueBox.revive();
return dialogueBox;
}
else
{
trace('Failed to fetch dialogueBox, not found in cache: ${dialogueBoxId}');
return null;
}
}
static function clearDialogueBoxCache():Void
{
if (dialogueBoxCache != null)
{
for (dialogueBox in dialogueBoxCache)
{
dialogueBox.destroy();
}
dialogueBoxCache.clear();
}
}
public static function listDialogueBoxIds():Array<String>
{
return dialogueBoxCache.keys().array();
}
/**
* Load a dialogueBox's JSON file, parse its data, and return it.
*
* @param dialogueBoxId The dialogueBox to load.
* @return The dialogueBox data, or null if validation failed.
*/
public static function parseDialogueBoxData(dialogueBoxId:String):Null<DialogueBoxData>
{
var rawJson:String = loadDialogueBoxFile(dialogueBoxId);
try
{
var dialogueBoxData:DialogueBoxData = DialogueBoxData.fromString(rawJson);
return dialogueBoxData;
}
catch (e)
{
trace('Failed to parse dialogueBox ($dialogueBoxId).');
trace(e);
return null;
}
}
static function loadDialogueBoxFile(dialogueBoxPath:String):String
{
var dialogueBoxFilePath:String = Paths.json('dialogue/boxes/${dialogueBoxPath}');
var rawJson:String = Assets.getText(dialogueBoxFilePath).trim();
while (!rawJson.endsWith('}') && rawJson.length > 0)
{
rawJson = rawJson.substr(0, rawJson.length - 1);
}
return rawJson;
}
}

View file

@ -1,4 +1,10 @@
package funkin.play.cutscene.dialogue;
/**
* A script that can be tied to a Conversation.
* Create a scripted class that extends Conversation to use this.
* This allows you to customize how a specific conversation appears and behaves.
* Someone clever could use this to add branching dialogue I think.
*/
@:hscriptClass
class ScriptedConversation extends Conversation implements polymod.hscript.HScriptedClass {}

View file

@ -1,4 +1,9 @@
package funkin.play.cutscene.dialogue;
/**
* A script that can be tied to a DialogueBox.
* Create a scripted class that extends DialogueBox to use this.
* This allows you to customize how a specific dialogue box appears.
*/
@:hscriptClass
class ScriptedDialogueBox extends DialogueBox implements polymod.hscript.HScriptedClass {}

View file

@ -1,27 +1,30 @@
package funkin.play.cutscene.dialogue;
import flixel.FlxSprite;
import funkin.data.IRegistryEntry;
import funkin.modding.events.ScriptEvent;
import flixel.graphics.frames.FlxFramesCollection;
import funkin.util.assets.FlxAnimationUtil;
import funkin.modding.IScriptedClass.IDialogueScriptedClass;
import funkin.data.dialogue.SpeakerData;
import funkin.data.dialogue.SpeakerRegistry;
/**
* The character sprite which displays during dialogue.
*
* Most conversations have two speakers, with one being flipped.
*/
class Speaker extends FlxSprite implements IDialogueScriptedClass
class Speaker extends FlxSprite implements IDialogueScriptedClass implements IRegistryEntry<SpeakerData>
{
/**
* The internal ID for this speaker.
*/
public final speakerId:String;
public final id:String;
/**
* The full data for a speaker.
*/
var speakerData:SpeakerData;
public final _data:SpeakerData;
/**
* A readable name for this speaker.
@ -30,7 +33,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
function get_speakerName():String
{
return speakerData.name;
return _data.name;
}
/**
@ -75,14 +78,17 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
return globalOffsets = value;
}
public function new(speakerId:String)
public function new(id:String)
{
super();
this.speakerId = speakerId;
this.speakerData = SpeakerDataParser.parseSpeakerData(this.speakerId);
this.id = id;
this._data = _fetchData(id);
if (speakerData == null) throw 'Could not load speaker data for speaker ID "$speakerId"';
if (_data == null)
{
throw 'Could not parse speaker data for id: $id';
}
}
/**
@ -102,18 +108,18 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
function loadSpritesheet():Void
{
trace('[SPEAKER] Loading spritesheet ${speakerData.assetPath} for ${speakerId}');
trace('[SPEAKER] Loading spritesheet ${_data.assetPath} for ${id}');
var tex:FlxFramesCollection = Paths.getSparrowAtlas(speakerData.assetPath);
var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath);
if (tex == null)
{
trace('Could not load Sparrow sprite: ${speakerData.assetPath}');
trace('Could not load Sparrow sprite: ${_data.assetPath}');
return;
}
this.frames = tex;
if (speakerData.isPixel)
if (_data.isPixel)
{
this.antialiasing = false;
}
@ -122,9 +128,10 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
this.antialiasing = true;
}
this.flipX = speakerData.flipX;
this.globalOffsets = speakerData.offsets;
this.setScale(speakerData.scale);
this.flipX = _data.flipX;
this.flipY = _data.flipY;
this.globalOffsets = _data.offsets;
this.setScale(_data.scale);
}
/**
@ -141,11 +148,11 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
function loadAnimations():Void
{
trace('[SPEAKER] Loading ${speakerData.animations.length} animations for ${speakerId}');
trace('[SPEAKER] Loading ${_data.animations.length} animations for ${id}');
FlxAnimationUtil.addAtlasAnimations(this, speakerData.animations);
FlxAnimationUtil.addAtlasAnimations(this, _data.animations);
for (anim in speakerData.animations)
for (anim in _data.animations)
{
if (anim.offsets == null)
{
@ -158,7 +165,7 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
}
var animNames:Array<String> = this.animation.getNameList();
trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${speakerId}');
trace('[SPEAKER] Successfully loaded ${animNames.length} animations for ${id}');
}
/**
@ -271,4 +278,14 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass
}
public function onScriptEvent(event:ScriptEvent):Void {}
public override function toString():String
{
return 'Speaker($id)';
}
static function _fetchData(id:String):Null<SpeakerData>
{
return SpeakerRegistry.instance.parseEntryDataWithMigration(id, SpeakerRegistry.instance.fetchEntryVersion(id));
}
}

View file

@ -1,78 +0,0 @@
package funkin.play.cutscene.dialogue;
import funkin.data.animation.AnimationData;
/**
* Data about a conversation.
* Includes what speakers are in the conversation, and what phrases they say.
*/
@:jsonParse(j -> funkin.play.cutscene.dialogue.SpeakerData.fromJson(j))
@:jsonStringify(v -> v.toJson())
class SpeakerData
{
public var version:String;
public var name:String;
public var assetPath:String;
public var flipX:Bool;
public var isPixel:Bool;
public var offsets:Array<Float>;
public var scale:Float;
public var animations:Array<AnimationData>;
public function new(version:String, name:String, assetPath:String, animations:Array<AnimationData>, ?offsets:Array<Float>, flipX:Bool = false,
isPixel:Bool = false, ?scale:Float = 1.0)
{
this.version = version;
this.name = name;
this.assetPath = assetPath;
this.animations = animations;
this.offsets = offsets;
if (this.offsets == null || this.offsets == []) this.offsets = [0, 0];
this.flipX = flipX;
this.isPixel = isPixel;
this.scale = scale;
}
public static function fromString(i:String):SpeakerData
{
if (i == null || i == '') return null;
var data:
{
version:String,
name:String,
assetPath:String,
animations:Array<AnimationData>,
?offsets:Array<Float>,
?flipX:Bool,
?isPixel:Bool,
?scale:Float
} = tink.Json.parse(i);
return fromJson(data);
}
public static function fromJson(j:Dynamic):SpeakerData
{
// TODO: Check version and perform migrations if necessary.
if (j == null) return null;
return new SpeakerData(j.version, j.name, j.assetPath, j.animations, j.offsets, j.flipX, j.isPixel, j.scale);
}
public function toJson():Dynamic
{
var result:Dynamic =
{
version: this.version,
name: this.name,
assetPath: this.assetPath,
animations: this.animations,
flipX: this.flipX,
isPixel: this.isPixel
};
if (this.scale != 1.0) result.scale = this.scale;
return result;
}
}

View file

@ -1,159 +0,0 @@
package funkin.play.cutscene.dialogue;
import openfl.Assets;
import funkin.util.assets.DataAssets;
import funkin.play.cutscene.dialogue.Speaker;
import funkin.play.cutscene.dialogue.ScriptedSpeaker;
/**
* Contains utilities for loading and parsing speaker data.
*/
class SpeakerDataParser
{
public static final SPEAKER_DATA_VERSION:String = '1.0.0';
public static final SPEAKER_DATA_VERSION_RULE:String = '1.0.x';
static final speakerCache:Map<String, Speaker> = new Map<String, Speaker>();
static final speakerScriptedClass:Map<String, String> = new Map<String, String>();
static final DEFAULT_SPEAKER_ID:String = 'UNKNOWN';
/**
* Parses and preloads the game's speaker data and scripts when the game starts.
*
* If you want to force speakers to be reloaded, you can just call this function again.
*/
public static function loadSpeakerCache():Void
{
clearSpeakerCache();
trace('Loading dialogue speaker cache...');
//
// SCRIPTED CONVERSATIONS
//
var scriptedSpeakerClassNames:Array<String> = ScriptedSpeaker.listScriptClasses();
trace(' Instantiating ${scriptedSpeakerClassNames.length} scripted speakers...');
for (speakerCls in scriptedSpeakerClassNames)
{
var speaker:Speaker = ScriptedSpeaker.init(speakerCls, DEFAULT_SPEAKER_ID);
if (speaker != null)
{
trace(' Loaded scripted speaker: ${speaker.speakerName}');
// Disable the rendering logic for speaker until it's loaded.
// Note that kill() =/= destroy()
speaker.kill();
// Then store it.
speakerCache.set(speaker.speakerId, speaker);
}
else
{
trace(' Failed to instantiate scripted speaker class: ${speakerCls}');
}
}
//
// UNSCRIPTED CONVERSATIONS
//
// Scripts refers to code here, not the actual dialogue.
var speakerIdList:Array<String> = DataAssets.listDataFilesInPath('dialogue/speakers/');
// Filter out speakers that are scripted.
var unscriptedSpeakerIds:Array<String> = speakerIdList.filter(function(speakerId:String):Bool {
return !speakerCache.exists(speakerId);
});
trace(' Fetching data for ${unscriptedSpeakerIds.length} speakers...');
for (speakerId in unscriptedSpeakerIds)
{
try
{
var speaker:Speaker = new Speaker(speakerId);
if (speaker != null)
{
trace(' Loaded speaker data: ${speaker.speakerName}');
speakerCache.set(speaker.speakerId, speaker);
}
}
catch (e)
{
trace(e);
continue;
}
}
}
/**
* Fetches data for a speaker and returns a Speaker instance,
* ready to be displayed.
* @param speakerId The ID of the speaker to fetch.
* @return The speaker instance, or null if the speaker was not found.
*/
public static function fetchSpeaker(speakerId:String):Null<Speaker>
{
if (speakerId != null && speakerId != '' && speakerCache.exists(speakerId))
{
trace('Successfully fetched speaker: ${speakerId}');
var speaker:Speaker = speakerCache.get(speakerId);
speaker.revive();
return speaker;
}
else
{
trace('Failed to fetch speaker, not found in cache: ${speakerId}');
return null;
}
}
static function clearSpeakerCache():Void
{
if (speakerCache != null)
{
for (speaker in speakerCache)
{
speaker.destroy();
}
speakerCache.clear();
}
}
public static function listSpeakerIds():Array<String>
{
return speakerCache.keys().array();
}
/**
* Load a speaker's JSON file, parse its data, and return it.
*
* @param speakerId The speaker to load.
* @return The speaker data, or null if validation failed.
*/
public static function parseSpeakerData(speakerId:String):Null<SpeakerData>
{
var rawJson:String = loadSpeakerFile(speakerId);
try
{
var speakerData:SpeakerData = SpeakerData.fromString(rawJson);
return speakerData;
}
catch (e)
{
trace('Failed to parse speaker ($speakerId).');
trace(e);
return null;
}
}
static function loadSpeakerFile(speakerPath:String):String
{
var speakerFilePath:String = Paths.json('dialogue/speakers/${speakerPath}');
var rawJson:String = Assets.getText(speakerFilePath).trim();
while (!rawJson.endsWith('}') && rawJson.length > 0)
{
rawJson = rawJson.substr(0, rawJson.length - 1);
}
return rawJson;
}
}

View file

@ -135,10 +135,10 @@ class FocusCameraSongEvent extends SongEvent
return new SongEventSchema([
{
name: "char",
title: "Character",
title: "Target",
defaultValue: 0,
type: SongEventFieldType.ENUM,
keys: ["Position" => -1, "Boyfriend" => 0, "Dad" => 1, "Girlfriend" => 2]
keys: ["Position" => -1, "Player" => 0, "Opponent" => 1, "Girlfriend" => 2]
},
{
name: "x",
@ -146,6 +146,7 @@ class FocusCameraSongEvent extends SongEvent
defaultValue: 0,
step: 10.0,
type: SongEventFieldType.FLOAT,
units: "px"
},
{
name: "y",
@ -153,6 +154,7 @@ class FocusCameraSongEvent extends SongEvent
defaultValue: 0,
step: 10.0,
type: SongEventFieldType.FLOAT,
units: "px"
}
]);
}

View file

@ -78,14 +78,16 @@ class SetCameraBopSongEvent extends SongEvent
title: 'Intensity',
defaultValue: 1.0,
step: 0.1,
type: SongEventFieldType.FLOAT
type: SongEventFieldType.FLOAT,
units: 'x'
},
{
name: 'rate',
title: 'Rate (beats/zoom)',
title: 'Rate',
defaultValue: 4,
step: 1,
type: SongEventFieldType.INTEGER,
units: 'beats/zoom'
}
]);
}

View file

@ -106,14 +106,16 @@ class ZoomCameraSongEvent extends SongEvent
title: 'Zoom Level',
defaultValue: 1.0,
step: 0.1,
type: SongEventFieldType.FLOAT
type: SongEventFieldType.FLOAT,
units: 'x'
},
{
name: 'duration',
title: 'Duration (in steps)',
title: 'Duration',
defaultValue: 4.0,
step: 0.5,
type: SongEventFieldType.FLOAT,
units: 'steps'
},
{
name: 'ease',

View file

@ -184,7 +184,8 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
difficulty.characters = metadata.playData.characters;
difficulties.set(diffId, difficulty);
var variationSuffix = (metadata.variation != Constants.DEFAULT_VARIATION) ? '-${metadata.variation}' : '';
difficulties.set('$diffId$variationSuffix', difficulty);
}
}
}
@ -218,13 +219,14 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
for (diffId in chartNotes.keys())
{
// Retrieve the cached difficulty data.
var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
var variationSuffix = (variation != Constants.DEFAULT_VARIATION) ? '-$variation' : '';
var difficulty:Null<SongDifficulty> = difficulties.get('$diffId$variationSuffix');
if (difficulty == null)
{
trace('Fabricated new difficulty for $diffId.');
difficulty = new SongDifficulty(this, diffId, variation);
var metadata = _metadata.get(variation);
difficulties.set(diffId, difficulty);
difficulties.set('$diffId$variationSuffix', difficulty);
if (metadata != null)
{
@ -254,41 +256,80 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
/**
* Retrieve the metadata for a specific difficulty, including the chart if it is loaded.
* @param diffId The difficulty ID, such as `easy` or `hard`.
* @param variation The variation ID to fetch the difficulty for. Or you can use `variations`.
* @param variations A list of variations to fetch the difficulty for. Looks for the first variation that exists.
* @return The difficulty data.
*/
public inline function getDifficulty(?diffId:String):Null<SongDifficulty>
public function getDifficulty(?diffId:String, ?variation:String, ?variations:Array<String>):Null<SongDifficulty>
{
if (diffId == null) diffId = listDifficulties()[0];
if (diffId == null) diffId = listDifficulties(variation)[0];
if (variation == null) variation = Constants.DEFAULT_VARIATION;
if (variations == null) variations = [variation];
return difficulties.get(diffId);
for (currentVariation in variations)
{
var variationSuffix = (currentVariation != Constants.DEFAULT_VARIATION) ? '-$currentVariation' : '';
if (difficulties.exists('$diffId$variationSuffix'))
{
return difficulties.get('$diffId$variationSuffix');
}
}
return null;
}
public function getFirstValidVariation(?diffId:String, ?possibleVariations:Array<String>):Null<String>
{
if (variations == null) possibleVariations = variations;
if (diffId == null) diffId = listDifficulties(null, possibleVariations)[0];
for (variation in variations)
{
if (difficulties.exists('$diffId-$variation')) return variation;
}
return null;
}
/**
* List all the difficulties in this song.
* @param variationId Optionally filter by variation.
* @param variationId Optionally filter by a single variation.
* @param variationIds Optionally filter by multiple variations.
* @return The list of difficulties.
*/
public function listDifficulties(?variationId:String):Array<String>
public function listDifficulties(?variationId:String, ?variationIds:Array<String>):Array<String>
{
if (variationId == '') variationId = null;
if (variationIds == null) variationIds = [];
if (variationId != null) variationIds.push(variationId);
var diffFiltered:Array<String> = difficulties.keys().array().filter(function(diffId:String):Bool {
if (variationId == null) return true;
// The difficulties array contains entries like 'normal', 'nightmare-erect', and 'normal-pico',
// so we have to map it to the actual difficulty names.
// We also filter out difficulties that don't match the variation or that don't exist.
var diffFiltered:Array<String> = difficulties.keys().array().map(function(diffId:String):Null<String> {
var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
if (difficulty == null) return false;
return difficulty.variation == variationId;
});
if (difficulty == null) return null;
if (variationIds.length > 0 && !variationIds.contains(difficulty.variation)) return null;
return difficulty.difficulty;
}).nonNull().unique();
diffFiltered.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_DIFFICULTY_LIST));
return diffFiltered;
}
public function hasDifficulty(diffId:String, ?variationId:String):Bool
public function hasDifficulty(diffId:String, ?variationId:String, ?variationIds:Array<String>):Bool
{
if (variationId == '') variationId = null;
var difficulty:Null<SongDifficulty> = difficulties.get(diffId);
return variationId == null ? (difficulty != null) : (difficulty != null && difficulty.variation == variationId);
if (variationIds == null) variationIds = [];
if (variationId != null) variationIds.push(variationId);
for (targetVariation in variationIds)
{
var variationSuffix = (targetVariation != Constants.DEFAULT_VARIATION) ? '-$targetVariation' : '';
if (difficulties.exists('$diffId$variationSuffix')) return true;
}
return false;
}
/**
@ -445,12 +486,14 @@ class SongDifficulty
{
if (instrumental != '' && characters.altInstrumentals.contains(instrumental))
{
FlxG.sound.cache(Paths.inst(this.song.id, instrumental));
var instId = '-$instrumental';
FlxG.sound.cache(Paths.inst(this.song.id, instId));
}
else
{
// Fallback to default instrumental.
FlxG.sound.cache(Paths.inst(this.song.id, characters.instrumental));
var instId = (characters.instrumental ?? '') != '' ? '-${characters.instrumental}' : '';
FlxG.sound.cache(Paths.inst(this.song.id, instId));
}
}
else
@ -492,7 +535,8 @@ class SongDifficulty
var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
// Automatically resolve voices by removing suffixes.
// For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`.
// For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`.
// Then, check for `Voices-bf-car.ogg`, then `Voices-bf.ogg`.
var playerId:String = characters.player;
var voicePlayer:String = Paths.voices(this.song.id, '-$playerId$suffix');
@ -504,6 +548,19 @@ class SongDifficulty
// Try again.
voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
}
if (voicePlayer == null)
{
// Try again without $suffix.
playerId = characters.player;
voicePlayer = Paths.voices(this.song.id, '-${playerId}');
while (voicePlayer != null && !Assets.exists(voicePlayer))
{
// Remove the last suffix.
playerId = playerId.split('-').slice(0, -1).join('-');
// Try again.
voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
}
}
var opponentId:String = characters.opponent;
var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix');
@ -514,6 +571,19 @@ class SongDifficulty
// Try again.
voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix');
}
if (voiceOpponent == null)
{
// Try again without $suffix.
opponentId = characters.opponent;
voiceOpponent = Paths.voices(this.song.id, '-${opponentId}');
while (voiceOpponent != null && !Assets.exists(voiceOpponent))
{
// Remove the last suffix.
opponentId = opponentId.split('-').slice(0, -1).join('-');
// Try again.
voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix');
}
}
var result:Array<String> = [];
if (voicePlayer != null) result.push(voicePlayer);

View file

@ -1,10 +1,17 @@
package funkin.play.stage;
import openfl.display.BlendMode;
import funkin.graphics.framebuffer.FrameBufferManager;
import flixel.util.FlxColor;
import funkin.graphics.framebuffer.SpriteCopy;
import funkin.graphics.FunkinCamera;
import flixel.FlxCamera;
import flixel.FlxSprite;
import flixel.group.FlxSpriteGroup;
import flixel.math.FlxPoint;
import flixel.system.FlxAssets.FlxShader;
import flixel.util.FlxSort;
import openfl.display.BitmapData;
import flixel.util.FlxColor;
import funkin.modding.IScriptedClass;
import funkin.modding.events.ScriptEvent;
@ -46,6 +53,13 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
return _data?.cameraZoom ?? 1.0;
}
var frameBufferMan:FrameBufferManager;
/**
* The texture that has the mask information. Used for shader effects.
*/
public var maskTexture:BitmapData;
var namedProps:Map<String, StageProp> = new Map<String, StageProp>();
var characters:Map<String, BaseCharacter> = new Map<String, BaseCharacter>();
var boppers:Array<Bopper> = new Array<Bopper>();
@ -75,6 +89,10 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
*/
public function onCreate(event:ScriptEvent):Void
{
if (frameBufferMan != null) frameBufferMan.dispose();
frameBufferMan = new FrameBufferManager(FlxG.camera);
setupFrameBuffers();
buildStage();
this.refresh();
@ -701,6 +719,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
{
debugIconGroup = null;
}
if (frameBufferMan != null)
{
frameBufferMan.dispose();
}
}
/**
@ -724,13 +747,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
}
}
public function onUpdate(event:UpdateScriptEvent)
{
if (FlxG.keys.justPressed.F3)
{
debugIconGroup.visible = !debugIconGroup.visible;
}
}
public function onUpdate(event:UpdateScriptEvent) {}
public override function kill()
{
@ -753,6 +770,53 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
return Sprite;
}
override function draw():Void
{
if (frameBufferMan != null)
{
frameBufferMan.lock();
}
super.draw();
if (frameBufferMan != null)
{
frameBufferMan.unlock();
}
frameBuffersUpdated();
}
/**
* Called when the frame buffer manager is ready.
* Create frame buffers inside this method.
*/
function setupFrameBuffers():Void {}
/**
* Called when all the frame buffers are updated. If you need any
* frame buffers before `grabScreen()`, make sure you
* grab the screen inside this method since it immediately uses the
* frame buffers.
*/
function frameBuffersUpdated():Void {}
/**
* Grabs the current screen and returns it as a bitmap data. You can sefely modify it.
* @param applyFilters if this is `true`, the filters set to the camera will be applied to the resulting bitmap
* @return the grabbed screen
*/
function grabScreen(applyFilters:Bool):BitmapData
{
if (Std.isOfType(FlxG.camera, FunkinCamera))
{
final cam:FunkinCamera = cast FlxG.camera;
return cam.grabScreen(applyFilters);
}
else
{
FlxG.log.error('cannot grab the screen: the main camera is not grabbable');
return null;
}
}
static function _fetchData(id:String):Null<StageData>
{
return StageRegistry.instance.parseEntryDataWithMigration(id, StageRegistry.instance.fetchEntryVersion(id));

View file

@ -74,7 +74,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler
function handleFunctionControls():Void
{
// Emergency exit button.
if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState());
// This can now be used in EVERY STATE YAY!
if (FlxG.keys.justPressed.F5) debug_refreshModules();

View file

@ -16,7 +16,7 @@ import funkin.input.Controls;
/**
* MusicBeatSubState reincorporates the functionality of MusicBeatState into an FlxSubState.
*/
class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandler
class MusicBeatSubState extends FlxSubState implements IEventHandler
{
public var leftWatermarkText:FlxText = null;
public var rightWatermarkText:FlxText = null;
@ -59,7 +59,7 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl
else if (controls.VOLUME_DOWN) FlxG.sound.changeVolume(-0.1);
// Emergency exit button.
if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState());
// This can now be used in EVERY STATE YAY!
if (FlxG.keys.justPressed.F5) debug_refreshModules();

View file

@ -1,103 +0,0 @@
package funkin.ui;
import flixel.FlxCamera;
import flixel.FlxSprite;
import flixel.math.FlxPoint;
import funkin.util.MathUtil;
class SwagCamera extends FlxCamera
{
/**
* properly follow framerate
* most of this just copied from FlxCamera,
* only lines 96 and 97 are changed
*/
override public function updateFollow():Void
{
// Either follow the object closely,
// or double check our deadzone and update accordingly.
if (deadzone == null)
{
target.getMidpoint(_point);
_point.addPoint(targetOffset);
focusOn(_point);
}
else
{
var edge:Float;
var targetX:Float = target.x + targetOffset.x;
var targetY:Float = target.y + targetOffset.y;
if (style == SCREEN_BY_SCREEN)
{
if (targetX >= (scroll.x + width))
{
_scrollTarget.x += width;
}
else if (targetX < scroll.x)
{
_scrollTarget.x -= width;
}
if (targetY >= (scroll.y + height))
{
_scrollTarget.y += height;
}
else if (targetY < scroll.y)
{
_scrollTarget.y -= height;
}
}
else
{
edge = targetX - deadzone.x;
if (_scrollTarget.x > edge)
{
_scrollTarget.x = edge;
}
edge = targetX + target.width - deadzone.x - deadzone.width;
if (_scrollTarget.x < edge)
{
_scrollTarget.x = edge;
}
edge = targetY - deadzone.y;
if (_scrollTarget.y > edge)
{
_scrollTarget.y = edge;
}
edge = targetY + target.height - deadzone.y - deadzone.height;
if (_scrollTarget.y < edge)
{
_scrollTarget.y = edge;
}
}
if ((target is FlxSprite))
{
if (_lastTargetPosition == null)
{
_lastTargetPosition = FlxPoint.get(target.x, target.y); // Creates this point.
}
_scrollTarget.x += (target.x - _lastTargetPosition.x) * followLead.x;
_scrollTarget.y += (target.y - _lastTargetPosition.y) * followLead.y;
_lastTargetPosition.x = target.x;
_lastTargetPosition.y = target.y;
}
if (followLerp >= 60 / FlxG.updateFramerate)
{
scroll.copyFrom(_scrollTarget); // no easing
}
else
{
// THIS THE PART THAT ACTUALLY MATTERS LOL
scroll.x = MathUtil.coolLerp(scroll.x, _scrollTarget.x, followLerp);
scroll.y = MathUtil.coolLerp(scroll.y, _scrollTarget.y, followLerp);
// scroll.x += (_scrollTarget.x - scroll.x) * MathUtil.cameraLerp(followLerp);
// scroll.y += (_scrollTarget.y - scroll.y) * MathUtil.cameraLerp(followLerp);
}
}
}
}

View file

@ -87,12 +87,12 @@ class DebugMenuSubState extends MusicBeatSubState
{
FlxTransitionableState.skipNextTransIn = true;
FlxG.switchState(new ChartEditorState());
FlxG.switchState(() -> new ChartEditorState());
}
function openAnimationEditor()
{
FlxG.switchState(new funkin.ui.debug.anim.DebugBoundingState());
FlxG.switchState(() -> new funkin.ui.debug.anim.DebugBoundingState());
trace('Animation Editor');
}

View file

@ -0,0 +1,191 @@
package funkin.ui.debug;
import flixel.math.FlxRect;
import flixel.FlxSprite;
import flixel.util.FlxColor;
import funkin.audio.FunkinSound;
import funkin.audio.waveform.WaveformData;
import funkin.audio.waveform.WaveformSprite;
import funkin.audio.waveform.WaveformDataParser;
import funkin.graphics.rendering.MeshRender;
class WaveformTestState extends MusicBeatState
{
public function new()
{
super();
}
var waveformData:WaveformData;
var waveformData2:WaveformData;
var waveformAudio:FunkinSound;
// var waveformSprite:WaveformSprite;
// var waveformSprite2:WaveformSprite;
var timeMarker:FlxSprite;
var polygonSprite:MeshRender;
var vertexCount:Int = 3;
public override function create():Void
{
super.create();
var testSprite = new FlxSprite(0, 0);
testSprite.loadGraphic(Paths.image('funkay'));
testSprite.updateHitbox();
testSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height);
// add(testSprite);
waveformAudio = FunkinSound.load(Paths.inst('bopeebo', '-erect'));
waveformData = WaveformDataParser.interpretFlxSound(waveformAudio);
polygonSprite = new MeshRender(FlxG.width / 2, FlxG.height / 2, FlxColor.WHITE);
setPolygonVertices(vertexCount);
add(polygonSprite);
// waveformSprite = WaveformSprite.buildFromWaveformData(waveformData, HORIZONTAL, FlxColor.fromString("#ADD8E6"));
// waveformSprite.duration = 5.0 * 160;
// waveformSprite.width = FlxG.width * 160;
// waveformSprite.height = FlxG.height; // / 2;
// waveformSprite.amplitude = 2.0;
// waveformSprite.minWaveformSize = 25;
// waveformSprite.clipRect = new FlxRect(0, 0, FlxG.width, FlxG.height);
// add(waveformSprite);
//
// waveformSprite2 = WaveformSprite.buildFromWaveformData(waveformData2, HORIZONTAL, FlxColor.fromString("#FF0000"), 5.0);
// waveformSprite2.width = FlxG.width;
// waveformSprite2.height = FlxG.height / 2;
// waveformSprite2.y = FlxG.height / 2;
// add(waveformSprite2);
timeMarker = new FlxSprite(0, FlxG.height * 1 / 6);
timeMarker.makeGraphic(1, Std.int(FlxG.height * 2 / 3), FlxColor.RED);
add(timeMarker);
// drawWaveform(time, duration);
}
public override function update(elapsed:Float):Void
{
super.update(elapsed);
if (FlxG.keys.justPressed.SPACE)
{
if (waveformAudio.isPlaying)
{
waveformAudio.stop();
}
else
{
waveformAudio.play();
}
}
if (FlxG.keys.justPressed.ENTER)
{
// if (waveformSprite.orientation == HORIZONTAL)
// {
// // waveformSprite.orientation = VERTICAL;
// // waveformSprite2.orientation = VERTICAL;
// }
// else
// {
// // waveformSprite.orientation = HORIZONTAL;
// // waveformSprite2.orientation = HORIZONTAL;
// }
}
if (waveformAudio.isPlaying)
{
// waveformSprite takes a time in fractional seconds, not milliseconds.
var timeSeconds = waveformAudio.time / 1000;
// waveformSprite.time = timeSeconds;
// waveformSprite2.time = timeSeconds;
}
if (FlxG.keys.justPressed.UP)
{
vertexCount += 1;
setPolygonVertices(vertexCount);
// waveformSprite.duration += 1.0;
// waveformSprite2.duration += 1.0;
}
if (FlxG.keys.justPressed.DOWN)
{
vertexCount -= 1;
setPolygonVertices(vertexCount);
// waveformSprite.duration -= 1.0;
// waveformSprite2.duration -= 1.0;
}
if (FlxG.keys.justPressed.LEFT)
{
// waveformSprite.time -= 1.0;
// waveformSprite2.time -= 1.0;
}
if (FlxG.keys.justPressed.RIGHT)
{
// waveformSprite.time += 1.0;
// waveformSprite2.time += 1.0;
}
}
function setPolygonVertices(count:Int)
{
polygonSprite.clear();
var size = 100.0;
// Build a polygon with count vertices.
var vertices:Array<Array<Float>> = [];
var angle = 0.0;
for (i in 0...count)
{
var x = Math.cos(angle) * size;
var y = Math.sin(angle) * size;
vertices.push([x, y]);
angle += 2 * Math.PI / count;
}
trace('vertices: ${vertices}');
var centerVertex = polygonSprite.build_vertex(0, 0);
var firstVertex = -1;
var lastVertex = -1;
for (vertex in vertices)
{
var x = vertex[0];
var y = vertex[1];
var newVertex = polygonSprite.build_vertex(x, y);
if (firstVertex == -1)
{
firstVertex = newVertex;
}
if (lastVertex != -1)
{
polygonSprite.add_tri(centerVertex, lastVertex, newVertex);
}
lastVertex = newVertex;
}
polygonSprite.add_tri(centerVertex, lastVertex, firstVertex);
}
public override function destroy():Void
{
super.destroy();
}
}

View file

@ -364,7 +364,7 @@ class DebugBoundingState extends FlxState
if (FlxG.keys.justPressed.H) hudCam.visible = !hudCam.visible;
if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState());
MouseUtil.mouseCamDrag();
MouseUtil.mouseWheelZoom();

View file

@ -7,7 +7,6 @@ import flixel.FlxCamera;
import flixel.FlxSprite;
import flixel.FlxSubState;
import flixel.graphics.FlxGraphic;
import flixel.graphics.FlxGraphic;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.group.FlxSpriteGroup;
import flixel.input.gamepad.FlxGamepadInputID;
@ -28,9 +27,10 @@ import flixel.util.FlxSort;
import flixel.util.FlxTimer;
import funkin.audio.FunkinSound;
import funkin.audio.visualize.PolygonSpectogram;
import funkin.audio.visualize.PolygonSpectogram;
import funkin.audio.visualize.PolygonVisGroup;
import funkin.audio.VoicesGroup;
import funkin.audio.VoicesGroup;
import funkin.audio.waveform.WaveformSprite;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.data.song.SongData.SongCharacterData;
import funkin.data.song.SongData.SongChartData;
@ -41,11 +41,10 @@ import funkin.data.song.SongData.SongOffsets;
import funkin.data.song.SongDataUtils;
import funkin.data.song.SongRegistry;
import funkin.data.stage.StageData;
import funkin.graphics.FunkinCamera;
import funkin.graphics.FunkinSprite;
import funkin.input.Controls.Action;
import funkin.input.Cursor;
import funkin.input.TurboActionHandler;
import funkin.input.TurboButtonHandler;
import funkin.input.TurboKeyHandler;
import funkin.modding.events.ScriptEvent;
import funkin.play.character.BaseCharacter.CharacterType;
@ -59,9 +58,6 @@ import funkin.save.Save;
import funkin.ui.debug.charting.commands.AddEventsCommand;
import funkin.ui.debug.charting.commands.AddNotesCommand;
import funkin.ui.debug.charting.commands.ChartEditorCommand;
import funkin.ui.debug.charting.commands.ChartEditorCommand;
import funkin.ui.debug.charting.commands.ChartEditorCommand;
import funkin.ui.debug.charting.commands.ChartEditorCommand;
import funkin.ui.debug.charting.commands.CopyItemsCommand;
import funkin.ui.debug.charting.commands.CutItemsCommand;
import funkin.ui.debug.charting.commands.DeselectAllItemsCommand;
@ -90,6 +86,8 @@ import funkin.ui.debug.charting.components.ChartEditorSelectionSquareSprite;
import funkin.ui.debug.charting.handlers.ChartEditorShortcutHandler;
import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
import funkin.ui.haxeui.components.CharacterPlayer;
import funkin.ui.haxeui.HaxeUIState;
import funkin.ui.mainmenu.MainMenuState;
@ -117,7 +115,6 @@ import haxe.ui.containers.Grid;
import haxe.ui.containers.HBox;
import haxe.ui.containers.menus.Menu;
import haxe.ui.containers.menus.MenuBar;
import haxe.ui.containers.menus.MenuBar;
import haxe.ui.containers.menus.MenuCheckBox;
import haxe.ui.containers.menus.MenuItem;
import haxe.ui.containers.ScrollView;
@ -128,7 +125,6 @@ import haxe.ui.core.Screen;
import haxe.ui.events.DragEvent;
import haxe.ui.events.MouseEvent;
import haxe.ui.events.UIEvent;
import haxe.ui.events.UIEvent;
import haxe.ui.focus.FocusManager;
import haxe.ui.Toolkit;
import openfl.display.BitmapData;
@ -153,17 +149,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
*/
// ==============================
// Layouts
public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties');
public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
public static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty');
public static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview');
public static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview');
public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
public static final CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:String = Paths.ui('chart-editor/toolbox/offsets');
public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
public static final CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:String = Paths.ui('chart-editor/toolbox/freeplay');
public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties');
// Validation
public static final SUPPORTED_MUSIC_FORMATS:Array<String> = ['ogg'];
public static final SUPPORTED_MUSIC_FORMATS:Array<String> = #if sys ['ogg'] #else ['mp3'] #end;
// Layout
@ -393,13 +391,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
gridTiledSprite.y = -scrollPositionInPixels + (GRID_INITIAL_Y_POS);
measureTicks.y = gridTiledSprite.y;
if (audioVisGroup != null && audioVisGroup.playerVis != null)
for (member in audioWaveforms.members)
{
audioVisGroup.playerVis.y = Math.max(gridTiledSprite.y, GRID_INITIAL_Y_POS - GRID_TOP_PAD);
}
if (audioVisGroup != null && audioVisGroup.opponentVis != null)
{
audioVisGroup.opponentVis.y = Math.max(gridTiledSprite.y, GRID_INITIAL_Y_POS - GRID_TOP_PAD);
member.time = scrollPositionInMs / Constants.MS_PER_SEC;
// Doing this desyncs the waveforms from the grid.
// member.y = Math.max(this.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
}
}
}
@ -507,8 +504,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
function get_playheadPositionInMs():Float
{
if (audioVisGroup != null && audioVisGroup.playerVis != null)
audioVisGroup.playerVis.realtimeStartOffset = -Conductor.instance.getStepTimeInMs(playheadPositionInSteps);
return Conductor.instance.getStepTimeInMs(playheadPositionInSteps);
}
@ -516,7 +511,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
{
playheadPositionInSteps = Conductor.instance.getTimeInSteps(value);
if (audioVisGroup != null && audioVisGroup.playerVis != null) audioVisGroup.playerVis.realtimeStartOffset = -value;
return value;
}
@ -696,6 +690,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
*/
var activeToolboxes:Map<String, CollapsibleDialog> = new Map<String, CollapsibleDialog>();
/**
* The camera component we're using for this state.
*/
var uiCamera:FlxCamera;
// Audio
/**
@ -1180,14 +1179,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
* `null` until vocal track(s) are loaded.
* When switching characters, the elements of the VoicesGroup will be swapped to match the new character.
*/
var audioVocalTrackGroup:Null<VoicesGroup> = null;
var audioVocalTrackGroup:VoicesGroup = new VoicesGroup();
/**
* The audio vis for the inst/vocals.
* The audio waveform visualization for the inst/vocals.
* `null` until vocal track(s) are loaded.
* When switching characters, the elements of the PolygonVisGroup will be swapped to match the new character.
* When switching characters, the elements will be swapped to match the new character.
*/
var audioVisGroup:Null<PolygonVisGroup> = null;
var audioWaveforms:FlxTypedSpriteGroup<WaveformSprite> = new FlxTypedSpriteGroup<WaveformSprite>();
/**
* A map of the audio tracks for each character's vocals.
@ -1370,6 +1369,29 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
return currentSongChartData.events = value;
}
/**
* Convenience property to get the rating for this difficulty in the Freeplay menu.
*/
var currentSongChartDifficultyRating(get, set):Int;
function get_currentSongChartDifficultyRating():Int
{
var result:Null<Int> = currentSongMetadata.playData.ratings.get(selectedDifficulty);
if (result == null)
{
// Initialize to the default value if not set.
currentSongMetadata.playData.ratings.set(selectedDifficulty, 0);
return 0;
}
return result;
}
function set_currentSongChartDifficultyRating(value:Int):Int
{
currentSongMetadata.playData.ratings.set(selectedDifficulty, value);
return value;
}
var currentSongNoteStyle(get, set):String;
function get_currentSongNoteStyle():String
@ -1387,6 +1409,30 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
return currentSongMetadata.playData.noteStyle = value;
}
var currentSongFreeplayPreviewStart(get, set):Int;
function get_currentSongFreeplayPreviewStart():Int
{
return currentSongMetadata.playData.previewStart;
}
function set_currentSongFreeplayPreviewStart(value:Int):Int
{
return currentSongMetadata.playData.previewStart = value;
}
var currentSongFreeplayPreviewEnd(get, set):Int;
function get_currentSongFreeplayPreviewEnd():Int
{
return currentSongMetadata.playData.previewEnd;
}
function set_currentSongFreeplayPreviewEnd(value:Int):Int
{
return currentSongMetadata.playData.previewEnd = value;
}
var currentSongStage(get, set):String;
function get_currentSongStage():String
@ -1520,19 +1566,28 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
return value;
}
var currentVocalOffset(get, set):Float;
var currentVocalOffsetPlayer(get, set):Float;
function get_currentVocalOffset():Float
function get_currentVocalOffsetPlayer():Float
{
// Currently there's only one vocal offset, so we just grab the player's offset since both should be the same.
// Should probably make it so we can set offsets for player + opponent individually, though.
return currentSongOffsets.getVocalOffset(currentPlayerChar);
}
function set_currentVocalOffset(value:Float):Float
function set_currentVocalOffsetPlayer(value:Float):Float
{
// Currently there's only one vocal offset, so we just apply it to both characters.
currentSongOffsets.setVocalOffset(currentPlayerChar, value);
return value;
}
var currentVocalOffsetOpponent(get, set):Float;
function get_currentVocalOffsetOpponent():Float
{
return currentSongOffsets.getVocalOffset(currentOpponentChar);
}
function set_currentVocalOffsetOpponent(value:Float):Float
{
currentSongOffsets.setVocalOffset(currentOpponentChar, value);
return value;
}
@ -1937,11 +1992,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
*/
var notePreviewViewportBitmap:Null<BitmapData> = null;
/**r
/**
* The IMAGE used for the measure ticks. Updated by ChartEditorThemeHandler.
*/
var measureTickBitmap:Null<BitmapData> = null;
/**
* The IMAGE used for the offset ticks. Updated by ChartEditorThemeHandler.
*/
var offsetTickBitmap:Null<BitmapData> = null;
/**
* The tiled sprite used to display the grid.
* The height is the length of the song, and scrolling is done by simply the sprite.
@ -2118,7 +2178,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
loadPreferences();
fixCamera();
uiCamera = new FunkinCamera();
FlxG.cameras.reset(uiCamera);
buildDefaultSongData();
@ -2336,8 +2397,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
// Initialize the song chart data.
songChartData = new Map<String, SongChartData>();
audioVocalTrackGroup = new VoicesGroup();
}
/**
@ -2424,8 +2483,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
add(healthIconBF);
healthIconBF.zIndex = 30;
audioVisGroup = new PolygonVisGroup();
add(audioVisGroup);
add(audioWaveforms);
}
function buildMeasureTicks():Void
@ -2985,13 +3043,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
menubarItemVolumeVocalsPlayer.onChange = event -> {
var volume:Float = event.value.toFloat() / 100.0;
if (audioVocalTrackGroup != null) audioVocalTrackGroup.playerVolume = volume;
audioVocalTrackGroup.playerVolume = volume;
menubarLabelVolumeVocalsPlayer.text = 'Player - ${Std.int(event.value)}%';
};
menubarItemVolumeVocalsOpponent.onChange = event -> {
var volume:Float = event.value.toFloat() / 100.0;
if (audioVocalTrackGroup != null) audioVocalTrackGroup.opponentVolume = volume;
audioVocalTrackGroup.opponentVolume = volume;
menubarLabelVolumeVocalsOpponent.text = 'Enemy - ${Std.int(event.value)}%';
};
@ -3000,7 +3058,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
pitch = Math.floor(pitch / 0.25) * 0.25; // Round to nearest 0.25.
#if FLX_PITCH
if (audioInstTrack != null) audioInstTrack.pitch = pitch;
if (audioVocalTrackGroup != null) audioVocalTrackGroup.pitch = pitch;
audioVocalTrackGroup.pitch = pitch;
#end
var pitchDisplay:Float = Std.int(pitch * 100) / 100; // Round to 2 decimal places.
menubarLabelPlaybackSpeed.text = 'Playback Speed - ${pitchDisplay}x';
@ -3008,8 +3066,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
menubarItemToggleToolboxDifficulty.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value);
menubarItemToggleToolboxMetadata.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value);
menubarItemToggleToolboxOffsets.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT, event.value);
menubarItemToggleToolboxNotes.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value);
menubarItemToggleToolboxEventData.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT, event.value);
menubarItemToggleToolboxFreeplay.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT, event.value);
menubarItemToggleToolboxPlaytestProperties.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT, event.value);
menubarItemToggleToolboxPlayerPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value);
menubarItemToggleToolboxOpponentPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value);
@ -3238,9 +3298,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
handleTestKeybinds();
handleHelpKeybinds();
this.handleGamepadControls();
#if debug
#if (debug || FORCE_DEBUG_VERSION)
handleQuickWatch();
#end
@ -3327,7 +3385,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
Conductor.instance.update(audioInstTrack.time);
handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
// Resync vocals.
if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
{
audioVocalTrackGroup.time = audioInstTrack.time;
}
@ -3345,7 +3403,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
Conductor.instance.update(audioInstTrack.time);
handleHitsounds(oldSongPosition, Conductor.instance.songPosition + Conductor.instance.instrumentalOffset);
// Resync vocals.
if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
{
audioVocalTrackGroup.time = audioInstTrack.time;
}
@ -5356,7 +5414,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
stopWelcomeMusic();
// TODO: PR Flixel to make onComplete nullable.
if (audioInstTrack != null) audioInstTrack.onComplete = null;
FlxG.switchState(new MainMenuState());
FlxG.switchState(() -> new MainMenuState());
resetWindowTitle();
@ -5612,7 +5670,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
Paths.setCurrentLevel('weekend1');
}
subStateClosed.add(fixCamera);
subStateClosed.add(reviveUICamera);
subStateClosed.add(resetConductorAfterTest);
FlxTransitionableState.skipNextTransIn = false;
@ -5622,8 +5680,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
{
targetSong: targetSong,
targetDifficulty: selectedDifficulty,
// TODO: Add this.
// targetCharacter: targetCharacter,
targetVariation: selectedVariation,
practiceMode: playtestPracticeMode,
minimalMode: minimal,
startTimestamp: startTimestamp,
@ -5635,7 +5692,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
{
FlxG.sound.music = audioInstTrack;
}
if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup;
targetState.vocals = audioVocalTrackGroup;
// Kill and replace the UI camera so it doesn't get destroyed during the state transition.
uiCamera.kill();
FlxG.cameras.remove(uiCamera, false);
FlxG.cameras.reset(new FunkinCamera());
this.persistentUpdate = false;
this.persistentDraw = false;
@ -5726,13 +5788,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
}
/**
* Fix a camera issue caused when closing the PlayState used when testing.
* Revive the UI camera and re-establish it as the main camera so UI elements depending on it don't explode.
*/
function fixCamera(_:FlxSubState = null):Void
function reviveUICamera(_:FlxSubState = null):Void
{
FlxG.cameras.reset(new FlxCamera());
FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2));
FlxG.camera.zoom = 1.0;
uiCamera.revive();
FlxG.cameras.reset(uiCamera);
add(this.root);
}
@ -5747,7 +5808,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
if (audioInstTrack != null)
{
audioInstTrack.play(false, audioInstTrack.time);
if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time);
audioVocalTrackGroup.play(false, audioInstTrack.time);
}
playbarPlay.text = '||'; // Pause
@ -5985,7 +6046,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instance.instrumentalOffset;
// Update the songPosition in the Conductor.
Conductor.instance.update(audioInstTrack.time);
if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = audioInstTrack.time;
audioVocalTrackGroup.time = audioInstTrack.time;
}
// We need to update the note sprites because we changed the scroll position.
@ -6206,7 +6267,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
function stopAudioPlayback():Void
{
if (audioInstTrack != null) audioInstTrack.pause();
if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
audioVocalTrackGroup.pause();
playbarPlay.text = '>';
}
@ -6241,7 +6302,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
// Keep the track at the end.
audioInstTrack.time = audioInstTrack.length;
}
if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause();
audioVocalTrackGroup.pause();
};
}
else
@ -6255,12 +6316,32 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
healthIconsDirty = true;
}
function hardRefreshOffsetsToolbox():Void
{
var offsetsToolbox:ChartEditorOffsetsToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
if (offsetsToolbox != null)
{
offsetsToolbox.refreshAudioPreview();
offsetsToolbox.refresh();
}
}
function hardRefreshFreeplayToolbox():Void
{
var freeplayToolbox:ChartEditorFreeplayToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT);
if (freeplayToolbox != null)
{
freeplayToolbox.refreshAudioPreview();
freeplayToolbox.refresh();
}
}
/**
* Clear the voices group.
*/
public function clearVocals():Void
{
if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear();
audioVocalTrackGroup.clear();
}
function isNoteSelected(note:Null<SongNoteData>):Bool
@ -6285,7 +6366,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
// Stop the music.
if (welcomeMusic != null) welcomeMusic.destroy();
if (audioInstTrack != null) audioInstTrack.destroy();
if (audioVocalTrackGroup != null) audioVocalTrackGroup.destroy();
audioVocalTrackGroup.destroy();
}
function applyCanQuickSave():Void

View file

@ -46,26 +46,14 @@ class CopyItemsCommand implements ChartEditorCommand
function performVisuals(state:ChartEditorState):Void
{
var hasNotes:Bool = false;
var hasEvents:Bool = false;
// Wiggle copied notes.
if (state.currentNoteSelection.length > 0)
{
// Display the "Copied Notes" text.
if (state.txtCopyNotif != null)
{
state.txtCopyNotif.visible = true;
state.txtCopyNotif.text = "Copied " + state.currentNoteSelection.length + " notes to clipboard";
state.txtCopyNotif.x = FlxG.mouse.x - (state.txtCopyNotif.width / 2);
state.txtCopyNotif.y = FlxG.mouse.y - 16;
FlxTween.tween(state.txtCopyNotif, {y: state.txtCopyNotif.y - 32}, 0.5,
{
type: FlxTween.ONESHOT,
ease: FlxEase.quadOut,
onComplete: function(_) {
state.txtCopyNotif.visible = false;
}
});
}
hasNotes = true;
// Wiggle the notes.
for (note in state.renderedNotes.members)
{
if (state.isNoteSelected(note.noteData))
@ -91,8 +79,13 @@ class CopyItemsCommand implements ChartEditorCommand
});
}
}
}
// Wiggle copied events.
if (state.currentEventSelection.length > 0)
{
hasEvents = true;
// Wiggle the events.
for (event in state.renderedEvents.members)
{
if (state.isEventSelected(event.eventData))
@ -119,6 +112,39 @@ class CopyItemsCommand implements ChartEditorCommand
}
}
}
// Display the "Copied Notes" text.
if ((hasNotes || hasEvents) && state.txtCopyNotif != null)
{
var copiedString:String = '';
if (hasNotes)
{
var copiedNotes:Int = state.currentNoteSelection.length;
copiedString += '${copiedNotes} note';
if (copiedNotes > 1) copiedString += 's';
if (hasEvents) copiedString += ' and ';
}
if (hasEvents)
{
var copiedEvents:Int = state.currentEventSelection.length;
copiedString += '${state.currentEventSelection.length} event';
if (copiedEvents > 1) copiedString += 's';
}
state.txtCopyNotif.visible = true;
state.txtCopyNotif.text = 'Copied ${copiedString} to clipboard';
state.txtCopyNotif.x = FlxG.mouse.x - (state.txtCopyNotif.width / 2);
state.txtCopyNotif.y = FlxG.mouse.y - 16;
FlxTween.tween(state.txtCopyNotif, {y: state.txtCopyNotif.y - 32}, 0.5,
{
type: FlxTweenType.ONESHOT,
ease: FlxEase.quadOut,
onComplete: function(_) {
state.txtCopyNotif.visible = false;
}
});
}
}
public function undo(state:ChartEditorState):Void

View file

@ -0,0 +1,113 @@
package funkin.ui.debug.charting.commands;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongDataUtils;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
/**
* Command that copies a given set of notes and song events to the clipboard,
* without deleting them from the chart editor.
*/
@:nullSafety
@:access(funkin.ui.debug.charting.ChartEditorState)
class SetAudioOffsetCommand implements ChartEditorCommand
{
var type:AudioOffsetType;
var oldOffset:Float = 0;
var newOffset:Float;
var refreshOffsetsToolbox:Bool;
public function new(type:AudioOffsetType, newOffset:Float, refreshOffsetsToolbox:Bool = true)
{
this.type = type;
this.newOffset = newOffset;
this.refreshOffsetsToolbox = refreshOffsetsToolbox;
}
public function execute(state:ChartEditorState):Void
{
switch (type)
{
case INSTRUMENTAL:
oldOffset = state.currentInstrumentalOffset;
state.currentInstrumentalOffset = newOffset;
// Update rendering.
Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset;
state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset;
case PLAYER:
oldOffset = state.currentVocalOffsetPlayer;
state.currentVocalOffsetPlayer = newOffset;
// Update rendering.
state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
case OPPONENT:
oldOffset = state.currentVocalOffsetOpponent;
state.currentVocalOffsetOpponent = newOffset;
// Update rendering.
state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
}
// Update the offsets toolbox.
if (refreshOffsetsToolbox)
{
state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT);
}
}
public function undo(state:ChartEditorState):Void
{
switch (type)
{
case INSTRUMENTAL:
state.currentInstrumentalOffset = oldOffset;
// Update rendering.
Conductor.instance.instrumentalOffset = state.currentInstrumentalOffset;
state.songLengthInMs = (state.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset;
case PLAYER:
state.currentVocalOffsetPlayer = oldOffset;
// Update rendering.
state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
case OPPONENT:
state.currentVocalOffsetOpponent = oldOffset;
// Update rendering.
state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
}
// Update the offsets toolbox.
state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
}
public function shouldAddToHistory(state:ChartEditorState):Bool
{
// This command is undoable. Add to the history if we actually performed an action.
return (newOffset != oldOffset);
}
public function toString():String
{
switch (type)
{
case INSTRUMENTAL:
return 'Set Inst. Audio Offset to $newOffset';
case PLAYER:
return 'Set Player Audio Offset to $newOffset';
case OPPONENT:
return 'Set Opponent Audio Offset to $newOffset';
}
}
}
enum AudioOffsetType
{
INSTRUMENTAL;
PLAYER;
OPPONENT;
}

View file

@ -0,0 +1,62 @@
package funkin.ui.debug.charting.commands;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongDataUtils;
/**
* Command that sets the start time or end time of the Freeplay preview.
*/
@:nullSafety
@:access(funkin.ui.debug.charting.ChartEditorState)
class SetFreeplayPreviewCommand implements ChartEditorCommand
{
var previousStartTime:Int = 0;
var previousEndTime:Int = 0;
var newStartTime:Null<Int> = null;
var newEndTime:Null<Int> = null;
public function new(newStartTime:Null<Int>, newEndTime:Null<Int>)
{
this.newStartTime = newStartTime;
this.newEndTime = newEndTime;
}
public function execute(state:ChartEditorState):Void
{
this.previousStartTime = state.currentSongFreeplayPreviewStart;
this.previousEndTime = state.currentSongFreeplayPreviewEnd;
if (newStartTime != null) state.currentSongFreeplayPreviewStart = newStartTime;
if (newEndTime != null) state.currentSongFreeplayPreviewEnd = newEndTime;
}
public function undo(state:ChartEditorState):Void
{
state.currentSongFreeplayPreviewStart = previousStartTime;
state.currentSongFreeplayPreviewEnd = previousEndTime;
}
public function shouldAddToHistory(state:ChartEditorState):Bool
{
return (newStartTime != null && newStartTime != previousStartTime) || (newEndTime != null && newEndTime != previousEndTime);
}
public function toString():String
{
var setStart = newStartTime != null && newStartTime != previousStartTime;
var setEnd = newEndTime != null && newEndTime != previousEndTime;
if (setStart && !setEnd)
{
return "Set Freeplay Preview Start Time";
}
else if (setEnd && !setStart)
{
return "Set Freeplay Preview End Time";
}
else
{
return "Set Freeplay Preview Start and End Times";
}
}
}

View file

@ -1,13 +1,16 @@
package funkin.ui.debug.charting.handlers;
import flixel.system.FlxAssets.FlxSoundAsset;
import flixel.system.FlxSound;
import flixel.sound.FlxSound;
import funkin.audio.VoicesGroup;
import funkin.audio.visualize.PolygonVisGroup;
import funkin.audio.FunkinSound;
import funkin.play.character.BaseCharacter.CharacterType;
import funkin.util.FileUtil;
import funkin.util.assets.SoundUtil;
import funkin.audio.waveform.WaveformData;
import funkin.audio.waveform.WaveformDataParser;
import funkin.audio.waveform.WaveformSprite;
import flixel.util.FlxColor;
import haxe.io.Bytes;
import haxe.io.Path;
import openfl.utils.Assets;
@ -125,15 +128,42 @@ class ChartEditorAudioHandler
public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool
{
var perfA = haxe.Timer.stamp();
var result:Bool = playInstrumental(state, instId);
if (!result) return false;
var perfB = haxe.Timer.stamp();
stopExistingVocals(state);
var perfC = haxe.Timer.stamp();
result = playVocals(state, BF, playerId, instId);
var perfD = haxe.Timer.stamp();
// if (!result) return false;
result = playVocals(state, DAD, opponentId, instId);
// if (!result) return false;
var perfE = haxe.Timer.stamp();
state.hardRefreshOffsetsToolbox();
var perfF = haxe.Timer.stamp();
state.hardRefreshFreeplayToolbox();
var perfG = haxe.Timer.stamp();
trace('Switched to instrumental in ${perfB - perfA} seconds.');
trace('Stopped existing vocals in ${perfC - perfB} seconds.');
trace('Played BF vocals in ${perfD - perfC} seconds.');
trace('Played DAD vocals in ${perfE - perfD} seconds.');
trace('Hard refreshed offsets toolbox in ${perfF - perfE} seconds.');
trace('Hard refreshed freeplay toolbox in ${perfG - perfF} seconds.');
return true;
}
@ -144,7 +174,10 @@ class ChartEditorAudioHandler
{
if (instId == '') instId = 'default';
var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId);
var perfA = haxe.Timer.stamp();
var instTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(instTrackData);
var perfB = haxe.Timer.stamp();
trace('Built instrumental track in ${perfB - perfA} seconds.');
if (instTrack == null) return false;
stopExistingInstrumental(state);
@ -172,10 +205,12 @@ class ChartEditorAudioHandler
{
var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId);
var perfStart = haxe.Timer.stamp();
var vocalTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(vocalTrackData);
var perfEnd = haxe.Timer.stamp();
trace('Built vocal track in ${perfEnd - perfStart} seconds.');
if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup();
if (state.audioVisGroup == null) state.audioVisGroup = new PolygonVisGroup();
if (vocalTrack != null)
{
@ -183,26 +218,57 @@ class ChartEditorAudioHandler
{
case BF:
state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
state.audioVisGroup.addPlayerVis(vocalTrack);
state.audioVisGroup.playerVis.x = 885;
state.audioVisGroup.playerVis.realtimeVisLenght = Conductor.instance.getStepTimeInMs(16) * 0.00195;
state.audioVisGroup.playerVis.daHeight = (ChartEditorState.GRID_SIZE) * 16;
state.audioVisGroup.playerVis.detail = 1;
state.audioVisGroup.playerVis.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffset;
var perfStart = haxe.Timer.stamp();
var waveformData:Null<WaveformData> = vocalTrack.waveformData;
var perfEnd = haxe.Timer.stamp();
trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
if (waveformData != null)
{
var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001;
var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE);
waveformSprite.x = 840;
waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16;
waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2;
waveformSprite.time = 0;
waveformSprite.duration = duration;
state.audioWaveforms.add(waveformSprite);
}
else
{
trace('[WARN] Failed to parse waveform data for vocal track.');
}
state.audioVocalTrackGroup.playerVoicesOffset = state.currentVocalOffsetPlayer;
return true;
case DAD:
state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
state.audioVisGroup.addOpponentVis(vocalTrack);
state.audioVisGroup.opponentVis.x = 405;
state.audioVisGroup.opponentVis.realtimeVisLenght = Conductor.instance.getStepTimeInMs(16) * 0.00195;
state.audioVisGroup.opponentVis.daHeight = (ChartEditorState.GRID_SIZE) * 16;
state.audioVisGroup.opponentVis.detail = 1;
state.audioVisGroup.opponentVis.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
var perfStart = haxe.Timer.stamp();
var waveformData:Null<WaveformData> = vocalTrack.waveformData;
var perfEnd = haxe.Timer.stamp();
trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffset;
if (waveformData != null)
{
var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001;
var waveformSprite:WaveformSprite = new WaveformSprite(waveformData, VERTICAL, FlxColor.WHITE);
waveformSprite.x = 360;
waveformSprite.y = Math.max(state.gridTiledSprite?.y ?? 0.0, ChartEditorState.GRID_INITIAL_Y_POS - ChartEditorState.GRID_TOP_PAD);
waveformSprite.height = (ChartEditorState.GRID_SIZE) * 16;
waveformSprite.width = (ChartEditorState.GRID_SIZE) * 2;
waveformSprite.time = 0;
waveformSprite.duration = duration;
state.audioWaveforms.add(waveformSprite);
}
else
{
trace('[WARN] Failed to parse waveform data for vocal track.');
}
state.audioVocalTrackGroup.opponentVoicesOffset = state.currentVocalOffsetOpponent;
return true;
case OTHER:
@ -219,13 +285,10 @@ class ChartEditorAudioHandler
public static function stopExistingVocals(state:ChartEditorState):Void
{
if (state.audioVocalTrackGroup != null)
state.audioVocalTrackGroup.clear();
if (state.audioWaveforms != null)
{
state.audioVocalTrackGroup.clear();
}
if (state.audioVisGroup != null)
{
state.audioVisGroup.clearAllVis();
state.audioWaveforms.clear();
}
}

View file

@ -28,6 +28,8 @@ class ChartEditorImportExportHandler
*/
public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void
{
trace('===============START');
var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
if (song == null) return;
@ -70,39 +72,42 @@ class ChartEditorImportExportHandler
{
state.loadInstFromAsset(Paths.inst(songId, '-$variation'), variation);
}
}
for (difficultyId in song.listDifficulties())
{
var diff:Null<SongDifficulty> = song.getDifficulty(difficultyId);
if (diff == null) continue;
for (difficultyId in song.listDifficulties(variation))
{
var diff:Null<SongDifficulty> = song.getDifficulty(difficultyId, variation);
if (diff == null) continue;
var instId:String = diff.variation == Constants.DEFAULT_VARIATION ? '' : diff.variation;
var voiceList:Array<String> = diff.buildVoiceList(); // SongDifficulty accounts for variation already.
var instId:String = diff.variation == Constants.DEFAULT_VARIATION ? '' : diff.variation;
var voiceList:Array<String> = diff.buildVoiceList(); // SongDifficulty accounts for variation already.
if (voiceList.length == 2)
{
state.loadVocalsFromAsset(voiceList[0], diff.characters.player, instId);
state.loadVocalsFromAsset(voiceList[1], diff.characters.opponent, instId);
}
else if (voiceList.length == 1)
{
state.loadVocalsFromAsset(voiceList[0], diff.characters.player, instId);
}
else
{
trace('[WARN] Strange quantity of voice paths for difficulty ${difficultyId}: ${voiceList.length}');
if (voiceList.length == 2)
{
state.loadVocalsFromAsset(voiceList[0], diff.characters.player, instId);
state.loadVocalsFromAsset(voiceList[1], diff.characters.opponent, instId);
}
else if (voiceList.length == 1)
{
state.loadVocalsFromAsset(voiceList[0], diff.characters.player, instId);
}
else
{
trace('[WARN] Strange quantity of voice paths for difficulty ${difficultyId}: ${voiceList.length}');
}
}
}
state.isHaxeUIDialogOpen = false;
state.currentWorkingFilePath = null; // New file, so no path.
state.switchToCurrentInstrumental();
state.postLoadInstrumental();
state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
state.success('Success', 'Loaded song (${rawSongMetadata[0].songName})');
trace('===============END');
}
/**
@ -132,11 +137,8 @@ class ChartEditorImportExportHandler
state.audioInstTrack.stop();
state.audioInstTrack = null;
}
if (state.audioVocalTrackGroup != null)
{
state.audioVocalTrackGroup.stop();
state.audioVocalTrackGroup.clear();
}
state.audioVocalTrackGroup.stop();
state.audioVocalTrackGroup.clear();
}
/**

View file

@ -82,6 +82,7 @@ class ChartEditorThemeHandler
updateBackground(state);
updateGridBitmap(state);
updateMeasureTicks(state);
updateOffsetTicks(state);
updateSelectionSquare(state);
updateNotePreview(state);
}
@ -231,6 +232,9 @@ class ChartEditorThemeHandler
// Else, gridTiledSprite will be built later.
}
/**
* Vertical measure ticks.
*/
static function updateMeasureTicks(state:ChartEditorState):Void
{
var measureTickWidth:Int = 6;
@ -286,6 +290,59 @@ class ChartEditorThemeHandler
state.measureTickBitmap.fillRect(new Rectangle(0, stepTick16Y, stepTickLength, stepTickWidth), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
}
/**
* Horizontal offset ticks.
*/
static function updateOffsetTicks(state:ChartEditorState):Void
{
var majorTickWidth:Int = 6;
var minorTickWidth:Int = 3;
var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * Conductor.instance.stepsPerMeasure); // 10 minor ticks wide.
var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 grid squares tall.
state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true);
state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK);
// Draw the major ticks.
var leftTickX:Float = 0;
var middleTickX:Float = state.offsetTickBitmap.width / 2 - (majorTickWidth / 2);
var rightTickX:Float = state.offsetTickBitmap.width - (majorTickWidth / 2);
var majorTickLength:Float = state.offsetTickBitmap.height;
state.offsetTickBitmap.fillRect(new Rectangle(leftTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(middleTickX, 0, majorTickWidth, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, majorTickWidth / 2, majorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
// Draw the minor ticks.
var minorTick2X:Float = state.offsetTickBitmap.width * 1 / 10 - (minorTickWidth / 2);
var minorTick3X:Float = state.offsetTickBitmap.width * 2 / 10 - (minorTickWidth / 2);
var minorTick4X:Float = state.offsetTickBitmap.width * 3 / 10 - (minorTickWidth / 2);
var minorTick5X:Float = state.offsetTickBitmap.width * 4 / 10 - (minorTickWidth / 2);
var minorTick7X:Float = state.offsetTickBitmap.width * 6 / 10 - (minorTickWidth / 2);
var minorTick8X:Float = state.offsetTickBitmap.width * 7 / 10 - (minorTickWidth / 2);
var minorTick9X:Float = state.offsetTickBitmap.width * 8 / 10 - (minorTickWidth / 2);
var minorTick10X:Float = state.offsetTickBitmap.width * 9 / 10 - (minorTickWidth / 2);
var minorTickLength:Float = state.offsetTickBitmap.height * 1 / 3;
state.offsetTickBitmap.fillRect(new Rectangle(minorTick2X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(minorTick3X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(minorTick4X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(minorTick5X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(minorTick7X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(minorTick8X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(minorTick9X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
state.offsetTickBitmap.fillRect(new Rectangle(minorTick10X, 0, minorTickWidth, minorTickLength), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
// Draw the offset ticks.
// var ticksWidth:Int = Std.int(ChartEditorState.GRID_SIZE * TOTAL_COLUMN_COUNT); // 1 grid squares wide.
// var ticksHeight:Int = Std.int(ChartEditorState.GRID_SIZE); // 1 measure tall.
// state.offsetTickBitmap = new BitmapData(ticksWidth, ticksHeight, true);
// state.offsetTickBitmap.fillRect(new Rectangle(0, 0, ticksWidth, ticksHeight), GRID_BEAT_DIVIDER_COLOR_DARK);
//
//// Draw the offset ticks.
// state.offsetTickBitmap.fillRect(new Rectangle(0, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
// var rightTickX:Float = state.offsetTickBitmap.width - (offsetTickWidth / 2);
// state.offsetTickBitmap.fillRect(new Rectangle(rightTickX, 0, offsetTickWidth / 2, state.offsetTickBitmap.height), GRID_MEASURE_DIVIDER_COLOR_LIGHT);
}
static function updateSelectionSquare(state:ChartEditorState):Void
{
var selectionSquareBorderColor:FlxColor = switch (state.currentTheme)

View file

@ -35,6 +35,8 @@ import haxe.ui.containers.dialogs.Dialog.DialogButton;
import haxe.ui.containers.dialogs.Dialog.DialogEvent;
import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorMetadataToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorEventDataToolbox;
import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
import haxe.ui.containers.Frame;
@ -89,6 +91,10 @@ class ChartEditorToolboxHandler
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
// TODO: Fix this.
cast(toolbox, ChartEditorBaseToolbox).refresh();
case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:
cast(toolbox, ChartEditorBaseToolbox).refresh();
case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:
cast(toolbox, ChartEditorBaseToolbox).refresh();
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
onShowToolboxPlayerPreview(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@ -124,8 +130,6 @@ class ChartEditorToolboxHandler
onHideToolboxEventData(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:
onHideToolboxPlaytestProperties(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
onHideToolboxMetadata(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
onHideToolboxPlayerPreview(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@ -202,6 +206,10 @@ class ChartEditorToolboxHandler
toolbox = buildToolboxDifficultyLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
toolbox = buildToolboxMetadataLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:
toolbox = buildToolboxOffsetsLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:
toolbox = buildToolboxFreeplayLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
toolbox = buildToolboxPlayerPreviewLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@ -304,8 +312,6 @@ class ChartEditorToolboxHandler
static function onHideToolboxNoteData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
static function onHideToolboxEventData(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
static function onShowToolboxPlaytestProperties(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
@ -373,6 +379,24 @@ class ChartEditorToolboxHandler
return toolbox;
}
static function buildToolboxOffsetsLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
{
var toolbox:ChartEditorBaseToolbox = ChartEditorOffsetsToolbox.build(state);
if (toolbox == null) return null;
return toolbox;
}
static function buildToolboxFreeplayLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
{
var toolbox:ChartEditorBaseToolbox = ChartEditorFreeplayToolbox.build(state);
if (toolbox == null) return null;
return toolbox;
}
static function buildToolboxEventDataLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
{
var toolbox:ChartEditorBaseToolbox = ChartEditorEventDataToolbox.build(state);

View file

@ -18,6 +18,7 @@ import haxe.ui.core.Component;
import funkin.data.event.SongEventRegistry;
import haxe.ui.components.TextField;
import haxe.ui.containers.Box;
import haxe.ui.containers.HBox;
import haxe.ui.containers.Frame;
import haxe.ui.events.UIEvent;
import haxe.ui.data.ArrayDataSource;
@ -214,7 +215,20 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox
input.text = field.type;
}
target.addComponent(input);
// Putting in a box so we can add a unit label easily if there is one.
var inputBox:HBox = new HBox();
inputBox.addComponent(input);
// Add a unit label if applicable.
if (field.units != null && field.units != "")
{
var units:Label = new Label();
units.text = field.units;
units.verticalAlign = "center";
inputBox.addComponent(units);
}
target.addComponent(inputBox);
// Update the value of the event data.
input.onChange = function(event:UIEvent) {

View file

@ -0,0 +1,693 @@
package funkin.ui.debug.charting.toolboxes;
import funkin.audio.SoundGroup;
import haxe.ui.components.Button;
import haxe.ui.components.HorizontalSlider;
import haxe.ui.components.Label;
import flixel.addons.display.FlxTiledSprite;
import flixel.math.FlxMath;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.Slider;
import haxe.ui.backend.flixel.components.SpriteWrapper;
import funkin.ui.debug.charting.commands.SetFreeplayPreviewCommand;
import funkin.ui.haxeui.components.WaveformPlayer;
import funkin.audio.waveform.WaveformDataParser;
import haxe.ui.containers.VBox;
import haxe.ui.containers.Absolute;
import haxe.ui.containers.ScrollView;
import funkin.ui.freeplay.FreeplayState;
import haxe.ui.containers.Frame;
import haxe.ui.core.Screen;
import haxe.ui.events.DragEvent;
import haxe.ui.events.MouseEvent;
import haxe.ui.events.UIEvent;
/**
* The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM.
*/
// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
@:access(funkin.ui.debug.charting.ChartEditorState)
@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/freeplay.xml"))
class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox
{
var waveformContainer:Absolute;
var waveformScrollview:ScrollView;
var waveformMusic:WaveformPlayer;
var freeplayButtonZoomIn:Button;
var freeplayButtonZoomOut:Button;
var freeplayButtonPause:Button;
var freeplayButtonPlay:Button;
var freeplayButtonStop:Button;
var freeplayPreviewStart:NumberStepper;
var freeplayPreviewEnd:NumberStepper;
var freeplayTicksContainer:Absolute;
var playheadSprite:SpriteWrapper;
var previewSelectionSprite:SpriteWrapper;
static final TICK_LABEL_X_OFFSET:Float = 4.0;
static final PLAYHEAD_RIGHT_PAD:Float = 10.0;
static final BASE_SCALE:Float = 64.0;
static final STARTING_SCALE:Float = 1024.0;
static final MIN_SCALE:Float = 4.0;
static final WAVEFORM_ZOOM_MULT:Float = 1.5;
static final MAGIC_SCALE_BASE_TIME:Float = 5.0;
var waveformScale:Float = STARTING_SCALE;
var playheadAbsolutePos(get, set):Float;
function get_playheadAbsolutePos():Float
{
return playheadSprite.left;
}
function set_playheadAbsolutePos(value:Float):Float
{
return playheadSprite.left = value;
}
var playheadRelativePos(get, set):Float;
function get_playheadRelativePos():Float
{
return playheadSprite.left - waveformScrollview.hscrollPos;
}
function set_playheadRelativePos(value:Float):Float
{
return playheadSprite.left = waveformScrollview.hscrollPos + value;
}
var previewBoxStartPosAbsolute(get, set):Float;
function get_previewBoxStartPosAbsolute():Float
{
return previewSelectionSprite.left;
}
function set_previewBoxStartPosAbsolute(value:Float):Float
{
return previewSelectionSprite.left = value;
}
var previewBoxEndPosAbsolute(get, set):Float;
function get_previewBoxEndPosAbsolute():Float
{
return previewSelectionSprite.left + previewSelectionSprite.width;
}
function set_previewBoxEndPosAbsolute(value:Float):Float
{
if (value < previewBoxStartPosAbsolute) return previewSelectionSprite.left = previewBoxStartPosAbsolute;
return previewSelectionSprite.width = value - previewBoxStartPosAbsolute;
}
var previewBoxStartPosRelative(get, set):Float;
function get_previewBoxStartPosRelative():Float
{
return previewSelectionSprite.left - waveformScrollview.hscrollPos;
}
function set_previewBoxStartPosRelative(value:Float):Float
{
return previewSelectionSprite.left = waveformScrollview.hscrollPos + value;
}
var previewBoxEndPosRelative(get, set):Float;
function get_previewBoxEndPosRelative():Float
{
return previewSelectionSprite.left + previewSelectionSprite.width - waveformScrollview.hscrollPos;
}
function set_previewBoxEndPosRelative(value:Float):Float
{
if (value < previewBoxStartPosRelative) return previewSelectionSprite.left = previewBoxStartPosRelative;
return previewSelectionSprite.width = value - previewBoxStartPosRelative;
}
/**
* The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds.
*/
var waveformMagicFactor:Float = 1.0;
var audioPreviewTracks:SoundGroup;
var tickTiledSprite:FlxTiledSprite;
var freeplayPreviewVolume(get, null):Float;
function get_freeplayPreviewVolume():Float
{
return freeplayMusicVolume.value * 2 / 100;
}
var tickLabels:Array<Label> = [];
public function new(chartEditorState2:ChartEditorState)
{
super(chartEditorState2);
initialize();
this.onDialogClosed = onClose;
}
function onClose(event:UIEvent)
{
chartEditorState.menubarItemToggleToolboxFreeplay.selected = false;
}
function initialize():Void
{
// Starting position.
// TODO: Save and load this.
this.x = 150;
this.y = 250;
freeplayMusicVolume.onChange = (_) -> {
setTrackVolume(freeplayPreviewVolume);
};
freeplayMusicMute.onClick = (_) -> {
toggleMuteTrack();
};
freeplayButtonZoomIn.onClick = (_) -> {
zoomWaveformIn();
};
freeplayButtonZoomOut.onClick = (_) -> {
zoomWaveformOut();
};
freeplayButtonPause.onClick = (_) -> {
pauseAudioPreview();
};
freeplayButtonPlay.onClick = (_) -> {
playAudioPreview();
};
freeplayButtonStop.onClick = (_) -> {
stopAudioPreview();
};
testPreview.onClick = (_) -> {
performPreview();
};
freeplayPreviewStart.onChange = (event:UIEvent) -> {
if (event.value == chartEditorState.currentSongFreeplayPreviewStart) return;
if (waveformDragStartPos != null) return; // The values are changing because we are dragging the preview.
chartEditorState.performCommand(new SetFreeplayPreviewCommand(event.value, null));
refresh();
}
freeplayPreviewEnd.onChange = (event:UIEvent) -> {
if (event.value == chartEditorState.currentSongFreeplayPreviewEnd) return;
if (waveformDragStartPos != null) return; // The values are changing because we are dragging the preview.
chartEditorState.performCommand(new SetFreeplayPreviewCommand(null, event.value));
refresh();
}
waveformScrollview.onScroll = (_) -> {
if (!audioPreviewTracks.playing)
{
// Move the playhead if it would go out of view.
var prevPlayheadRelativePos = playheadRelativePos;
playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD);
trace('newPos: ${playheadRelativePos}');
var diff = playheadRelativePos - prevPlayheadRelativePos;
if (diff != 0)
{
// We have to change the song time to match the playhead position when we move it.
var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
var targetSongTimeSeconds:Float = waveformMusic.waveform.waveformData.indexToSeconds(currentWaveformIndex);
audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
}
addOffsetsToAudioPreview();
}
else
{
// The scrollview probably changed because the song position changed.
// If we try to move the song now it will glitch.
}
// Either way, clipRect has changed, so we need to refresh the waveforms.
refresh();
};
initializeTicks();
refreshAudioPreview();
refresh();
refreshTicks();
waveformMusic.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragWaveform();
});
freeplayTicksContainer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragPlayhead();
});
}
function initializeTicks():Void
{
tickTiledSprite = new FlxTiledSprite(chartEditorState.offsetTickBitmap, 100, chartEditorState.offsetTickBitmap.height, true, false);
freeplayTicksSprite.sprite = tickTiledSprite;
tickTiledSprite.width = 5000;
}
/**
* Pull the audio tracks from the chart editor state and create copies of them to play in the Offsets Toolbox.
* These must be DEEP CLONES or else the editor will affect the audio preview!
*/
public function refreshAudioPreview():Void
{
if (audioPreviewTracks == null)
{
audioPreviewTracks = new SoundGroup();
// Make sure audioPreviewTracks (and all its children) receives update() calls.
chartEditorState.add(audioPreviewTracks);
}
else
{
audioPreviewTracks.stop();
audioPreviewTracks.clear();
}
var instTrack = chartEditorState.audioInstTrack.clone();
audioPreviewTracks.add(instTrack);
var playerVoice = chartEditorState.audioVocalTrackGroup.getPlayerVoice();
if (playerVoice != null) audioPreviewTracks.add(playerVoice.clone());
var opponentVoice = chartEditorState.audioVocalTrackGroup.getOpponentVoice();
if (opponentVoice != null) audioPreviewTracks.add(opponentVoice.clone());
// Build player waveform.
// waveformMusic.waveform.forceUpdate = true;
var perfStart = haxe.Timer.stamp();
var waveformData1 = playerVoice.waveformData;
var waveformData2 = opponentVoice?.waveformData ?? playerVoice.waveformData; // this null check is for songs that only have 1 vocals file!
var waveformData3 = chartEditorState.audioInstTrack.waveformData;
var waveformData = waveformData1.merge(waveformData2).merge(waveformData3);
trace('Waveform data merging took: ${haxe.Timer.stamp() - perfStart} seconds');
waveformMusic.waveform.waveformData = waveformData;
// Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
waveformMusic.waveform.duration = instTrack.length / Constants.MS_PER_SEC;
addOffsetsToAudioPreview();
}
public function refreshTicks():Void
{
while (tickLabels.length > 0)
{
var label = tickLabels.pop();
freeplayTicksContainer.removeComponent(label);
}
var labelYPos:Float = chartEditorState.offsetTickBitmap.height / 2;
var labelHeight:Float = chartEditorState.offsetTickBitmap.height / 2;
var numberOfTicks:Int = Math.floor(waveformMusic.waveform.width / chartEditorState.offsetTickBitmap.width * 2) + 1;
for (index in 0...numberOfTicks)
{
var tickPos = chartEditorState.offsetTickBitmap.width / 2 * index;
var tickTime = tickPos * (waveformScale / BASE_SCALE * waveformMagicFactor) / waveformMusic.waveform.waveformData.pointsPerSecond();
var tickLabel:Label = new Label();
tickLabel.text = formatTime(tickTime);
tickLabel.styleNames = "offset-ticks-label";
tickLabel.height = labelHeight;
// Positioning within offsetTicksContainer is absolute (relative to the container itself).
tickLabel.top = labelYPos;
tickLabel.left = tickPos + TICK_LABEL_X_OFFSET;
freeplayTicksContainer.addComponent(tickLabel);
tickLabels.push(tickLabel);
}
}
function formatTime(seconds:Float):String
{
if (seconds <= 0) return "0.0";
var integerSeconds = Math.floor(seconds);
var decimalSeconds = Math.floor((seconds - integerSeconds) * 10);
if (integerSeconds < 60)
{
return '${integerSeconds}.${decimalSeconds}';
}
else
{
var integerMinutes = Math.floor(integerSeconds / 60);
var remainingSeconds = integerSeconds % 60;
var remainingSecondsPad:String = remainingSeconds < 10 ? '0$remainingSeconds' : '$remainingSeconds';
return '${integerMinutes}:${remainingSecondsPad}${decimalSeconds > 0 ? '.$decimalSeconds' : ''}';
}
}
function buildTickLabel():Void {}
public function onStartDragPlayhead():Void
{
Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
movePlayheadToMouse();
}
public function onDragPlayhead(event:MouseEvent):Void
{
movePlayheadToMouse();
}
public function onStopDragPlayhead(event:MouseEvent):Void
{
// Stop dragging.
Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
}
function movePlayheadToMouse():Void
{
// Determine the position of the mouse relative to the
var mouseXPos = FlxG.mouse.x;
var relativeMouseXPos = mouseXPos - waveformScrollview.screenX;
var targetPlayheadPos = relativeMouseXPos + waveformScrollview.hscrollPos;
// Move the playhead to the mouse position.
playheadAbsolutePos = targetPlayheadPos;
// Move the audio preview to the playhead position.
var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
var targetSongTimeSeconds:Float = waveformMusic.waveform.waveformData.indexToSeconds(currentWaveformIndex);
audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
}
var waveformDragStartPos:Null<Float> = null;
var waveformDragPreviewStartPos:Float;
var waveformDragPreviewEndPos:Float;
public function onStartDragWaveform():Void
{
waveformDragStartPos = FlxG.mouse.x;
Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
}
public function onDragWaveform(event:MouseEvent):Void
{
// Set waveformDragPreviewStartPos and waveformDragPreviewEndPos to the position the drag started and the current mouse position.
// This only affects the visuals.
var currentAbsMousePos = FlxG.mouse.x;
var dragDiff = currentAbsMousePos - waveformDragStartPos;
var currentRelativeMousePos = currentAbsMousePos - waveformScrollview.screenX;
var relativeStartPos = waveformDragStartPos - waveformScrollview.screenX;
var isDraggingRight = dragDiff > 0;
var hasDraggedEnough = Math.abs(dragDiff) > 10;
if (hasDraggedEnough)
{
if (isDraggingRight)
{
waveformDragPreviewStartPos = relativeStartPos;
waveformDragPreviewEndPos = currentRelativeMousePos;
}
else
{
waveformDragPreviewStartPos = currentRelativeMousePos;
waveformDragPreviewEndPos = relativeStartPos;
}
}
refresh();
}
public function onStopDragWaveform(event:MouseEvent):Void
{
Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
var previewStartPosAbsolute = waveformDragPreviewStartPos + waveformScrollview.hscrollPos;
var previewStartPosIndex:Int = Std.int(previewStartPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
var previewStartPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewStartPosIndex) * Constants.MS_PER_SEC);
var previewEndPosAbsolute = waveformDragPreviewEndPos + waveformScrollview.hscrollPos;
var previewEndPosIndex:Int = Std.int(previewEndPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
var previewEndPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewEndPosIndex) * Constants.MS_PER_SEC);
chartEditorState.performCommand(new SetFreeplayPreviewCommand(previewStartPosMs, previewEndPosMs));
waveformDragStartPos = null;
waveformDragPreviewStartPos = 0;
waveformDragPreviewEndPos = 0;
refresh();
addOffsetsToAudioPreview();
}
public function playAudioPreview():Void
{
if (isPerformingPreview) stopPerformingPreview();
audioPreviewTracks.volume = freeplayPreviewVolume;
audioPreviewTracks.play(false, audioPreviewTracks.time);
}
public function addOffsetsToAudioPreview():Void
{
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.time -= chartEditorState.currentInstrumentalOffset;
}
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.time -= chartEditorState.currentVocalOffsetPlayer;
}
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.time -= chartEditorState.currentVocalOffsetOpponent;
}
}
public function pauseAudioPreview():Void
{
if (isPerformingPreview) stopPerformingPreview();
audioPreviewTracks.pause();
}
public function stopAudioPreview():Void
{
if (isPerformingPreview) stopPerformingPreview();
audioPreviewTracks.stop();
audioPreviewTracks.time = 0;
waveformScrollview.hscrollPos = 0;
playheadAbsolutePos = 0 + playheadSprite.width;
refresh();
addOffsetsToAudioPreview();
}
public function zoomWaveformIn():Void
{
if (isPerformingPreview) stopPerformingPreview();
if (waveformScale > MIN_SCALE)
{
waveformScale = waveformScale / WAVEFORM_ZOOM_MULT;
if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
trace('Zooming in, scale: ${waveformScale}');
// Update the playhead too!
playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT;
// Recenter the scroll view on the playhead.
var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
refresh();
refreshTicks();
}
else
{
waveformScale = MIN_SCALE;
}
}
public function zoomWaveformOut():Void
{
waveformScale = waveformScale * WAVEFORM_ZOOM_MULT;
if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
trace('Zooming out, scale: ${waveformScale}');
// Update the playhead too!
playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT;
// Recenter the scroll view on the playhead.
var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
refresh();
refreshTicks();
}
public function setTrackVolume(volume:Float):Void
{
audioPreviewTracks.volume = volume;
}
public function muteTrack():Void
{
audioPreviewTracks.muted = true;
}
public function unmuteTrack():Void
{
audioPreviewTracks.muted = false;
}
public function toggleMuteTrack():Void
{
audioPreviewTracks.muted = !audioPreviewTracks.muted;
}
var isPerformingPreview:Bool = false;
var isFadingOutPreview:Bool = false;
public function performPreview():Void
{
isPerformingPreview = true;
isFadingOutPreview = false;
audioPreviewTracks.play(true, chartEditorState.currentSongFreeplayPreviewStart);
audioPreviewTracks.fadeIn(FreeplayState.FADE_IN_DURATION, FreeplayState.FADE_IN_START_VOLUME * freeplayPreviewVolume,
FreeplayState.FADE_IN_END_VOLUME * freeplayPreviewVolume, null);
}
public function stopPerformingPreview():Void
{
isPerformingPreview = false;
isFadingOutPreview = false;
audioPreviewTracks.volume = freeplayPreviewVolume;
audioPreviewTracks.pause();
}
public override function update(elapsed:Float)
{
super.update(elapsed);
if (isPerformingPreview && !audioPreviewTracks.playing)
{
stopPerformingPreview();
}
if (isPerformingPreview && audioPreviewTracks.playing)
{
var startFadeOutTime = chartEditorState.currentSongFreeplayPreviewEnd - (FreeplayState.FADE_OUT_DURATION * Constants.MS_PER_SEC);
trace('startFadeOutTime: ${audioPreviewTracks.time} >= ${startFadeOutTime}');
if (!isFadingOutPreview && audioPreviewTracks.time >= startFadeOutTime)
{
isFadingOutPreview = true;
audioPreviewTracks.fadeOut(FreeplayState.FADE_OUT_DURATION, FreeplayState.FADE_OUT_END_VOLUME * freeplayPreviewVolume, (_) -> {
trace('Stop performing preview! ${audioPreviewTracks.time}');
stopPerformingPreview();
});
}
}
if (audioPreviewTracks.playing)
{
var targetScrollPos:Float = waveformMusic.waveform.waveformData.secondsToIndex(audioPreviewTracks.time / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
// waveformScrollview.hscrollPos = targetScrollPos;
var prevPlayheadAbsolutePos = playheadAbsolutePos;
playheadAbsolutePos = targetScrollPos;
var playheadDiff = playheadAbsolutePos - prevPlayheadAbsolutePos;
// BEHAVIOR C.
// Copy Audacity!
// If the playhead is out of view, jump forward or backward by one screen width until it's in view.
if (playheadAbsolutePos < waveformScrollview.hscrollPos)
{
waveformScrollview.hscrollPos -= waveformScrollview.width;
}
if (playheadAbsolutePos > waveformScrollview.hscrollPos + waveformScrollview.width)
{
waveformScrollview.hscrollPos += waveformScrollview.width;
}
}
freeplayLabelTime.text = formatTime(audioPreviewTracks.time / Constants.MS_PER_SEC);
if (waveformDragStartPos != null && (waveformDragPreviewStartPos > 0 && waveformDragPreviewEndPos > 0))
{
var previewStartPosAbsolute = waveformDragPreviewStartPos + waveformScrollview.hscrollPos;
var previewStartPosIndex:Int = Std.int(previewStartPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
var previewStartPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewStartPosIndex) * Constants.MS_PER_SEC);
var previewEndPosAbsolute = waveformDragPreviewEndPos + waveformScrollview.hscrollPos;
var previewEndPosIndex:Int = Std.int(previewEndPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
var previewEndPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewEndPosIndex) * Constants.MS_PER_SEC);
// Set the values in milliseconds.
freeplayPreviewStart.value = previewStartPosMs;
freeplayPreviewEnd.value = previewEndPosMs;
previewBoxStartPosAbsolute = previewStartPosAbsolute;
previewBoxEndPosAbsolute = previewEndPosAbsolute;
}
else
{
previewBoxStartPosAbsolute = waveformMusic.waveform.waveformData.secondsToIndex(chartEditorState.currentSongFreeplayPreviewStart / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
previewBoxEndPosAbsolute = waveformMusic.waveform.waveformData.secondsToIndex(chartEditorState.currentSongFreeplayPreviewEnd / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
freeplayPreviewStart.value = chartEditorState.currentSongFreeplayPreviewStart;
freeplayPreviewEnd.value = chartEditorState.currentSongFreeplayPreviewEnd;
}
}
public override function refresh():Void
{
super.refresh();
waveformMagicFactor = MAGIC_SCALE_BASE_TIME / (chartEditorState.offsetTickBitmap.width / waveformMusic.waveform.waveformData.pointsPerSecond());
var currentZoomFactor = waveformScale / BASE_SCALE * waveformMagicFactor;
var maxWidth:Int = -1;
waveformMusic.waveform.time = -chartEditorState.currentInstrumentalOffset / Constants.MS_PER_SEC;
waveformMusic.waveform.width = (waveformMusic.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
if (waveformMusic.waveform.width > maxWidth) maxWidth = Std.int(waveformMusic.waveform.width);
waveformMusic.waveform.height = 65;
waveformMusic.waveform.markDirty();
waveformContainer.width = maxWidth;
tickTiledSprite.width = maxWidth;
}
public static function build(chartEditorState:ChartEditorState):ChartEditorFreeplayToolbox
{
return new ChartEditorFreeplayToolbox(chartEditorState);
}
}

View file

@ -35,8 +35,6 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
var buttonCharacterGirlfriend:Button;
var buttonCharacterOpponent:Button;
var inputBPM:NumberStepper;
var inputOffsetInst:NumberStepper;
var inputOffsetVocal:NumberStepper;
var labelScrollSpeed:Label;
var inputScrollSpeed:Slider;
var frameVariation:Frame;
@ -138,25 +136,6 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
chartEditorState.updateTimeSignature();
};
inputOffsetInst.onChange = function(event:UIEvent) {
if (event.value == null) return;
chartEditorState.currentInstrumentalOffset = event.value;
Conductor.instance.instrumentalOffset = event.value;
// Update song length.
chartEditorState.songLengthInMs = (chartEditorState.audioInstTrack?.length ?? 1000.0) + Conductor.instance.instrumentalOffset;
};
inputOffsetVocal.onChange = function(event:UIEvent) {
if (event.value == null) return;
chartEditorState.currentVocalOffset = event.value;
if (chartEditorState.audioVocalTrackGroup != null)
{
chartEditorState.audioVocalTrackGroup.playerVoicesOffset = event.value;
chartEditorState.audioVocalTrackGroup.opponentVoicesOffset = event.value;
}
};
inputScrollSpeed.onChange = function(event:UIEvent) {
var valid:Bool = event.target.value != null && event.target.value > 0;
@ -172,6 +151,10 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
labelScrollSpeed.text = 'Scroll Speed: ${chartEditorState.currentSongChartScrollSpeed}x';
};
inputDifficultyRating.onChange = function(event:UIEvent) {
chartEditorState.currentSongChartDifficultyRating = event.target.value;
};
buttonCharacterOpponent.onClick = function(_) {
chartEditorState.openCharacterDropdown(CharacterType.DAD, false);
};
@ -196,8 +179,7 @@ class ChartEditorMetadataToolbox extends ChartEditorBaseToolbox
inputStage.value = chartEditorState.currentSongMetadata.playData.stage;
inputNoteStyle.value = chartEditorState.currentSongMetadata.playData.noteStyle;
inputBPM.value = chartEditorState.currentSongMetadata.timeChanges[0].bpm;
inputOffsetInst.value = chartEditorState.currentSongMetadata.offsets.getInstrumentalOffset();
inputOffsetVocal.value = chartEditorState.currentSongMetadata.offsets.getVocalOffset(chartEditorState.currentSongMetadata.playData.characters.player);
inputDifficultyRating.value = chartEditorState.currentSongChartDifficultyRating;
inputScrollSpeed.value = chartEditorState.currentSongChartScrollSpeed;
labelScrollSpeed.text = 'Scroll Speed: ${chartEditorState.currentSongChartScrollSpeed}x';
frameVariation.text = 'Variation: ${chartEditorState.selectedVariation.toTitleCase()}';

View file

@ -0,0 +1,850 @@
package funkin.ui.debug.charting.toolboxes;
import funkin.audio.SoundGroup;
import haxe.ui.components.Button;
import haxe.ui.components.HorizontalSlider;
import haxe.ui.components.Label;
import flixel.addons.display.FlxTiledSprite;
import flixel.math.FlxMath;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.Slider;
import haxe.ui.backend.flixel.components.SpriteWrapper;
import funkin.ui.debug.charting.commands.SetAudioOffsetCommand;
import funkin.ui.haxeui.components.WaveformPlayer;
import funkin.audio.waveform.WaveformDataParser;
import haxe.ui.containers.VBox;
import haxe.ui.containers.Absolute;
import haxe.ui.containers.ScrollView;
import haxe.ui.containers.Frame;
import haxe.ui.core.Screen;
import haxe.ui.events.DragEvent;
import haxe.ui.events.MouseEvent;
import haxe.ui.events.UIEvent;
/**
* The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM.
*/
// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
@:access(funkin.ui.debug.charting.ChartEditorState)
@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/offsets.xml"))
class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
{
var waveformContainer:Absolute;
var waveformScrollview:ScrollView;
var waveformPlayer:WaveformPlayer;
var waveformOpponent:WaveformPlayer;
var waveformInstrumental:WaveformPlayer;
var offsetButtonZoomIn:Button;
var offsetButtonZoomOut:Button;
var offsetButtonPause:Button;
var offsetButtonPlay:Button;
var offsetButtonStop:Button;
var offsetStepperPlayer:NumberStepper;
var offsetStepperOpponent:NumberStepper;
var offsetStepperInstrumental:NumberStepper;
var offsetTicksContainer:Absolute;
var playheadSprite:SpriteWrapper;
static final TICK_LABEL_X_OFFSET:Float = 4.0;
static final PLAYHEAD_RIGHT_PAD:Float = 10.0;
static final BASE_SCALE:Float = 64.0;
static final MIN_SCALE:Float = 4.0;
static final WAVEFORM_ZOOM_MULT:Float = 1.5;
static final MAGIC_SCALE_BASE_TIME:Float = 5.0;
var waveformScale:Float = BASE_SCALE;
var playheadAbsolutePos(get, set):Float;
function get_playheadAbsolutePos():Float
{
return playheadSprite.left;
}
function set_playheadAbsolutePos(value:Float):Float
{
return playheadSprite.left = value;
}
var playheadRelativePos(get, set):Float;
function get_playheadRelativePos():Float
{
return playheadSprite.left - waveformScrollview.hscrollPos;
}
function set_playheadRelativePos(value:Float):Float
{
return playheadSprite.left = waveformScrollview.hscrollPos + value;
}
/**
* The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds.
*/
var waveformMagicFactor:Float = 1.0;
var audioPreviewTracks:SoundGroup;
var tickTiledSprite:FlxTiledSprite;
var tickLabels:Array<Label> = [];
// Local store of the audio offsets, so we can detect when they change.
var audioPreviewPlayerOffset:Float = 0;
var audioPreviewOpponentOffset:Float = 0;
var audioPreviewInstrumentalOffset:Float = 0;
public function new(chartEditorState2:ChartEditorState)
{
super(chartEditorState2);
initialize();
this.onDialogClosed = onClose;
}
function onClose(event:UIEvent)
{
chartEditorState.menubarItemToggleToolboxOffsets.selected = false;
}
function initialize():Void
{
// Starting position.
// TODO: Save and load this.
this.x = 150;
this.y = 250;
offsetPlayerVolume.onChange = (_) -> {
var targetVolume = offsetPlayerVolume.value * 2 / 100;
setTrackVolume(PLAYER, targetVolume);
};
offsetPlayerMute.onClick = (_) -> {
toggleMuteTrack(PLAYER);
};
offsetPlayerSolo.onClick = (_) -> {
soloTrack(PLAYER);
};
offsetOpponentVolume.onChange = (_) -> {
var targetVolume = offsetOpponentVolume.value * 2 / 100;
setTrackVolume(OPPONENT, targetVolume);
};
offsetOpponentMute.onClick = (_) -> {
toggleMuteTrack(OPPONENT);
};
offsetOpponentSolo.onClick = (_) -> {
soloTrack(OPPONENT);
};
offsetInstrumentalVolume.onChange = (_) -> {
var targetVolume = offsetInstrumentalVolume.value * 2 / 100;
setTrackVolume(INSTRUMENTAL, targetVolume);
};
offsetInstrumentalMute.onClick = (_) -> {
toggleMuteTrack(INSTRUMENTAL);
};
offsetInstrumentalSolo.onClick = (_) -> {
soloTrack(INSTRUMENTAL);
};
offsetButtonZoomIn.onClick = (_) -> {
zoomWaveformIn();
};
offsetButtonZoomOut.onClick = (_) -> {
zoomWaveformOut();
};
offsetButtonPause.onClick = (_) -> {
pauseAudioPreview();
};
offsetButtonPlay.onClick = (_) -> {
playAudioPreview();
};
offsetButtonStop.onClick = (_) -> {
stopAudioPreview();
};
offsetStepperPlayer.onChange = (event:UIEvent) -> {
if (event.value == chartEditorState.currentVocalOffsetPlayer) return;
if (dragWaveform != null) return;
chartEditorState.performCommand(new SetAudioOffsetCommand(PLAYER, event.value));
refresh();
}
offsetStepperOpponent.onChange = (event:UIEvent) -> {
if (event.value == chartEditorState.currentVocalOffsetOpponent) return;
if (dragWaveform != null) return;
chartEditorState.performCommand(new SetAudioOffsetCommand(OPPONENT, event.value));
refresh();
}
offsetStepperInstrumental.onChange = (event:UIEvent) -> {
if (event.value == chartEditorState.currentInstrumentalOffset) return;
if (dragWaveform != null) return;
chartEditorState.performCommand(new SetAudioOffsetCommand(INSTRUMENTAL, event.value));
refresh();
}
waveformScrollview.onScroll = (_) -> {
if (!audioPreviewTracks.playing)
{
// Move the playhead if it would go out of view.
var prevPlayheadRelativePos = playheadRelativePos;
playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD);
var diff = playheadRelativePos - prevPlayheadRelativePos;
if (diff != 0)
{
// We have to change the song time to match the playhead position when we move it.
var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
var targetSongTimeSeconds:Float = waveformPlayer.waveform.waveformData.indexToSeconds(currentWaveformIndex);
audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
}
addOffsetsToAudioPreview();
}
else
{
// The scrollview probably changed because the song position changed.
// If we try to move the song now it will glitch.
}
// Either way, clipRect has changed, so we need to refresh the waveforms.
refresh();
};
initializeTicks();
refreshAudioPreview();
refresh();
refreshTicks();
waveformPlayer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragWaveform(PLAYER);
});
waveformOpponent.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragWaveform(OPPONENT);
});
waveformInstrumental.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragWaveform(INSTRUMENTAL);
});
offsetTicksContainer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
onStartDragPlayhead();
});
}
function initializeTicks():Void
{
tickTiledSprite = new FlxTiledSprite(chartEditorState.offsetTickBitmap, 100, chartEditorState.offsetTickBitmap.height, true, false);
offsetTicksSprite.sprite = tickTiledSprite;
tickTiledSprite.width = 5000;
}
/**
* Pull the audio tracks from the chart editor state and create copies of them to play in the Offsets Toolbox.
* These must be DEEP CLONES or else the editor will affect the audio preview!
*/
public function refreshAudioPreview():Void
{
if (audioPreviewTracks == null)
{
audioPreviewTracks = new SoundGroup();
// Make sure audioPreviewTracks (and all its children) receives update() calls.
chartEditorState.add(audioPreviewTracks);
}
else
{
audioPreviewTracks.stop();
audioPreviewTracks.clear();
}
var instTrack = chartEditorState.audioInstTrack.clone();
audioPreviewTracks.add(instTrack);
var playerVoice = chartEditorState.audioVocalTrackGroup.getPlayerVoice();
if (playerVoice != null) audioPreviewTracks.add(playerVoice.clone());
var opponentVoice = chartEditorState.audioVocalTrackGroup.getOpponentVoice();
if (opponentVoice != null) audioPreviewTracks.add(opponentVoice.clone());
// Build player waveform.
// waveformPlayer.waveform.forceUpdate = true;
waveformPlayer.waveform.waveformData = playerVoice.waveformData;
// Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
waveformPlayer.waveform.duration = playerVoice.length / Constants.MS_PER_SEC;
// Build opponent waveform.
// waveformOpponent.waveform.forceUpdate = true;
// note: if song only has one set of vocals (Vocals.ogg/mp3) then this is null and crashes charting editor
// so we null check
if (opponentVoice != null)
{
waveformOpponent.waveform.waveformData = opponentVoice.waveformData;
waveformOpponent.waveform.duration = opponentVoice.length / Constants.MS_PER_SEC;
}
// Build instrumental waveform.
// waveformInstrumental.waveform.forceUpdate = true;
waveformInstrumental.waveform.waveformData = chartEditorState.audioInstTrack.waveformData;
waveformInstrumental.waveform.duration = instTrack.length / Constants.MS_PER_SEC;
addOffsetsToAudioPreview();
}
public function refreshTicks():Void
{
while (tickLabels.length > 0)
{
var label = tickLabels.pop();
offsetTicksContainer.removeComponent(label);
}
var labelYPos:Float = chartEditorState.offsetTickBitmap.height / 2;
var labelHeight:Float = chartEditorState.offsetTickBitmap.height / 2;
var numberOfTicks:Int = Math.floor(waveformInstrumental.waveform.width / chartEditorState.offsetTickBitmap.width * 2) + 1;
for (index in 0...numberOfTicks)
{
var tickPos = chartEditorState.offsetTickBitmap.width / 2 * index;
var tickTime = tickPos * (waveformScale / BASE_SCALE * waveformMagicFactor) / waveformInstrumental.waveform.waveformData.pointsPerSecond();
var tickLabel:Label = new Label();
tickLabel.text = formatTime(tickTime);
tickLabel.styleNames = "offset-ticks-label";
tickLabel.height = labelHeight;
// Positioning within offsetTicksContainer is absolute (relative to the container itself).
tickLabel.top = labelYPos;
tickLabel.left = tickPos + TICK_LABEL_X_OFFSET;
offsetTicksContainer.addComponent(tickLabel);
tickLabels.push(tickLabel);
}
}
function formatTime(seconds:Float):String
{
if (seconds <= 0) return "0.0";
var integerSeconds = Math.floor(seconds);
var decimalSeconds = Math.floor((seconds - integerSeconds) * 10);
if (integerSeconds < 60)
{
return '${integerSeconds}.${decimalSeconds}';
}
else
{
var integerMinutes = Math.floor(integerSeconds / 60);
var remainingSeconds = integerSeconds % 60;
var remainingSecondsPad:String = remainingSeconds < 10 ? '0$remainingSeconds' : '$remainingSeconds';
return '${integerMinutes}:${remainingSecondsPad}${decimalSeconds > 0 ? '.$decimalSeconds' : ''}';
}
}
function buildTickLabel():Void {}
public function onStartDragPlayhead():Void
{
Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
movePlayheadToMouse();
}
public function onDragPlayhead(event:MouseEvent):Void
{
movePlayheadToMouse();
}
public function onStopDragPlayhead(event:MouseEvent):Void
{
// Stop dragging.
Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
}
function movePlayheadToMouse():Void
{
// Determine the position of the mouse relative to the
var mouseXPos = FlxG.mouse.x;
var relativeMouseXPos = mouseXPos - waveformScrollview.screenX;
var targetPlayheadPos = relativeMouseXPos + waveformScrollview.hscrollPos;
// Move the playhead to the mouse position.
playheadAbsolutePos = targetPlayheadPos;
// Move the audio preview to the playhead position.
var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
var targetSongTimeSeconds:Float = waveformPlayer.waveform.waveformData.indexToSeconds(currentWaveformIndex);
audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
}
public function onStartDragWaveform(waveform:Waveform):Void
{
dragMousePosition = FlxG.mouse.x;
dragWaveform = waveform;
Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
}
var dragMousePosition:Float = 0;
var dragWaveform:Waveform = null;
var dragOffsetMs:Float = 0;
public function onDragWaveform(event:MouseEvent):Void
{
var newDragMousePosition = FlxG.mouse.x;
var deltaMousePosition = newDragMousePosition - dragMousePosition;
if (deltaMousePosition == 0) return;
var deltaPixels:Float = deltaMousePosition * (waveformScale / BASE_SCALE * waveformMagicFactor);
var deltaMilliseconds:Float = switch (dragWaveform)
{
case PLAYER:
deltaPixels / waveformPlayer.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
case OPPONENT:
deltaPixels / waveformOpponent.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
case INSTRUMENTAL:
deltaPixels / waveformInstrumental.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
};
switch (dragWaveform)
{
case PLAYER:
// chartEditorState.currentVocalOffsetPlayer += deltaMilliseconds;
dragOffsetMs += deltaMilliseconds;
offsetStepperPlayer.value += deltaMilliseconds;
case OPPONENT:
// chartEditorState.currentVocalOffsetOpponent += deltaMilliseconds;
dragOffsetMs += deltaMilliseconds;
offsetStepperOpponent.value += deltaMilliseconds;
case INSTRUMENTAL:
// chartEditorState.currentInstrumentalOffset += deltaMilliseconds;
dragOffsetMs += deltaMilliseconds;
offsetStepperInstrumental.value += deltaMilliseconds;
}
dragMousePosition = newDragMousePosition;
refresh();
}
public function onStopDragWaveform(event:MouseEvent):Void
{
// Stop dragging.
Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
// Apply the offset change after dragging happens.
// We only do this once per drag so we don't get 20 commands a second in the history.
if (dragOffsetMs != 0)
{
// false to not refresh this toolbox, we will manually do that later.
switch (dragWaveform)
{
case PLAYER:
chartEditorState.performCommand(new SetAudioOffsetCommand(PLAYER, chartEditorState.currentVocalOffsetPlayer + dragOffsetMs, false));
case OPPONENT:
chartEditorState.performCommand(new SetAudioOffsetCommand(OPPONENT, chartEditorState.currentVocalOffsetOpponent + dragOffsetMs, false));
case INSTRUMENTAL:
chartEditorState.performCommand(new SetAudioOffsetCommand(INSTRUMENTAL, chartEditorState.currentInstrumentalOffset + dragOffsetMs, false));
}
}
dragOffsetMs = 0;
dragMousePosition = 0;
dragWaveform = null;
refresh();
addOffsetsToAudioPreview();
}
public function playAudioPreview():Void
{
audioPreviewTracks.play(false, audioPreviewTracks.time);
}
public function addOffsetsToAudioPreview():Void
{
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
trackInst.time -= audioPreviewInstrumentalOffset;
}
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
trackPlayer.time -= audioPreviewPlayerOffset;
}
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
trackOpponent.time -= audioPreviewOpponentOffset;
}
}
public function pauseAudioPreview():Void
{
audioPreviewTracks.pause();
}
public function stopAudioPreview():Void
{
audioPreviewTracks.stop();
audioPreviewTracks.time = 0;
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
trackInst.time = -audioPreviewInstrumentalOffset;
}
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
trackPlayer.time = -audioPreviewPlayerOffset;
}
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
trackOpponent.time = -audioPreviewOpponentOffset;
}
waveformScrollview.hscrollPos = 0;
playheadAbsolutePos = 0 + playheadSprite.width;
refresh();
addOffsetsToAudioPreview();
}
public function zoomWaveformIn():Void
{
if (waveformScale > MIN_SCALE)
{
waveformScale = waveformScale / WAVEFORM_ZOOM_MULT;
if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
// Update the playhead too!
playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT;
// Recenter the scroll view on the playhead.
var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
refresh();
refreshTicks();
}
else
{
waveformScale = MIN_SCALE;
}
}
public function zoomWaveformOut():Void
{
waveformScale = waveformScale * WAVEFORM_ZOOM_MULT;
if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
// Update the playhead too!
playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT;
// Recenter the scroll view on the playhead.
var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
refresh();
refreshTicks();
}
public function setTrackVolume(target:Waveform, volume:Float):Void
{
switch (target)
{
case Waveform.INSTRUMENTAL:
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.volume = volume;
}
case Waveform.PLAYER:
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.volume = volume;
}
case Waveform.OPPONENT:
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.volume = volume;
}
}
}
public function muteTrack(target:Waveform):Void
{
switch (target)
{
case Waveform.INSTRUMENTAL:
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.muted = true;
offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
}
case Waveform.PLAYER:
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.muted = true;
offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
}
case Waveform.OPPONENT:
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.muted = true;
offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
}
}
}
public function unmuteTrack(target:Waveform):Void
{
switch (target)
{
case Waveform.INSTRUMENTAL:
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.muted = false;
offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
}
case Waveform.PLAYER:
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.muted = false;
offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
}
case Waveform.OPPONENT:
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.muted = false;
offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
}
}
}
public function toggleMuteTrack(target:Waveform):Void
{
switch (target)
{
case Waveform.INSTRUMENTAL:
var trackInst = audioPreviewTracks.members[0];
if (trackInst != null)
{
trackInst.muted = !trackInst.muted;
offsetInstrumentalMute.text = trackInst.muted ? "Unmute" : "Mute";
}
case Waveform.PLAYER:
var trackPlayer = audioPreviewTracks.members[1];
if (trackPlayer != null)
{
trackPlayer.muted = !trackPlayer.muted;
offsetPlayerMute.text = trackPlayer.muted ? "Unmute" : "Mute";
}
case Waveform.OPPONENT:
var trackOpponent = audioPreviewTracks.members[2];
if (trackOpponent != null)
{
trackOpponent.muted = !trackOpponent.muted;
offsetOpponentMute.text = trackOpponent.muted ? "Unmute" : "Mute";
}
}
}
/**
* Clicking the solo button will unmute the track and mute all other tracks.
* @param target
*/
public function soloTrack(target:Waveform):Void
{
switch (target)
{
case Waveform.PLAYER:
muteTrack(Waveform.OPPONENT);
muteTrack(Waveform.INSTRUMENTAL);
unmuteTrack(Waveform.PLAYER);
case Waveform.OPPONENT:
muteTrack(Waveform.PLAYER);
muteTrack(Waveform.INSTRUMENTAL);
unmuteTrack(Waveform.OPPONENT);
case Waveform.INSTRUMENTAL:
muteTrack(Waveform.PLAYER);
muteTrack(Waveform.OPPONENT);
unmuteTrack(Waveform.INSTRUMENTAL);
}
}
public override function update(elapsed:Float)
{
super.update(elapsed);
if (audioPreviewTracks.playing)
{
trace('Playback time: ${audioPreviewTracks.time}');
var targetScrollPos:Float = waveformInstrumental.waveform.waveformData.secondsToIndex(audioPreviewTracks.time / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
// waveformScrollview.hscrollPos = targetScrollPos;
var prevPlayheadAbsolutePos = playheadAbsolutePos;
playheadAbsolutePos = targetScrollPos;
var playheadDiff = playheadAbsolutePos - prevPlayheadAbsolutePos;
// BEHAVIOR A.
// Just move the scroll view with the playhead, constraining it so that the playhead is always visible.
// waveformScrollview.hscrollPos += playheadDiff;
// waveformScrollview.hscrollPos = FlxMath.bound(waveformScrollview.hscrollPos, playheadAbsolutePos - playheadSprite.width, playheadAbsolutePos);
// BEHAVIOR B.
// Keep `playheadAbsolutePos` within the bounds of the screen.
// The scroll view will eventually move to where the playhead is 1/8th of the way from the left. This looks kinda nice!
// TODO: This causes a hard snap to scroll when the playhead is to the right of the playheadCenterPoint.
// var playheadCenterPoint = waveformScrollview.width / 8;
// waveformScrollview.hscrollPos = FlxMath.bound(waveformScrollview.hscrollPos, playheadAbsolutePos - playheadCenterPoint, playheadAbsolutePos);
// playheadRelativePos = 0;
// BEHAVIOR C.
// Copy Audacity!
// If the playhead is out of view, jump forward or backward by one screen width until it's in view.
if (playheadAbsolutePos < waveformScrollview.hscrollPos)
{
waveformScrollview.hscrollPos -= waveformScrollview.width;
}
if (playheadAbsolutePos > waveformScrollview.hscrollPos + waveformScrollview.width)
{
waveformScrollview.hscrollPos += waveformScrollview.width;
}
}
if (chartEditorState.currentInstrumentalOffset != audioPreviewInstrumentalOffset)
{
var track = audioPreviewTracks.members[0];
if (track != null)
{
track.time += audioPreviewInstrumentalOffset;
track.time -= chartEditorState.currentInstrumentalOffset;
audioPreviewInstrumentalOffset = chartEditorState.currentInstrumentalOffset;
}
}
if (chartEditorState.currentVocalOffsetPlayer != audioPreviewPlayerOffset)
{
var track = audioPreviewTracks.members[1];
if (track != null)
{
track.time += audioPreviewPlayerOffset;
track.time -= chartEditorState.currentVocalOffsetPlayer;
audioPreviewPlayerOffset = chartEditorState.currentVocalOffsetPlayer;
}
}
if (chartEditorState.currentVocalOffsetOpponent != audioPreviewOpponentOffset)
{
var track = audioPreviewTracks.members[2];
if (track != null)
{
track.time += audioPreviewOpponentOffset;
track.time -= chartEditorState.currentVocalOffsetOpponent;
audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
}
}
offsetLabelTime.text = formatTime(audioPreviewTracks.time / Constants.MS_PER_SEC);
// Keep the playhead in view.
// playheadRelativePos = FlxMath.bound(playheadRelativePos, waveformScrollview.hscrollPos + 1,
// Math.min(waveformScrollview.hscrollPos + waveformScrollview.width, waveformContainer.width));
}
public override function refresh():Void
{
super.refresh();
waveformMagicFactor = MAGIC_SCALE_BASE_TIME / (chartEditorState.offsetTickBitmap.width / waveformInstrumental.waveform.waveformData.pointsPerSecond());
var currentZoomFactor = waveformScale / BASE_SCALE * waveformMagicFactor;
var maxWidth:Int = -1;
offsetStepperPlayer.value = chartEditorState.currentVocalOffsetPlayer;
offsetStepperOpponent.value = chartEditorState.currentVocalOffsetOpponent;
offsetStepperInstrumental.value = chartEditorState.currentInstrumentalOffset;
waveformPlayer.waveform.time = -chartEditorState.currentVocalOffsetPlayer / Constants.MS_PER_SEC; // Negative offsets make the song start early.
waveformPlayer.waveform.width = (waveformPlayer.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
if (waveformPlayer.waveform.width > maxWidth) maxWidth = Std.int(waveformPlayer.waveform.width);
waveformPlayer.waveform.height = 65;
waveformOpponent.waveform.time = -chartEditorState.currentVocalOffsetOpponent / Constants.MS_PER_SEC;
waveformOpponent.waveform.width = (waveformOpponent.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
if (waveformOpponent.waveform.width > maxWidth) maxWidth = Std.int(waveformOpponent.waveform.width);
waveformOpponent.waveform.height = 65;
waveformInstrumental.waveform.time = -chartEditorState.currentInstrumentalOffset / Constants.MS_PER_SEC;
waveformInstrumental.waveform.width = (waveformInstrumental.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
if (waveformInstrumental.waveform.width > maxWidth) maxWidth = Std.int(waveformInstrumental.waveform.width);
waveformInstrumental.waveform.height = 65;
// Live update the drag, but don't actually change the underlying offset until we release the mouse to finish dragging.
if (dragWaveform != null) switch (dragWaveform)
{
case PLAYER:
// chartEditorState.currentVocalOffsetPlayer += deltaMilliseconds;
waveformPlayer.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
offsetStepperPlayer.value += dragOffsetMs;
case OPPONENT:
// chartEditorState.currentVocalOffsetOpponent += deltaMilliseconds;
waveformOpponent.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
offsetStepperOpponent.value += dragOffsetMs;
case INSTRUMENTAL:
// chartEditorState.currentInstrumentalOffset += deltaMilliseconds;
waveformInstrumental.waveform.time -= dragOffsetMs / Constants.MS_PER_SEC;
offsetStepperInstrumental.value += dragOffsetMs;
default:
// No drag, no
}
waveformPlayer.waveform.markDirty();
waveformOpponent.waveform.markDirty();
waveformInstrumental.waveform.markDirty();
waveformContainer.width = maxWidth;
tickTiledSprite.width = maxWidth;
}
public static function build(chartEditorState:ChartEditorState):ChartEditorOffsetsToolbox
{
return new ChartEditorOffsetsToolbox(chartEditorState);
}
}
enum Waveform
{
PLAYER;
OPPONENT;
INSTRUMENTAL;
}

View file

@ -1,10 +1,19 @@
package funkin.play.cutscene.dialogue;
package funkin.ui.debug.dialogue;
import flixel.FlxState;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.modding.events.ScriptEvent;
import flixel.util.FlxColor;
import funkin.ui.MusicBeatState;
import funkin.data.dialogue.ConversationData;
import funkin.data.dialogue.ConversationRegistry;
import funkin.data.dialogue.DialogueBoxData;
import funkin.data.dialogue.DialogueBoxRegistry;
import funkin.data.dialogue.SpeakerData;
import funkin.data.dialogue.SpeakerRegistry;
import funkin.play.cutscene.dialogue.Conversation;
import funkin.play.cutscene.dialogue.DialogueBox;
import funkin.play.cutscene.dialogue.Speaker;
/**
* A state with displays a conversation with no background.
@ -27,7 +36,7 @@ class ConversationDebugState extends MusicBeatState
public override function create():Void
{
conversation = ConversationDataParser.fetchConversation(conversationId);
conversation = ConversationRegistry.instance.fetchEntry(conversationId);
conversation.completeCallback = onConversationComplete;
add(conversation);
@ -40,6 +49,12 @@ class ConversationDebugState extends MusicBeatState
conversation = null;
}
public override function dispatchEvent(event:ScriptEvent):Void
{
// Dispatch event to conversation script.
ScriptEventDispatcher.callEvent(conversation, event);
}
public override function update(elapsed:Float):Void
{
super.update(elapsed);

View file

@ -14,6 +14,7 @@ import flixel.group.FlxSpriteGroup;
import flixel.input.touch.FlxTouch;
import flixel.math.FlxAngle;
import flixel.math.FlxMath;
import funkin.graphics.FunkinCamera;
import flixel.math.FlxPoint;
import flixel.system.debug.watch.Tracker.TrackerProfile;
import flixel.text.FlxText;
@ -54,8 +55,51 @@ import funkin.util.MathUtil;
import lime.app.Future;
import lime.utils.Assets;
/**
* Parameters used to initialize the FreeplayState.
*/
typedef FreeplayStateParams =
{
?character:String,
};
class FreeplayState extends MusicBeatSubState
{
//
// Params
//
/**
* The current character for this FreeplayState.
* You can't change this without transitioning to a new FreeplayState.
*/
final currentCharacter:String;
/**
* For the audio preview, the duration of the fade-in effect.
*/
public static final FADE_IN_DURATION:Float = 0.5;
/**
* For the audio preview, the duration of the fade-out effect.
*/
public static final FADE_OUT_DURATION:Float = 0.25;
/**
* For the audio preview, the volume at which the fade-in starts.
*/
public static final FADE_IN_START_VOLUME:Float = 0.25;
/**
* For the audio preview, the volume at which the fade-in ends.
*/
public static final FADE_IN_END_VOLUME:Float = 1.0;
/**
* For the audio preview, the volume at which the fade-out starts.
*/
public static final FADE_OUT_END_VOLUME:Float = 0.0;
var songs:Array<Null<FreeplaySongData>> = [];
var diffIdsCurrent:Array<String> = [];
@ -91,6 +135,8 @@ class FreeplayState extends MusicBeatSubState
var ostName:FlxText;
var difficultyStars:DifficultyStars;
var displayedVariations:Array<String>;
var dj:DJBoyfriend;
var letterSort:LetterSort;
@ -99,12 +145,13 @@ class FreeplayState extends MusicBeatSubState
var stickerSubState:StickerSubState;
//
static var rememberedDifficulty:Null<String> = Constants.DEFAULT_DIFFICULTY;
static var rememberedSongId:Null<String> = null;
public function new(?stickers:StickerSubState = null)
public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
{
currentCharacter = params?.character ?? Constants.DEFAULT_CHARACTER;
if (stickers != null)
{
stickerSubState = stickers;
@ -139,14 +186,18 @@ class FreeplayState extends MusicBeatSubState
isDebug = true;
#end
if (FlxG.sound.music != null)
if (FlxG.sound.music == null || (FlxG.sound.music != null && !FlxG.sound.music.playing))
{
if (!FlxG.sound.music.playing) FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
FlxG.sound.playMusic(Paths.music('freakyMenu/freakyMenu'));
}
// Add a null entry that represents the RANDOM option
songs.push(null);
// TODO: This makes custom variations disappear from Freeplay. Figure out a better solution later.
// Default character (BF) shows default and Erect variations. Pico shows only Pico variations.
displayedVariations = (currentCharacter == "bf") ? [Constants.DEFAULT_VARIATION, "erect"] : [currentCharacter];
// programmatically adds the songs via LevelRegistry and SongRegistry
for (levelId in LevelRegistry.instance.listBaseGameLevelIds())
{
@ -154,9 +205,12 @@ class FreeplayState extends MusicBeatSubState
{
var song:Song = SongRegistry.instance.fetchEntry(songId);
songs.push(new FreeplaySongData(levelId, songId, song));
// Only display songs which actually have available charts for the current character.
var availableDifficultiesForSong = song.listDifficulties(displayedVariations);
if (availableDifficultiesForSong.length == 0) continue;
for (difficulty in song.listDifficulties())
songs.push(new FreeplaySongData(levelId, songId, song, displayedVariations));
for (difficulty in availableDifficultiesForSong)
{
diffIdsTotal.pushUnique(difficulty);
}
@ -275,6 +329,8 @@ class FreeplayState extends MusicBeatSubState
x: -dj.width * 1.6,
speed: 0.5
});
// TODO: Replace this.
if (currentCharacter == "pico") dj.visible = false;
add(dj);
var bgDad:FlxSprite = new FlxSprite(pinkBack.width * 0.75, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad'));
@ -518,7 +574,7 @@ class FreeplayState extends MusicBeatSubState
var swag:Alphabet = new Alphabet(1, 0, "swag");
var funnyCam = new FlxCamera(0, 0, FlxG.width, FlxG.height);
var funnyCam = new FunkinCamera(0, 0, FlxG.width, FlxG.height);
funnyCam.bgColor = FlxColor.TRANSPARENT;
FlxG.cameras.add(funnyCam);
@ -844,6 +900,16 @@ class FreeplayState extends MusicBeatSubState
changeDiff(1);
}
// TODO: DEBUG REMOVE THIS
if (FlxG.keys.justPressed.P)
{
var newParams:FreeplayStateParams =
{
character: currentCharacter == "bf" ? "pico" : "bf",
};
openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(newParams, sticker)));
}
if (controls.BACK && !typing.hasFocus)
{
FlxTween.globalManager.clear();
@ -895,7 +961,7 @@ class FreeplayState extends MusicBeatSubState
}
else
{
FlxG.switchState(new MainMenuState());
FlxG.switchState(() -> new MainMenuState());
}
});
}
@ -1061,13 +1127,7 @@ class FreeplayState extends MusicBeatSubState
return;
}
var targetDifficulty:String = currentDifficulty;
// TODO: Implement Pico into the interface properly.
var targetCharacter:String = 'bf';
if (FlxG.keys.pressed.P)
{
targetCharacter = 'pico';
}
var targetVariation:String = targetSong.getFirstValidVariation(targetDifficulty);
PlayStatePlaylist.campaignId = cap.songData.levelId;
@ -1081,11 +1141,11 @@ class FreeplayState extends MusicBeatSubState
new FlxTimer().start(1, function(tmr:FlxTimer) {
Paths.setCurrentLevel(cap.songData.levelId);
LoadingState.loadAndSwitchState(new PlayState(
LoadingState.loadAndSwitchState(() -> new PlayState(
{
targetSong: targetSong,
targetDifficulty: targetDifficulty,
targetCharacter: targetCharacter,
targetVariation: targetVariation,
}), true);
});
}
@ -1250,31 +1310,33 @@ class FreeplaySongData
public var songRating(default, null):Int = 0;
public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
public var displayedVariations(default, null):Array<String> = [Constants.DEFAULT_VARIATION];
function set_currentDifficulty(value:String):String
{
if (currentDifficulty == value) return value;
currentDifficulty = value;
updateValues();
updateValues(displayedVariations);
return value;
}
public function new(levelId:String, songId:String, song:Song)
public function new(levelId:String, songId:String, song:Song, ?displayedVariations:Array<String>)
{
this.levelId = levelId;
this.songId = songId;
this.song = song;
if (displayedVariations != null) this.displayedVariations = displayedVariations;
updateValues();
updateValues(displayedVariations);
}
function updateValues():Void
function updateValues(displayedVariations:Array<String>):Void
{
this.songDifficulties = song.listDifficulties();
this.songDifficulties = song.listDifficulties(displayedVariations);
if (!this.songDifficulties.contains(currentDifficulty)) currentDifficulty = Constants.DEFAULT_DIFFICULTY;
var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty);
var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, displayedVariations);
if (songDifficulty == null) return;
this.songName = songDifficulty.songName;
this.songCharacter = songDifficulty.characters.opponent;

View file

@ -45,7 +45,7 @@ class HaxeUISubState extends MusicBeatSubState
super.update(elapsed);
// Force quit.
if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState());
if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState());
// Refresh the component.
if (FlxG.keys.justPressed.F5)

View file

@ -0,0 +1,17 @@
package funkin.ui.haxeui.components;
import funkin.audio.waveform.WaveformSprite;
import funkin.audio.waveform.WaveformData;
import haxe.ui.backend.flixel.components.SpriteWrapper;
class WaveformPlayer extends SpriteWrapper
{
public var waveform(default, null):WaveformSprite;
public function new(?waveformData:WaveformData)
{
super();
this.waveform = new WaveformSprite(waveformData);
this.sprite = waveform;
}
}

View file

@ -8,10 +8,12 @@ import flixel.FlxState;
import flixel.addons.transition.FlxTransitionableState;
import flixel.effects.FlxFlicker;
import flixel.graphics.frames.FlxAtlasFrames;
import flixel.util.typeLimit.NextState;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.input.touch.FlxTouch;
import flixel.text.FlxText;
import flixel.tweens.FlxEase;
import funkin.graphics.FunkinCamera;
import flixel.tweens.FlxTween;
import funkin.ui.MusicBeatState;
import flixel.util.FlxTimer;
@ -94,7 +96,7 @@ class MainMenuState extends MusicBeatState
});
menuItems.enabled = true; // can move on intro
createMenuItem('storymode', 'mainmenu/storymode', function() startExitState(new StoryMenuState()));
createMenuItem('storymode', 'mainmenu/storymode', function() startExitState(() -> new StoryMenuState()));
createMenuItem('freeplay', 'mainmenu/freeplay', function() {
persistentDraw = true;
persistentUpdate = false;
@ -110,7 +112,7 @@ class MainMenuState extends MusicBeatState
#end
createMenuItem('options', 'mainmenu/options', function() {
startExitState(new funkin.ui.options.OptionsState());
startExitState(() -> new funkin.ui.options.OptionsState());
});
// Reset position of menu items.
@ -151,7 +153,7 @@ class MainMenuState extends MusicBeatState
function resetCamStuff()
{
FlxG.cameras.reset(new SwagCamera());
FlxG.cameras.reset(new FunkinCamera());
FlxG.camera.follow(camFollow, null, 0.06);
}
@ -255,7 +257,7 @@ class MainMenuState extends MusicBeatState
openSubState(prompt);
}
function startExitState(state:FlxState)
function startExitState(state:NextState)
{
menuItems.enabled = false; // disable for exit
var duration = 0.4;
@ -313,7 +315,7 @@ class MainMenuState extends MusicBeatState
if (controls.BACK && menuItems.enabled && !menuItems.busy)
{
FlxG.sound.play(Paths.sound('cancelMenu'));
FlxG.switchState(new TitleState());
FlxG.switchState(() -> new TitleState());
}
}
}

View file

@ -4,6 +4,7 @@ import funkin.util.InputUtil;
import flixel.FlxCamera;
import flixel.FlxObject;
import flixel.FlxSprite;
import funkin.graphics.FunkinCamera;
import flixel.group.FlxGroup;
import flixel.input.actions.FlxActionInput;
import flixel.input.gamepad.FlxGamepadInputID;
@ -47,7 +48,7 @@ class ControlsMenu extends funkin.ui.options.OptionsState.Page
{
super();
menuCamera = new FlxCamera();
menuCamera = new FunkinCamera();
FlxG.cameras.add(menuCamera, false);
menuCamera.bgColor = 0x0;
camera = menuCamera;

View file

@ -103,7 +103,7 @@ class OptionsState extends MusicBeatState
{
currentPage.enabled = false;
// TODO: Animate this transition?
FlxG.switchState(new MainMenuState());
FlxG.switchState(() -> new MainMenuState());
}
}

View file

@ -6,6 +6,7 @@ import flixel.FlxSprite;
import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
import funkin.ui.AtlasText.AtlasFont;
import funkin.ui.options.OptionsState.Page;
import funkin.graphics.FunkinCamera;
import funkin.ui.TextMenuList.TextMenuItem;
class PreferencesMenu extends Page
@ -20,7 +21,7 @@ class PreferencesMenu extends Page
{
super();
menuCamera = new SwagCamera();
menuCamera = new FunkinCamera();
FlxG.cameras.add(menuCamera, false);
menuCamera.bgColor = 0x0;
camera = menuCamera;

View file

@ -150,7 +150,8 @@ class Level implements IRegistryEntry<LevelData>
if (firstSong != null)
{
for (difficulty in firstSong.listDifficulties())
// Don't display alternate characters in Story Mode.
for (difficulty in firstSong.listDifficulties([Constants.DEFAULT_VARIATION, "erect"]))
{
difficulties.push(difficulty);
}
@ -168,7 +169,7 @@ class Level implements IRegistryEntry<LevelData>
for (difficulty in difficulties)
{
if (!song.hasDifficulty(difficulty))
if (!song.hasDifficulty(difficulty, [Constants.DEFAULT_VARIATION, "erect"]))
{
difficulties.remove(difficulty);
}

View file

@ -397,7 +397,7 @@ class StoryMenuState extends MusicBeatState
{
FlxG.sound.play(Paths.sound('cancelMenu'));
exitingMenu = true;
FlxG.switchState(new MainMenuState());
FlxG.switchState(() -> new MainMenuState());
}
}
@ -565,7 +565,7 @@ class StoryMenuState extends MusicBeatState
FlxTransitionableState.skipNextTransIn = false;
FlxTransitionableState.skipNextTransOut = false;
LoadingState.loadAndSwitchState(new PlayState(
LoadingState.loadAndSwitchState(() -> new PlayState(
{
targetSong: targetSong,
targetDifficulty: PlayStatePlaylist.campaignDifficulty,

View file

@ -105,6 +105,6 @@ class AttractState extends MusicBeatState
vid.destroy();
vid = null;
FlxG.switchState(new TitleState());
FlxG.switchState(() -> new TitleState());
}
}

View file

@ -39,7 +39,7 @@ class OutdatedSubState extends MusicBeatState
if (controls.BACK)
{
leftState = true;
FlxG.switchState(new MainMenuState());
FlxG.switchState(() -> new MainMenuState());
}
super.update(elapsed);
}

View file

@ -9,6 +9,7 @@ import flixel.tweens.FlxTween;
import flixel.util.FlxColor;
import flixel.util.FlxDirectionFlags;
import flixel.util.FlxTimer;
import flixel.util.typeLimit.NextState;
import funkin.audio.visualize.SpectogramSprite;
import funkin.graphics.shaders.ColorSwap;
import funkin.graphics.shaders.LeftMaskShader;
@ -213,7 +214,7 @@ class TitleState extends MusicBeatState
*/
function moveToAttract():Void
{
FlxG.switchState(new AttractState());
FlxG.switchState(() -> new AttractState());
}
function playMenuMusic():Void
@ -294,7 +295,7 @@ class TitleState extends MusicBeatState
{
if (touch.justPressed)
{
FlxG.switchState(new FreeplayState());
FlxG.switchState(() -> new FreeplayState());
pressedEnter = true;
}
}
@ -313,7 +314,7 @@ class TitleState extends MusicBeatState
// If you spam Enter, we should skip the transition.
if (pressedEnter && transitioning && skippedIntro)
{
FlxG.switchState(new MainMenuState());
FlxG.switchState(() -> new MainMenuState());
}
if (pressedEnter && !transitioning && skippedIntro)
@ -328,7 +329,7 @@ class TitleState extends MusicBeatState
FlxG.sound.play(Paths.sound('confirmMenu'), 0.7);
transitioning = true;
var targetState:FlxState = new MainMenuState();
var targetState:NextState = () -> new MainMenuState();
new FlxTimer().start(2, function(tmr:FlxTimer) {
// These assets are very unlikely to be used for the rest of gameplay, so it unloads them from cache/memory

View file

@ -1,7 +1,6 @@
package funkin.ui.transition;
import flixel.FlxSprite;
import flixel.FlxState;
import flixel.math.FlxMath;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
@ -21,12 +20,13 @@ import lime.utils.AssetManifest;
import lime.utils.Assets as LimeAssets;
import openfl.filters.ShaderFilter;
import openfl.utils.Assets;
import flixel.util.typeLimit.NextState;
class LoadingState extends MusicBeatState
{
inline static var MIN_TIME = 1.0;
var target:FlxState;
var target:NextState;
var stopMusic = false;
var callbacks:MultiCallback;
var danceLeft = false;
@ -34,7 +34,7 @@ class LoadingState extends MusicBeatState
var loadBar:FlxSprite;
var funkay:FlxSprite;
function new(target:FlxState, stopMusic:Bool)
function new(target:NextState, stopMusic:Bool)
{
super();
this.target = target;
@ -172,12 +172,12 @@ class LoadingState extends MusicBeatState
return Paths.inst(PlayState.instance.currentSong.id);
}
inline static public function loadAndSwitchState(nextState:FlxState, shouldStopMusic = false):Void
inline static public function loadAndSwitchState(nextState:NextState, shouldStopMusic = false):Void
{
FlxG.switchState(getNextState(nextState, shouldStopMusic));
}
static function getNextState(nextState:FlxState, shouldStopMusic = false):FlxState
static function getNextState(nextState:NextState, shouldStopMusic = false):NextState
{
Paths.setCurrentLevel(PlayStatePlaylist.campaignId);
@ -186,7 +186,7 @@ class LoadingState extends MusicBeatState
// && (!PlayState.currentSong.needsVoices || isSoundLoaded(getVocalPath()))
// && isLibraryLoaded('shared');
//
if (true) return new LoadingState(nextState, shouldStopMusic);
if (true) return () -> new LoadingState(nextState, shouldStopMusic);
#end
if (shouldStopMusic && FlxG.sound.music != null) FlxG.sound.music.stop();
@ -332,7 +332,7 @@ class MultiCallback
public function getUnfired():Array<Void->Void>
return unfired.array();
public static function coolSwitchState(state:FlxState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2)
public static function coolSwitchState(state:NextState, transitionTex:String = "shaderTransitionStuff/coolDots", time:Float = 2)
{
var screenShit:FlxSprite = new FlxSprite().loadGraphic(Paths.image("shaderTransitionStuff/coolDots"));
var screenWipeShit:ScreenWipeShader = new ScreenWipeShader();
@ -343,9 +343,9 @@ class MultiCallback
ease: FlxEase.quadInOut,
onComplete: function(twn) {
screenShit.destroy();
FlxG.switchState(new MainMenuState());
FlxG.switchState(state);
}
});
FlxG.camera.setFilters([new ShaderFilter(screenWipeShit)]);
FlxG.camera.filters = [new ShaderFilter(screenWipeShit)];
}
}

Some files were not shown because too many files have changed in this diff Show more