mirror of
https://github.com/FunkinCrew/Funkin.git
synced 2025-03-24 05:39:50 -04:00
Refactor InitState plus fix a couple crash bugs
This commit is contained in:
parent
4c85e59037
commit
6d5c5f5acb
5 changed files with 224 additions and 207 deletions
source
|
@ -77,17 +77,6 @@ class Main extends Sprite
|
|||
* -Eric
|
||||
*/
|
||||
|
||||
#if !debug
|
||||
/**
|
||||
* Someone was like "hey let's make a state that only runs code on debug builds"
|
||||
* then put essential initialization code in it.
|
||||
* The easiest fix is to make it run in all builds.
|
||||
* -Eric
|
||||
*/
|
||||
// TODO: Fix this properly.
|
||||
// initialState = funkin.TitleState;
|
||||
#end
|
||||
|
||||
initHaxeUI();
|
||||
|
||||
addChild(new FlxGame(gameWidth, gameHeight, initialState, framerate, framerate, skipSplash, startFullscreen));
|
||||
|
|
|
@ -6,52 +6,82 @@ import flixel.addons.transition.TransitionData;
|
|||
import flixel.graphics.FlxGraphic;
|
||||
import flixel.math.FlxPoint;
|
||||
import flixel.math.FlxRect;
|
||||
import flixel.FlxSprite;
|
||||
import flixel.system.debug.log.LogStyle;
|
||||
import flixel.util.FlxColor;
|
||||
import funkin.modding.module.ModuleHandler;
|
||||
import funkin.play.character.CharacterData.CharacterDataParser;
|
||||
import funkin.play.cutscene.dialogue.ConversationDataParser;
|
||||
import funkin.play.cutscene.dialogue.DialogueBoxDataParser;
|
||||
import funkin.play.cutscene.dialogue.SpeakerDataParser;
|
||||
import funkin.play.event.SongEventData.SongEventParser;
|
||||
import funkin.play.PlayState;
|
||||
import funkin.play.song.SongData.SongDataParser;
|
||||
import funkin.play.stage.StageData.StageDataParser;
|
||||
import funkin.ui.PreferencesMenu;
|
||||
import funkin.util.macro.MacroUtil;
|
||||
import funkin.util.WindowUtil;
|
||||
import funkin.play.PlayStatePlaylist;
|
||||
import openfl.display.BitmapData;
|
||||
#if discord_rpc
|
||||
import Discord.DiscordClient;
|
||||
#end
|
||||
|
||||
/**
|
||||
* Initializes the game state using custom defines.
|
||||
* Only used in Debug builds.
|
||||
* The initialization state has several functions:
|
||||
* - Calls code to set up the game, including loading saves and parsing game data.
|
||||
* - Chooses whether to start via debug or via launching normally.
|
||||
*/
|
||||
class InitState extends FlxTransitionableState
|
||||
{
|
||||
override public function create():Void
|
||||
/**
|
||||
* Perform a bunch of game setup, then immediately transition to the title screen.
|
||||
*/
|
||||
public override function create():Void
|
||||
{
|
||||
setupShit();
|
||||
|
||||
loadSaveData();
|
||||
|
||||
startGame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a bunch of important Flixel stuff.
|
||||
*/
|
||||
function setupShit()
|
||||
{
|
||||
//
|
||||
// FLIXEL SETUP
|
||||
// GAME SETUP
|
||||
//
|
||||
|
||||
// Setup window events (like callbacks for onWindowClose)
|
||||
WindowUtil.initWindowEvents();
|
||||
// Disable the thing on Windows where it tries to send a bug report to Microsoft because why do they care?
|
||||
WindowUtil.disableCrashHandler();
|
||||
|
||||
// This ain't a pixel art game! (most of the time)
|
||||
FlxSprite.defaultAntialiasing = true;
|
||||
|
||||
Application.current.onExit.add(function(exitCode) {
|
||||
DiscordClient.shutdown();
|
||||
});
|
||||
#end
|
||||
// Disable default keybinds for volume (we manually control volume in MusicBeatState with custom binds)
|
||||
FlxG.sound.volumeUpKeys = [];
|
||||
FlxG.sound.volumeDownKeys = [];
|
||||
FlxG.sound.muteKeys = [];
|
||||
|
||||
// ==== flixel shit ==== //
|
||||
// TODO: Make sure volume still saves/loads properly.
|
||||
// if (FlxG.save.data.volume != null) FlxG.sound.volume = FlxG.save.data.volume;
|
||||
// if (FlxG.save.data.mute != null) FlxG.sound.muted = FlxG.save.data.mute;
|
||||
|
||||
// Set the game to a lower frame rate while it is in the background.
|
||||
FlxG.game.focusLostFramerate = 30;
|
||||
|
||||
//
|
||||
// FLIXEL DEBUG SETUP
|
||||
//
|
||||
#if debug
|
||||
// Disable using ~ to open the console (we use that for the Editor menu)
|
||||
FlxG.debugger.toggleKeys = [F2];
|
||||
|
||||
// Adds an additional Close Debugger button.
|
||||
// This big obnoxious white button is for MOBILE, so that you can press it
|
||||
// easily with your finger when debug bullshit pops up during testing lol!
|
||||
FlxG.debugger.addButton(LEFT, new BitmapData(200, 200), function() {
|
||||
FlxG.debugger.visible = false;
|
||||
});
|
||||
|
||||
// Adds a red button to the debugger.
|
||||
// This pauses the game AND the music! This ensures the Conductor stops.
|
||||
FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFFCC2233), function() {
|
||||
if (FlxG.vcr.paused)
|
||||
{
|
||||
|
@ -77,7 +107,8 @@ class InitState extends FlxTransitionableState
|
|||
}
|
||||
});
|
||||
|
||||
#if FLX_DEBUG
|
||||
// Adds a blue button to the debugger.
|
||||
// This skips forward in the song.
|
||||
FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() {
|
||||
FlxG.game.debugger.vcr.onStep();
|
||||
|
||||
|
@ -90,175 +121,197 @@ class InitState extends FlxTransitionableState
|
|||
FlxG.sound.music.pause();
|
||||
FlxG.sound.music.time += FlxG.elapsed * 1000;
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
#end
|
||||
|
||||
FlxG.sound.muteKeys = [ZERO];
|
||||
FlxG.game.focusLostFramerate = 60;
|
||||
|
||||
// FlxG.stage.window.borderless = true;
|
||||
// FlxG.stage.window.mouseLock = true;
|
||||
//
|
||||
// FLIXEL TRANSITIONS
|
||||
//
|
||||
|
||||
// Diamond Transition
|
||||
var diamond:FlxGraphic = FlxGraphic.fromClass(GraphicTransTileDiamond);
|
||||
diamond.persist = true;
|
||||
diamond.destroyOnNoUse = false;
|
||||
|
||||
FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32},
|
||||
new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
|
||||
FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32},
|
||||
new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
|
||||
|
||||
// ===== save shit ===== //
|
||||
|
||||
FlxG.save.bind('funkin', 'ninjamuffin99');
|
||||
|
||||
// https://github.com/HaxeFlixel/flixel/pull/2396
|
||||
// IF/WHEN MY PR GOES THRU AND IT GETS INTO MAIN FLIXEL, DELETE THIS CHUNKOF CODE, AND THEN UNCOMMENT THE LINE BELOW
|
||||
// FlxG.sound.loadSavedPrefs();
|
||||
|
||||
if (FlxG.save.data.volume != null) FlxG.sound.volume = FlxG.save.data.volume;
|
||||
if (FlxG.save.data.mute != null) FlxG.sound.muted = FlxG.save.data.mute;
|
||||
|
||||
// Make errors and warnings less annoying.
|
||||
LogStyle.ERROR.openConsole = false;
|
||||
LogStyle.ERROR.errorSound = null;
|
||||
LogStyle.WARNING.openConsole = false;
|
||||
LogStyle.WARNING.errorSound = null;
|
||||
|
||||
// FlxG.save.close();
|
||||
// FlxG.sound.loadSavedPrefs();
|
||||
WindowUtil.initWindowEvents();
|
||||
WindowUtil.disableCrashHandler();
|
||||
|
||||
PreferencesMenu.initPrefs();
|
||||
PlayerSettings.init();
|
||||
Highscore.load();
|
||||
|
||||
if (FlxG.save.data.weekUnlocked != null)
|
||||
{
|
||||
// FIX LATER!!!
|
||||
// WEEK UNLOCK PROGRESSION!!
|
||||
// StoryMenuState.weekUnlocked = FlxG.save.data.weekUnlocked;
|
||||
|
||||
// if (StoryMenuState.weekUnlocked.length < 4) StoryMenuState.weekUnlocked.insert(0, true);
|
||||
|
||||
// QUICK PATCH OOPS!
|
||||
// if (!StoryMenuState.weekUnlocked[0]) StoryMenuState.weekUnlocked[0] = true;
|
||||
}
|
||||
|
||||
if (FlxG.save.data.seenVideo != null) VideoState.seenVideo = FlxG.save.data.seenVideo;
|
||||
|
||||
// ===== fuck outta here ===== //
|
||||
|
||||
// FlxTransitionableState.skipNextTransOut = true;
|
||||
// FlxTransitionableState.defaultTransIn = new TransitionData(FADE, FlxColor.BLACK, 1, new FlxPoint(0, -1), {asset: diamond, width: 32, height: 32},
|
||||
// new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
|
||||
// FlxTransitionableState.defaultTransOut = new TransitionData(FADE, FlxColor.BLACK, 0.7, new FlxPoint(0, 1), {asset: diamond, width: 32, height: 32},
|
||||
// new FlxRect(-200, -200, FlxG.width * 1.4, FlxG.height * 1.4));
|
||||
// Don't play transition in when entering the title state.
|
||||
FlxTransitionableState.skipNextTransIn = true;
|
||||
|
||||
// TODO: Register custom event callbacks here
|
||||
//
|
||||
// NEWGROUNDS API SETUP
|
||||
//
|
||||
#if newgrounds
|
||||
NGio.init();
|
||||
#end
|
||||
|
||||
//
|
||||
// DISCORD API SETUP
|
||||
//
|
||||
#if discord_rpc
|
||||
DiscordClient.initialize();
|
||||
|
||||
Application.current.onExit.add(function(exitCode) {
|
||||
DiscordClient.shutdown();
|
||||
});
|
||||
#end
|
||||
|
||||
//
|
||||
// ANDROID SETUP
|
||||
//
|
||||
#if android
|
||||
FlxG.android.preventDefaultKeys = [flixel.input.android.FlxAndroidKey.BACK];
|
||||
#end
|
||||
|
||||
//
|
||||
// GAME DATA PARSING
|
||||
//
|
||||
funkin.data.level.LevelRegistry.instance.loadEntries();
|
||||
SongEventParser.loadEventCache();
|
||||
ConversationDataParser.loadConversationCache();
|
||||
DialogueBoxDataParser.loadDialogueBoxCache();
|
||||
SpeakerDataParser.loadSpeakerCache();
|
||||
SongDataParser.loadSongCache();
|
||||
StageDataParser.loadStageCache();
|
||||
CharacterDataParser.loadCharacterCache();
|
||||
ModuleHandler.buildModuleCallbacks();
|
||||
ModuleHandler.loadModuleCache();
|
||||
funkin.play.event.SongEventData.SongEventParser.loadEventCache();
|
||||
funkin.play.cutscene.dialogue.ConversationDataParser.loadConversationCache();
|
||||
funkin.play.cutscene.dialogue.DialogueBoxDataParser.loadDialogueBoxCache();
|
||||
funkin.play.cutscene.dialogue.SpeakerDataParser.loadSpeakerCache();
|
||||
funkin.play.song.SongData.SongDataParser.loadSongCache();
|
||||
funkin.play.stage.StageData.StageDataParser.loadStageCache();
|
||||
funkin.play.character.CharacterData.CharacterDataParser.loadCharacterCache();
|
||||
funkin.modding.module.ModuleHandler.buildModuleCallbacks();
|
||||
funkin.modding.module.ModuleHandler.loadModuleCache();
|
||||
|
||||
FlxG.debugger.toggleKeys = [F2];
|
||||
funkin.modding.module.ModuleHandler.callOnCreate();
|
||||
}
|
||||
|
||||
ModuleHandler.callOnCreate();
|
||||
/**
|
||||
* Retrive and parse data from the user's save.
|
||||
*/
|
||||
function loadSaveData()
|
||||
{
|
||||
// Bind save data.
|
||||
// TODO: Migrate save data to a better format.
|
||||
FlxG.save.bind('funkin', 'ninjamuffin99');
|
||||
|
||||
#if song
|
||||
var song:String = getSong();
|
||||
// Load player options from save data.
|
||||
PreferencesMenu.initPrefs();
|
||||
// Load controls from save data.
|
||||
PlayerSettings.init();
|
||||
// Load highscores from save data.
|
||||
Highscore.load();
|
||||
// TODO: Load level/character/cosmetic unlocks from save data.
|
||||
}
|
||||
|
||||
var weeks:Array<Array<String>> = [
|
||||
['bopeebo', 'fresh', 'dadbattle'],
|
||||
['spookeez', 'south', 'monster'],
|
||||
['spooky', 'spooky', 'monster'],
|
||||
['pico', 'philly', 'blammed'],
|
||||
['satin-panties', 'high', 'milf'],
|
||||
['cocoa', 'eggnog', 'winter-horrorland'],
|
||||
['senpai', 'roses', 'thorns'],
|
||||
['ugh', 'guns', 'stress']
|
||||
];
|
||||
|
||||
var week:Int = 0;
|
||||
for (i in 0...weeks.length)
|
||||
{
|
||||
if (weeks[i].contains(song))
|
||||
{
|
||||
week = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (week == 0) throw 'Invalid -D song=$song';
|
||||
|
||||
startSong(week, song, false);
|
||||
#elseif week
|
||||
var week:Int = getWeek();
|
||||
|
||||
var songs:Array<String> = [
|
||||
'bopeebo',
|
||||
'spookeez',
|
||||
'spooky',
|
||||
'pico',
|
||||
'satin-panties',
|
||||
'cocoa',
|
||||
'senpai',
|
||||
'ugh'
|
||||
];
|
||||
|
||||
if (week <= 0 || week >= songs.length) throw 'invalid -D week=' + week;
|
||||
|
||||
startSong(week, songs[week - 1], true);
|
||||
#elseif FREEPLAY
|
||||
/**
|
||||
* Start the game.
|
||||
*
|
||||
* By default, moves to the `TitleState`.
|
||||
* But based on compile defines, the game can start immediately on a specific song,
|
||||
* or immediately in a specific debug menu.
|
||||
*/
|
||||
function startGame():Void
|
||||
{
|
||||
#if SONG // -DSONG=bopeebo
|
||||
startSong(defineSong(), defineDifficulty());
|
||||
#elseif LEVEL // -DLEVEL=week1 -DDIFFICULTY=hard
|
||||
startLevel(defineLevel(), defineDifficulty());
|
||||
#elseif FREEPLAY // -DFREEPLAY
|
||||
FlxG.switchState(new FreeplayState());
|
||||
#elseif ANIMATE
|
||||
#elseif ANIMATE // -DANIMATE
|
||||
FlxG.switchState(new funkin.ui.animDebugShit.FlxAnimateTest());
|
||||
#elseif CHARTING
|
||||
#elseif CHARTING // -DCHARTING
|
||||
FlxG.switchState(new funkin.ui.debug.charting.ChartEditorState());
|
||||
#elseif STAGEBUILD
|
||||
FlxG.switchState(new StageBuilderState());
|
||||
#elseif FIGHT
|
||||
FlxG.switchState(new PicoFight());
|
||||
#elseif ANIMDEBUG
|
||||
#elseif STAGEBUILD // -DSTAGEBUILD
|
||||
FlxG.switchState(new funkin.ui.stageBullshit.StageBuilderState());
|
||||
#elseif ANIMDEBUG // -DANIMDEBUG
|
||||
FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState());
|
||||
#elseif LATENCY
|
||||
FlxG.switchState(new LatencyState());
|
||||
#elseif NETTEST
|
||||
FlxG.switchState(new netTest.NetTest());
|
||||
#elseif LATENCY // -DLATENCY
|
||||
FlxG.switchState(new funkin.LatencyState());
|
||||
#else
|
||||
FlxG.sound.cache(Paths.music('freakyMenu'));
|
||||
FlxG.switchState(new TitleState());
|
||||
startGameNormally();
|
||||
#end
|
||||
}
|
||||
|
||||
function startSong(week, song, isStoryMode):Void
|
||||
/**
|
||||
* Start the game by moving to the title state and play the game as normal.
|
||||
*/
|
||||
function startGameNormally():Void
|
||||
{
|
||||
var dif:Int = getDif();
|
||||
FlxG.sound.cache(Paths.music('freakyMenu'));
|
||||
FlxG.switchState(new TitleState());
|
||||
}
|
||||
|
||||
var targetDifficulty = switch (dif)
|
||||
/**
|
||||
* Start the game by directly loading into a specific song.
|
||||
* @param songId
|
||||
* @param difficultyId
|
||||
*/
|
||||
function startSong(songId:String, difficultyId:String = 'normal'):Void
|
||||
{
|
||||
var songData:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
|
||||
|
||||
if (songData == null)
|
||||
{
|
||||
case 0: 'easy';
|
||||
case 1: 'normal';
|
||||
case 2: 'hard';
|
||||
default: 'normal';
|
||||
};
|
||||
LoadingState.loadAndSwitchState(new PlayState(
|
||||
startGameNormally();
|
||||
return;
|
||||
}
|
||||
|
||||
LoadingState.loadAndSwitchState(new funkin.play.PlayState(
|
||||
{
|
||||
targetSong: SongDataParser.fetchSong(song),
|
||||
targetDifficulty: targetDifficulty,
|
||||
targetSong: songData,
|
||||
targetDifficulty: difficultyId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game by directly loading into a specific story mode level.
|
||||
* @param levelId
|
||||
* @param difficultyId
|
||||
*/
|
||||
function startLevel(levelId:String, difficultyId:String = 'normal'):Void
|
||||
{
|
||||
var currentLevel:funkin.ui.story.Level = funkin.data.level.LevelRegistry.instance.fetchEntry(levelId);
|
||||
|
||||
if (currentLevel == null)
|
||||
{
|
||||
startGameNormally();
|
||||
return;
|
||||
}
|
||||
|
||||
PlayStatePlaylist.playlistSongIds = currentLevel.getSongs();
|
||||
PlayStatePlaylist.isStoryMode = true;
|
||||
PlayStatePlaylist.campaignScore = 0;
|
||||
|
||||
var targetSongId:String = PlayStatePlaylist.playlistSongIds.shift();
|
||||
|
||||
var targetSong:funkin.play.song.Song = funkin.play.song.SongData.SongDataParser.fetchSong(targetSongId);
|
||||
|
||||
LoadingState.loadAndSwitchState(new funkin.play.PlayState(
|
||||
{
|
||||
targetSong: targetSong,
|
||||
targetDifficulty: difficultyId,
|
||||
}));
|
||||
}
|
||||
|
||||
function defineSong():String
|
||||
{
|
||||
return MacroUtil.getDefine('SONG');
|
||||
}
|
||||
|
||||
function defineLevel():String
|
||||
{
|
||||
return MacroUtil.getDefine('LEVEL');
|
||||
}
|
||||
|
||||
function defineDifficulty():String
|
||||
{
|
||||
return MacroUtil.getDefine('DIFFICULTY');
|
||||
}
|
||||
}
|
||||
|
||||
function getWeek():Int
|
||||
return Std.parseInt(MacroUtil.getDefine('week'));
|
||||
|
||||
function getSong():String
|
||||
return MacroUtil.getDefine('song');
|
||||
|
||||
function getDif():Int
|
||||
return Std.parseInt(MacroUtil.getDefine('dif', '1'));
|
||||
|
|
|
@ -26,8 +26,10 @@ class PlayerSettings
|
|||
// public var avatar:Player;
|
||||
// public var camera(get, never):PlayCamera;
|
||||
|
||||
function new(id)
|
||||
function new(id:Int)
|
||||
{
|
||||
trace('loading player settings for id: $id');
|
||||
|
||||
this.id = id;
|
||||
this.controls = new Controls('player$id', None);
|
||||
|
||||
|
@ -52,7 +54,11 @@ class PlayerSettings
|
|||
}
|
||||
}
|
||||
|
||||
if (useDefault) controls.setKeyboardScheme(Solo);
|
||||
if (useDefault)
|
||||
{
|
||||
trace("falling back to default control scheme");
|
||||
controls.setKeyboardScheme(Solo);
|
||||
}
|
||||
|
||||
// Apply loaded settings.
|
||||
PreciseInputManager.instance.initializeKeys(controls);
|
||||
|
|
|
@ -44,6 +44,7 @@ class TitleState extends MusicBeatState
|
|||
|
||||
override public function create():Void
|
||||
{
|
||||
super.create();
|
||||
swagShader = new ColorSwap();
|
||||
|
||||
curWacky = FlxG.random.getObject(getIntroTextShit());
|
||||
|
@ -51,38 +52,6 @@ class TitleState extends MusicBeatState
|
|||
|
||||
// DEBUG BULLSHIT
|
||||
|
||||
super.create();
|
||||
|
||||
/*
|
||||
#elseif web
|
||||
|
||||
|
||||
if (!initialized)
|
||||
{
|
||||
|
||||
video = new Video();
|
||||
FlxG.stage.addChild(video);
|
||||
|
||||
var netConnection = new NetConnection();
|
||||
netConnection.connect(null);
|
||||
|
||||
netStream = new NetStream(netConnection);
|
||||
netStream.client = {onMetaData: client_onMetaData};
|
||||
netStream.addEventListener(AsyncErrorEvent.ASYNC_ERROR, netStream_onAsyncError);
|
||||
netConnection.addEventListener(NetStatusEvent.NET_STATUS, netConnection_onNetStatus);
|
||||
// netStream.addEventListener(NetStatusEvent.NET_STATUS) // netStream.play(Paths.file('music/kickstarterTrailer.mp4'));
|
||||
|
||||
overlay = new Sprite();
|
||||
overlay.graphics.beginFill(0, 0.5);
|
||||
overlay.graphics.drawRect(0, 0, 1280, 720);
|
||||
overlay.addEventListener(MouseEvent.MOUSE_DOWN, overlay_onMouseDown);
|
||||
|
||||
overlay.buttonMode = true;
|
||||
// FlxG.stage.addChild(overlay);
|
||||
|
||||
}
|
||||
*/
|
||||
|
||||
// netConnection.addEventListener(MouseEvent.MOUSE_DOWN, overlay_onMouseDown);
|
||||
new FlxTimer().start(1, function(tmr:FlxTimer) {
|
||||
startIntro();
|
||||
|
|
|
@ -110,7 +110,7 @@ class StoryMenuState extends MusicBeatState
|
|||
transIn = FlxTransitionableState.defaultTransIn;
|
||||
transOut = FlxTransitionableState.defaultTransOut;
|
||||
|
||||
if (!FlxG.sound.music.playing)
|
||||
if (FlxG.sound.music == null || !FlxG.sound.music.playing)
|
||||
{
|
||||
FlxG.sound.playMusic(Paths.music('freakyMenu'));
|
||||
FlxG.sound.music.fadeIn(4, 0, 0.7);
|
||||
|
|
Loading…
Add table
Reference in a new issue