Description

Orcs Must Die! Unchained is a free-to-play third-person tower-defense/shooter hybrid. The game provides two unique game modes for players to play.

In survival mode, a team of one to three players work together in order to defend a dimensional rift from attack by hordes of enemies intent on destroying it. In order to succeed, players must spend coin to build up their defenses with barricades, traps, and guardians. Then, players support these defenses by using their own heroes' unique abilities and attacks.

In sabotage mode, two teams of one to three players compete to defend their dimensional rift for the longest amount of time. Just like in survival mode, hordes of enemies swarm the players' heroes and their defenses. However, in this mode players gain spell and minion cards that they can use to bewilder their opponents and send more enemies at their defenses.

Quick Statistics:

Role: UI/Tools Programmer

Team: Robot Entertainment

Size: 88 Developers

  • 28 Artists
  • 11 Designers
  • 5 Producers
  • 25 Programmers
  • 19 QA

Engine: Unreal Engine 3

Platforms:

  • Windows
  • PS4

Development Time: (ongoing)

  • 22 Months (personally)
  • 5 Years (overall)
 

UI Programmer:

  • Created HUD elements and menus in Scaleform designed to be easily configurable from both Unreal and Flash.
  • Worked closely with UI artists to balance art assets' appearance and performance.
  • Tweaked Unreal and Flash code and assets to support our PS4 team.

