Continuing the previous–to paraphrase–“Testing is painful” post, we did everything we could to reduce this pain. Tests indeed aren’t easy, so we need to do all we can to reduce this pain & friction.
In KOPI, we support local multiplayer for “couch mode”: if you plug in two controllers, and then literally sit back on the couch, you can enjoy KOPI on the big screen. Under normal gameplay, the players would launch the game, enter the main menu, add player, select a level, wait for the transitions, and then enjoy the gameplay. This is indeed what the players expect, the transitions, fades, camera changes need to be smooth and snappy.
For gameplay experience, this is pure bliss. For development experience, this is pure pain.
A lot of the times, when we iterate on a level, we are focusing on adding or fixing one specific thing. We don’t want to go through the pain of all the end-player experience only to play for 10 seconds before realising that we need to make one more change.
Editor support
To help us speed up these issues, and to assist with the testing, we implemented Editor toolbar extension, packaged in the KopiEditor
module. This toolbar extension allows us to very easily start the levels and allows us to
- Just play (equivalent to the Play button)
- Play a hint-following automatic play (this is what would happen if the players exactly followed the in-game tutorial)
- Play a log-based automatic play (to paraphrase, repeat what happened the last time)
Alongside all of these options, we can tick to automatically have all connected controllers join the game, as if the players went though the main menu.
These options significantly reduce the development cycle time. Let’s see how they are implemented in code. The core of the structure is the FKopiEditorModule
; on its startup, it registers the menus; on shutdown, it removes the menus.
class FKopiEditorModule : public FDefaultGameModuleImpl {
virtual void StartupModule() override {
if (!IsRunningGame()) {
if (FSlateApplication::IsInitialized()) {
ToolMenusHandle = UToolMenus::RegisterStartupCallback(
FSimpleMulticastDelegate::FDelegate::CreateStatic(
&RegisterGameEditorMenus));
}
FEditorDelegates::BeginPIE.AddRaw(this, &ThisClass::OnBeginPIE);
FEditorDelegates::EndPIE.AddRaw(this, &ThisClass::OnEndPIE);
}
}
void OnBeginPIE(bool bIsSimulating) {
}
void OnEndPIE(bool bIsSimulating) {
}
virtual void ShutdownModule() override {
// Undo UToolMenus
if (UObjectInitialized() && ToolMenusHandle.IsValid()) {
UToolMenus::UnRegisterStartupCallback(ToolMenusHandle);
}
FModuleManager::Get().OnModulesChanged().RemoveAll(this);
}
private:
FDelegateHandle ToolMenusHandle;
};
IMPLEMENT_MODULE(FKopiEditorModule, KopiEditor);
The job of registering the menu items is a plain-old function, RegisterGameEditorMenus
. Here, we extend the Level editor and Widget blueprint editor toolbars by adding the relevant controls. (To find out what the correct names are, turn on Display UI Extension points and then execute the console command ToolbarMenus.Edit
, and take note of the green labels.)
After that, we can dive into the code, starting with the Play Test functionality.
Play test dropdown
static void RegisterGameEditorMenus() {
UToolMenu *Menu = UToolMenus::Get()->ExtendMenu(
"LevelEditor.LevelEditorToolBar.PlayToolBar");
FToolMenuSection &PlayTestSection = Menu->AddSection("Play Test",
TAttribute<FText>(), FToolMenuInsert("Play", EToolMenuInsertType::After));
FToolMenuEntry CommonMapEntry = FToolMenuEntry::InitComboButton(
"Play Test",
FUIAction(
FExecuteAction(),
FCanExecuteAction::CreateStatic(&HasNoPlayWorld),
FIsActionChecked(),
FIsActionButtonVisible::CreateStatic(&HasNoPlayWorld)),
FOnGetContent::CreateStatic(&GetPlayWithTestOptionsDropdown),
NSLOCTEXT("Kopi.Editor", "PlayTest", "Play Test"),
TAttribute<FText>(),
FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Play")
);
CommonMapEntry.StyleNameOverride = "CalloutToolbar";
PlayTestSection.AddEntry(CommonMapEntry);
}
First, we add the Play Test dropdown–the key element is the function GetPlayWithTestOptionsDropdown
, which populates the dropdown.
static TSharedRef<SWidget> GetPlayWithTestOptionsDropdown() {
FMenuBuilder MenuBuilder(true, nullptr);
auto Checkbox = SNew(SCheckBox)
.Style(&FAppStyle::Get().GetWidgetStyle<FCheckBoxStyle>("CheckBox"))
[
SNew(STextBlock).Text(NSLOCTEXT("Kopi.Editor",
"ALMP", "Auto Local Multiplayer"))
];
MenuBuilder.AddMenuEntry(
FText::FromString("Just Play"),
TAttribute<FText>(),
FSlateIcon(),
FUIAction(
FExecuteAction::CreateStatic(&PlayWithTest_Clicked, -1, Checkbox),
FCanExecuteAction::CreateStatic(&HasNoPlayWorld),
FIsActionChecked(),
FIsActionButtonVisible::CreateStatic(&HasNoPlayWorld)
)
);
MenuBuilder.AddSeparator();
for (const auto &Driver : UKopiTestWorldSubsystem::GetPlayerDrivers()) {
MenuBuilder.AddMenuEntry(
Driver.Name,
TAttribute<FText>(),
FSlateIcon(),
FUIAction(
FExecuteAction::CreateStatic(&PlayWithTest_Clicked, Driver.Id, Checkbox),
FCanExecuteAction::CreateStatic(&HasNoPlayWorld),
FIsActionChecked(),
FIsActionButtonVisible::CreateStatic(&HasNoPlayWorld)
)
);
}
MenuBuilder.AddSeparator();
MenuBuilder.AddWidget(Checkbox, FText::GetEmpty());
return MenuBuilder.MakeWidget();
}
The good news is that the KopiEditor
module depends on both KopiGame
and KopiTests
, which means we can directly access the UKopiTestWorldSubsystem
to query the available test drivers. The final piece of code is the PlayWithTest_Clicked, which actually starts the PIE session.
static void PlayWithTest_Clicked(const int Id, TSharedRef<SCheckBox> CheckBox) {
UKopiTestWorldSubsystem::SetPlayerDriverId(Id);
FRequestPlaySessionParams SessionParams;
FLevelEditorModule &LevelEditorModule =
FModuleManager::GetModuleChecked<FLevelEditorModule>(TEXT("LevelEditor"));
/** Set PlayInViewPort as the last executed play command */
TSharedPtr<IAssetViewport> ActiveLevelViewport =
LevelEditorModule.GetFirstActiveViewport();
// Make sure we can find a path to the view port. This will fail in cases where the view port widget
// is in a backgrounded tab, etc. We can't currently support starting PIE in a backgrounded tab
// due to how PIE manages focus and requires event forwarding from the application.
if (ActiveLevelViewport.IsValid() && FSlateApplication::Get()
.FindWidgetWindow(ActiveLevelViewport->AsWidget()).IsValid()) {
SessionParams.DestinationSlateViewport = ActiveLevelViewport;
}
if (CheckBox->IsChecked()) {
UKopiGamepadSubsystem::Test_AutoCreateAllLocalPlayers();
}
if (!HasPlayWorld()) {
// If there is an active level view port, play the game in it,
// otherwise make a new window.
GUnrealEd->RequestPlaySession(SessionParams);
} else {
// There is already a play world active which means simulate in
// editor is happening
// Toggle to PIE
check(!GIsPlayInEditorWorld);
GUnrealEd->RequestToggleBetweenPIEandSIE();
}
}
This function is that code that will execute when we select one of the Play Test dropdown buttons; and we also get access to the SCheckBox
that indicates whether we want to enrol all controllers into local multi-player.
Locales
Because we are localising our game into our shared three languages, we also added convenient locale switchers to speed up our testing. The code to register the buttons is very similar to the Play Test section, the only difference is that you will have to remember to add the buttons to the appropriate toolbars.
static TArray<FString> Locales = {"en", "zh-hans", "cs"};
static void Locale_Clicked(int Index) {
const FString Locale = Locales[Index];
const TArray<FString> &LocalizedCultures =
UKismetInternationalizationLibrary::GetLocalizedCultures();
const FString SuitableCulture =
UKismetInternationalizationLibrary::GetSuitableCulture(
LocalizedCultures, Locale, "en");
UKismetInternationalizationLibrary::SetCurrentLanguageAndLocale(
SuitableCulture, false);
}
static void RegisterGameEditorMenus() {
UToolMenu *Menu = UToolMenus::Get()->ExtendMenu(
"LevelEditor.LevelEditorToolBar.PlayToolBar");
FToolMenuSection &PlayTestSection = Menu->AddSection("Play Test",
TAttribute<FText>(), FToolMenuInsert("Play", EToolMenuInsertType::After));
UToolMenu *WidgetEditorMenu = UToolMenus::Get()->ExtendMenu(
"AssetEditor.WidgetBlueprintEditor.ToolBar");
FToolMenuSection &SetLocaleSection = WidgetEditorMenu->AddSection("Set Locale",
TAttribute<FText>());
// ...
auto AddLocaleButtons = [](FToolMenuSection &Section) {
for (auto Locale = Locales.CreateConstIterator(); Locale; ++Locale) {
const FName Id = FName("SetLocale" + *Locale);
FToolMenuEntry SetLocale = FToolMenuEntry::InitToolBarButton(
Id,
FUIAction(
FExecuteAction::CreateStatic(&Locale_Clicked, Locale.GetIndex()),
FCanExecuteAction(),
FIsActionChecked(),
FIsActionButtonVisible()
),
FText::FromString(*Locale),
TAttribute<FText>(),
FSlateIcon(FAppStyle::GetAppStyleSetName(),
"WidgetDesigner.ToggleLocalizationPreview"));
SetLocale.StyleNameOverride = "CalloutToolbar";
Section.AddEntry(SetLocale);
}
};
AddLocaleButtons(PlayTestSection);
AddLocaleButtons(SetLocaleSection);
}
As a hint, to find out which Slate Icon name, you can use the Slate Icon Browser plugin at sirjofri/SlateIconBrowser: Unreal Editor Icon Browser (github.com).
Leave a Reply