Tools Programmer:

  • Built Kismet actions and events that enabled designers to create tutorial and postgame sequences.
  • Made an archetype-based system for artists to create customized loading screens for levels and modes.
  • Adapted the Scaleform emulator into a tool that UI artists could use to test component functionality without using Unreal.
 
  • Customized Loading Screens

    Motivation:

    One feature that design and art frequently requested was the ability to display customized loading screens for each map and game mode. However, the design of Unreal Engine 3's loading system makes modifying the loading screen difficult.

    UE3 has only two threads: the game thread and the render thread. The base UE3 loading screen implementation is to load a static Flash file set in the game's config files into the render thread and then proceed directly to the loading process. After that point, the game thread is completely locked by the loading process. Additionally, the information about the map and game mode is unknown until the majority of the loading process is complete.


    Design:

    The primary actor in our custom loading screen solution is the LoadingScreenTemplate class. This class contains arrays of structs, each of which contains all of the data needed to construct and place text, images, and tips. For each map and game mode, our art and design teams then created an archetype of the LoadingScreenTemplate class using the Unreal Editor.

    When launching the game, the OMDU frontend passes an additional argument on the command line containing the asset path to a loading screen archetype. Right before starting the loading process on the game thread, Unreal loads a (mostly) empty Flash file and the given loading screen template. The loading screen template is then applied to the Flash file, which takes the data in the template and uses it to create the text and images on the fly. Finally, the customized loading screen is passed to the render thread as normal.


    Code:


    TextField Data Struct:

    
    struct native LoadingTextData
    {
       var(Flash) private{private} string TextFieldName<DisplayName="TextField Name" | Tooltip="What is the UNIQUE name for this TextField?">;
     
       var(Localization) private{private} string TextLocPackage<DisplayName="Localization Package" | Tooltip="What is the loc package for the text in this TextField?">;
       var(Localization) private{private} string TextLocSection<DisplayName="Localization Section" | Tooltip="What is the loc section for the text in this TextField?">;
       var(Localization) private{private} string TextLocKey<DisplayName="Localization Key" | Tooltip="What is the loc key for the text in this TextField?">;
     
       var(Transform) private{private} Vector2D Position<DisplayName="Position (from top-left)" | Tooltip="Where should this TextField be displayed?">;
     
       var(Formatting) private{private} RFlashTextAlignment Alignment<Tooltip="How should the text be aligned in the TextField?">;
       var(Formatting) private{private} RFlashFont Font<Tooltip="What font should be used for the text?">;
       var(Formatting) private{private} float FontSize<Tooltip="What size should the text be?">;
       var(Formatting) private{private} bool Bold<Tooltip="Should the text be bold?">;
       var(Formatting) private{private} bool Italic<Tooltip="Should the text be italicized?">;
       var(Formatting) private{private} Color TextColor<Tooltip="What color should the text be?">;
     
       // This variable is purposely concealed from designers; the only reason it's here is to simplify switching from true TextFields to Scaleform Labels if we decide to later.
       var private{private} string TextFieldTextVariableName;
     
       structcpptext
       {
          friend class URGFxLoadingScreenTemplate;
       }
     
       structdefaultproperties
       {
          TextLocPackage="SpitfireGameUI"
     
          Alignment=RFTA_Left
          Font=RFF_NormalFont
          FontSize=32.0
          TextColor=(R=255, G=255, B=255, A=255)
     
          TextFieldTextVariableName="text"
       }
    };
    

    TextField Building Function:

    
    void URGFxLoadingScreenTemplate::ApplyTextDataToMovie(const FLoadingTextData& textData, INT depthIndex, void* movieRootAsVoid)
    {
    #if WITH_GFx
       GFx::Value* movieRootValue = reinterpret_cast<GFx::Value*>(movieRootAsVoid);
       if(movieRootValue == NULL)
       {
          warnf(TEXT("%s::ApplyTextDataToMovie() -- Cannot insert text into a NULL loading screen root!"), *GetName());
          return;
       }
     
       const FString& textFieldName = textData.TextFieldName;
       GFx::Value textFieldValue;
       UBOOL foundTextField = movieRootValue->GetMember(FTCHARToUTF8(*textFieldName), &textFieldValue);
       if (!foundTextField)
       {
          // Now, since createTextField() isn't exposed via the GFx::Value interface, we have to call it via invoke as well.
          const INT numArgs = 6;
          AutoGFxValueArray(args, numArgs);
    #if TCHAR_IS_1_BYTE
          args[0].SetString(*textFieldName);
    #else
          args[0].SetStringW(*textFieldName);
    #endif
          // We can't use getNextHighestDepth() for depth here -- it's possible for multiple movieclips/textfields to be created this frame, and getNextHighestDepth() does not update properly!
          args[1].SetInt(UCONST_BASE_DEPTH_TEXTFIELDS + depthIndex);
     
          // We'll set the rest later, as we need to set these values for the found case as well.
          args[2].SetInt(0); // x position
          args[3].SetInt(0); // y position
          args[4].SetInt(1000); // width
          args[5].SetInt(1000); // height
          movieRootValue->Invoke(FTCHARToUTF8(TEXT("createTextField")), &textFieldValue, args, numArgs);
       }
     
       if(!textFieldValue.IsDisplayObject())
       {
          warnf(TEXT("%s::ApplyDataToTextField() -- Unable to find or create a TextField for the name \"%s\"! The text will not be shown."), *GetName(), *textData.TextFieldName);
          return;
       }
     
       // Set Formatting Data
       GFx::Value textFormatValue;
       textFieldValue.Invoke(FTCHARToUTF8(TEXT("getTextFormat")), &textFormatValue, NULL, 0);
     
       textFormatValue.SetMember( FTCHARToUTF8(TEXT("align")), *GetAlignmentStringFromEnum(textData.Alignment) );
     
       FString fontName = GetFontNameFromEnum(textData.Font);
       GFx::Value fontNameValue;
    #if TCHAR_IS_1_BYTE
       fontNameValue.SetString(*fontName);
    #else
       fontNameValue.SetStringW(*fontName);
    #endif
       textFormatValue.SetMember( FTCHARToUTF8(TEXT("font")), *fontName );
     
       textFormatValue.SetMember( FTCHARToUTF8(TEXT("size")), GFx::Value(textData.FontSize) );
     
       GFx::Value boldValue;
       boldValue.SetBoolean(textData.Bold);
       textFormatValue.SetMember( FTCHARToUTF8(TEXT("bold")), boldValue );
     
       GFx::Value italicValue;
       italicValue.SetBoolean(textData.Italic);
       textFormatValue.SetMember( FTCHARToUTF8(TEXT("italic")), italicValue );
     
       GFx::Value colorValue;
       colorValue.SetInt(UGFxObject::ConvertFColorToUnsignedARGB(textData.TextColor));
       textFormatValue.SetMember( FTCHARToUTF8(TEXT("color")), colorValue ); // NOTE: alpha cannot be set for dynamic textfields in Flash. Unfortunate. :(
     
       GFx::Value voidReturnValue;
       const INT numArgs = 1;
       AutoGFxValueArray(args, numArgs);
       args[0] = textFormatValue;
       textFieldValue.Invoke(FTCHARToUTF8(TEXT("setNewTextFormat")), &voidReturnValue, args, numArgs); // NOTE: setNewTextFormat applies ONLY to the new text that we will set, NOT the old text!
     
       // Set Localization Data
       FString textToDisplay = (!textData.TextLocKey.IsEmpty()) ? Localize(*textData.TextLocSection, *textData.TextLocKey, *textData.TextLocPackage) : TEXT("");
       GFx::Value fieldTextValue;
    #if TCHAR_IS_1_BYTE
       fieldTextValue.SetString(*textToDisplay);
    #else
       fieldTextValue.SetStringW(*textToDisplay);
    #endif
       textFieldValue.SetMember(FTCHARToUTF8(*textData.TextFieldTextVariableName), fieldTextValue);
     
       // Set Transform Data
       const FVector2D& textFieldPosition = textData.Position;
       textFieldValue.SetMember( FTCHARToUTF8(TEXT("_x")), GFx::Value(textFieldPosition.X) );
       textFieldValue.SetMember( FTCHARToUTF8(TEXT("_y")), GFx::Value(textFieldPosition.Y) );
    #endif // WITH_GFx
    }
    

    Loading Screen Template Loading:

    
    void FFullScreenMovieGFx::GameThreadPlayMovie ( EMovieMode InMovieMode, const TCHAR* InMovieFilename, INT StartFrame,
            INT InStartOfRenderingMovieFrame, INT InEndOfRenderingMovieFrame )
    {
    /* ... */
    
       // @ROBOT - [2016/12/15] VK - Redid the loading screen customization process to use a template. This allows us to move the customization code to its own file.
       FString loadingScreenTemplatePath = "";
       Parse(appCmdLine(), TEXT("LoadingScreenArchetypePath="), loadingScreenTemplatePath); // If the argument is not found, the path will remain the empty string.
     
       URGFxLoadingScreenTemplate* loadingScreenTemplate = Cast<URGFxLoadingScreenTemplate>(UObject::StaticLoadArchetype(UObject::StaticClass(), *loadingScreenTemplatePath));
       if(loadingScreenTemplate == NULL)
       {
          if(!loadingScreenTemplatePath.IsEmpty()) // If we were given a template path, the path is wrong. Log some information for debugging.
          {
             warnf(TEXT("FFullScreenMovieGFx::GameThreadPlayMovie() -- Unable to load the loading screen template at \"%s\"! The default loading screen will be displayed."), *loadingScreenTemplatePath);
          }
         
          // If the template isn't found at the path, use the CDO, which has a generic load screen set up.
          // NOTE: If we can't find the CDO, we'll crash. Crashing in that case is good, because if there is no CDO, something is VERY wrong.
          loadingScreenTemplate = Cast<URGFxLoadingScreenTemplate>(URGFxLoadingScreenTemplate::StaticClass()->GetDefaultObject());
       }
     
       loadingScreenTemplate->ApplyToLoadingScreen(NewMovieView);
     
       // Force at least one frame of advance at the start to flush Flash's load queue before the game starts loading and locks us out.
       INT framesToAdvanceAtStart = Max(1, StartFrame);
       NewMovieView->Advance ( framesToAdvanceAtStart / NewMovieDef->GetFrameRate() );
       // @ROBOT - end Robot edit
       
    /* ... */
    }
    


    Source File:

    Source files are © Robot Entertainment and are unavailable for view or download.

    Back to top of code tabs
  • OMDU Keybinding Menu

    Motivation:

    Once we began development on a PS4 port of Orcs Must Die! Unchained (OMDU), one of the largest problems that we knew we'd encounter was input. Even using a keyboard, OMDU was running out of keys within reach of the left hand, so cramming all of these inputs onto a controller was going to be a challenge.

    We considered a few different methods of increasing the combinations of inputs that the game could support, including press-and-hold, input contexts, and multi-input bindings. Ultimately, we decided that multi-input bindings would be the simplest to modify Unreal Engine 3 to support and give us the greatest number of inputs.


    Design:

    In general, our multiplayer menus start up by using the stage actions to set up the FocusManager masks for the controllers and the objects on stage.

    From then on, each masked object can act as if it were a normal Scaleform object dealing with a single player input.


    Code Snippets:

    Key Event Handler:

    
    //
    //            UInput::InputKey
    //
    UBOOL UInput::InputKey(INT ControllerId,FName Key,EInputEvent Event,FLOAT AmountDepressed,UBOOL bGamepad)
    {
                    switch(Event)
                    {
                    case IE_Pressed:
                                    if(PressedKeys.FindItemIndex(Key) != INDEX_NONE)
                                    {
                                                    debugf(NAME_Input, TEXT("Received pressed event for key %s that was already pressed (%s)"), *Key.ToString(), *GetFullName());
                                                    return FALSE;
                                    }
                                    PressedKeys.AddUniqueItem(Key);
                                    break;
     
                    case IE_Released:
                                    if(!PressedKeys.RemoveItem(Key))
                                    {
                                                    debugf(NAME_Input, TEXT("Received released event for key %s but key was never pressed (%s)"), *Key.ToString(), *GetFullName());
                                                    return FALSE;
                                    }
                                    break;
                    default:
                                    break;
                    };
     
       // @ROBOT - 4/17/2014 rws - Fixing an input bug where exec commands could double fire if input was flushed as a result of that command
       // AND the command had an OnRelease function.
       // I left this alone because it is used in a tick function and I don't have a good way to change that.
       CurrentControllerId = ControllerId;
     
                    // if kismet absorbs/traps the key, don't process it
                    if (ProcessInputKismetEvents(ControllerId, Key, Event))
                    {
                                    return TRUE;
                    }
     
       INT foundCommandCount = 0;
       TArray<FString> foundCommands;
       switch(Event)
       {
       case IE_Pressed:
       case IE_Repeat:
       case IE_Axis:
       case IE_DoubleClick:
          foundCommandCount = GetPressedBinds(Key, foundCommands);
          break;
     
       case IE_Released:
          foundCommandCount = GetReleasedBinds(Key, foundCommands);
          break;
       default:
          break;
       };
     
                    if(foundCommandCount > 0)
       {
          for(INT commandIndex = 0; commandIndex < foundCommandCount; ++commandIndex)
          {
             FString& command = foundCommands(commandIndex);
             // [2016/10/18] VK - PressedCommands modification has to happen before the command executes, as commands can cause input changes (via a flush, for example), and need the current state correct.
             switch(Event)
             {
             case IE_Pressed:
             case IE_Repeat:
             case IE_Axis:
             case IE_DoubleClick:
                PressedCommands.AddUniqueItem(command);
                break;
     
             case IE_Released:
                PressedCommands.RemoveItem(command);
                break;
             default:
                break;
             };
     
             ExecInputCommands(*command,*GLog, ControllerId, Event, 0.0f, 0.0f);
          }
     
                                    return TRUE;
                    }
                    else
                    {
                                    return Super::InputKey(ControllerId,Key,Event,AmountDepressed,bGamepad);
                    }
       // @Robot - end Robot edit
    }
    


    Bind Searching Functions:

    
    // @ROBOT [2016/09/30] VK - Split GetBind() into GetPressedBind()/GetReleasedBind() in order to properly support onRelease with modifiers.
    // @ROBOT [2016/10/19] VK - Changed GetPressedBind()/GetReleasedBind() so that they can support multiple command presses/releases with a single key press/release.
    //
    //            UInput::GetPressedBinds
    //
    static const INT BIND_ARRAY_EMPTY_SLACK = 2;
    INT UInput::GetPressedBinds(const FName& Key, TArray<FString>& pressedBinds)
    {
       pressedBinds.Empty(BIND_ARRAY_EMPTY_SLACK);
     
       int highestMatchingKeyCount = 0;
                    for ( INT BindIndex = Bindings.Num() - 1; BindIndex >= 0; BindIndex-- )
                    {
                                    const FKeyBind& Bind = Bindings(BindIndex);
     
          if (IsCommandDisabled(Bind.Command) ||
              //PressedCommands.ContainsItem(Bind.Command) || // Some binds (like WASD movement) want to fire even if they're already being fired (held).
              !BindContainsKey(Bind, Key))
          {
             continue;
          }
     
          if(BindIsCurrentlyBeingPressed(Bind, PressedKeys))
          {
             INT matchingKeyCount = Bind.ModifierKeyNames.Num() + 1; // +1 for PrimaryKeyName
     
             if(matchingKeyCount > highestMatchingKeyCount)
             {
                highestMatchingKeyCount = matchingKeyCount;
     
                pressedBinds.Empty(BIND_ARRAY_EMPTY_SLACK);
             }
     
             if(matchingKeyCount == highestMatchingKeyCount)
             {
                pressedBinds.AddItem(Bind.Command);
             }
          }
                    }
       return pressedBinds.Num();
    }
     
    //
    //            UInput::GetReleasedBinds
    //
    INT UInput::GetReleasedBinds(const FName& Key, TArray<FString>& releasedBinds)
    {
       releasedBinds.Empty(BIND_ARRAY_EMPTY_SLACK);
     
       for ( INT BindIndex = Bindings.Num() - 1; BindIndex >= 0; BindIndex-- )
       {
          const FKeyBind& Bind = Bindings(BindIndex);
          const TArray<FName>& bindModifierKeyNames = Bind.ModifierKeyNames;
     
          if (IsCommandDisabled(Bind.Command) ||
             !PressedCommands.ContainsItem(Bind.Command) ||
             !BindContainsKey(Bind, Key))
          {
             continue;
          }
     
          INT unpressedKeyCount = GetUnpressedKeyCountFor(Bind, PressedKeys);
     
          // We match a release if we are EXACTLY one unpressed key off from the command.
          // We cannot choose >1 key off because of multibinding. If we are holding bind 1, and we released a key for bind 2, we would trigger onrelease for bind 1, which is incorrect.
          // NOTE: This means if we somehow miss a key release (because of, say, Scaleform), we won't fire onRelease until the keys are repressed and rereleased!
          if(unpressedKeyCount == 1)
          {
             releasedBinds.AddItem(Bind.Command);
          }
       }
       return releasedBinds.Num();
    }
    // @ROBOT end
    


    Source File:

    Source files are © Robot Entertainment and are unavailable for view or download.

    Back to top of code tabs
  • OMDU Menu Screenshots

    Motivation:

    When I started working on Orcs Must Die! Unchained, I followed the coding style that was already in-place. Each HUD element or menu received its own AS2 class. Each UI element would either receive data via function calls from Unreal or by calling helper functions defined in a static helper function class which were bound to Unreal functions.

    However, as development continued, our UI required increasingly complex UI elements.


    Design:





    Code:

    Localizer:

    
    class Text_Localizer extends Object
    	config(Game);
    
    //----------------------------------------------------------------------------------------------------------
    enum Language
    {
        /* 0*/LANGUAGE_ENG,
        /* 1*/LANGUAGE_ESM,
        /* 2*/LANGUAGE_FRA,
        /* 3*/LANGUAGE_XXX
    };
    
    //----------------------------------------------------------------------------------------------------------
    var config Language GameLanguage;
    
    var string localizationFile_ENG;
    var string localizationFile_ESM;
    var string localizationFile_FRA;
    var string localizationFile_XXX;
    
    
    //----------------------------------------------------------------------------------------------------------
    static function string ConvertLanguageToString( Language Lang )
    {
        local string LanguageString;
    
        switch( Lang )
        {
        case LANGUAGE_ENG:
            LanguageString = "English";
            break;
        case LANGUAGE_ESM:
            LanguageString = "Espanol";
            break;
        case LANGUAGE_FRA:
            LanguageString = "Francais";
            break;
        case LANGUAGE_XXX:
            default:
            LanguageString = "Test Language Xx";
        }
    
        return LanguageString;
    }
    
    //----------------------------------------------------------------------------------------------------------
    static function string GetLocalizedStringWithName( string sectionName, string stringName )
    {
        local string currentFile;
    
        switch( Default.GameLanguage )
        {
        case LANGUAGE_ENG:
            currentFile = Default.localizationFile_ENG;
            break;
        case LANGUAGE_ESM:
            currentFile = Default.localizationFile_ESM;
            break;
        case LANGUAGE_FRA:
            currentFile = Default.localizationFile_FRA;
            break;
        case LANGUAGE_XXX:
            default:
            currentFile = Default.localizationFile_XXX;
        }
    
        return ParseLocalizedPropertyPath( currentFile $ "." $ sectionName $ "." $ stringName );
    }
    
    //----------------------------------------------------------------------------------------------------------
    static function Language GetCurrentLocalizationLanguage()
    {
        return Default.GameLanguage;
    }
    
    //----------------------------------------------------------------------------------------------------------
    static function string GetCurrentLocalizationName()
    {
        return ConvertLanguageToString( GetCurrentLocalizationLanguage() );
    }
    
    //----------------------------------------------------------------------------------------------------------
    static function SetLocalizationLanguage( Language newLanguage )
    {
    	Default.GameLanguage = newLanguage;
    	StaticSaveConfig();
    }
    
    //----------------------------------------------------------------------------------------------------------
    static function array< string > GetSupportedLanguages()
    {
        local array< string > SupportedLanguages;
        local int i;
    
        for( i = 0; i < Language.EnumCount - 1; ++i )
        {
            SupportedLanguages.AddItem( ConvertLanguageToString( Language( i ) ) );
        }
    
        return SupportedLanguages;
    }
    
    DefaultProperties
    {
        localizationFile_ENG = "SSG_ENG"
        localizationFile_ESM = "SSG_ESM"
        localizationFile_FRA = "SSG_FRA"
        localizationFile_XXX = "SSG_XXX"
    }
    

    Example Localization File:

    
    [SSG_Main_Menu]
    
    menuButtonNewGame="New Game"
    menuButtonContinueGame="Continue Game"
    menuButtonOptions="Options"
    menuButtonCredits="Credits"
    menuButtonQuit="Quit"
    
    
    [SSG_Options_Menu]
    
    menuTitleOptions="Options"
    menuButtonOptionsVideo="Video"
    menuButtonOptionsAudio="Audio"
    menuButtonOptionsGameplay="Gameplay"
    menuButtonBackMainMenu="Back"
    
    menuTitleVideoOptions="Video"
    menuLabelVideoSettingResolution="Resolution"
    menuLabelVideoSettingGraphicsLevel="Graphics Quality"
    menuLabelVideoSettingFullscreen="Fullscreen"
    menuLabelVideoSettingGamma="Gamma"
    menuButtonCancel="Cancel"
    menuButtonAccept="Accept"
    menuStepperGraphicsLevel0="Lowest"
    menuStepperGraphicsLevel1="Low"
    menuStepperGraphicsLevel2="Medium"
    menuStepperGraphicsLevel3="High"
    menuStepperGraphicsLevel4="Highest"
    
    menuTitleAudioOptions="Audio Options"
    menuLabelAudioVolumeMusic="Music Volume"
    menuLabelAudioVolumeSound="Sound Volume"
    menuLabelAudioVolumeVoice="Voice Volume"
    
    menuTitleGameOptions="Gameplay Options"
    menuLabelGameLanguage="Language"
    menuLabelGameRumble="Rumble On"
    menuLabelGameGoreLevel="Gore Level"
    menuLabelGameGoreMaximum="Maximum"
    


    Source File:

    Source files are © Robot Entertainment and are unavailable for view or download.

    Back to top of code tabs
 

Source:

Full source is © Robot Entertainment and is unavailable for view or download.