Tutorial Coding Creating a quest

Users who are viewing this thread

Gaktan

Recruit
Creating and registering the CampaignBehavior

Before creating a quest, you need to create a new TaleWorlds.CampaignSystem.CampaignBehavior Class.
(Some quests use a TaleWorlds.CampaignSystem.CampaignBehavior.SaveableCampaignBehaviorTypeDefiner Class as well, but not sure what this is actually used for). Both of these will help serialize your quests to be able to save/load them, this will be done completely automatically.
This is not known yet if you need one CampaignBehavior per quest, but that's what TaleWorlds seems to be doing, so make sure you create one CampaignBehavior per quest.

First create a class called MyCampainBehavior that inherits from CampaignBehaviorBase, and implement all the abstract functions.

In your submodule class, override the OnGameStart function. This function is called when the game starts (from loading or after character creation), this is where we are going to register all your campaign behaviors:
C#:
protected override void OnGameStart(Game inGame, IGameStarter inGameStarter)
{
    base.OnGameStart(inGame, inGameStarter);

    // Make sure we only load the quest in StoryMode.
    // This is not necessary, only if don't want your quest to start in a a game mode that's not the StoryMode (Like multiplayer)
    if (!(inGame.GameType is StoryMode.CampaignStoryMode))
        return;

    CampaignGameStarter campaignGameStarter = (CampaignGameStarter) inGameStarter;
    campaignGameStarter.AddBehavior(new MyCampainBehavior());
}

Here is an example of a quest that get triggered when entering a settlement:
Note: In this example I decided to trigger the quest when the player enters a settlement, but in theory you could create and start a quest anywhere you'd like. It doesn't have to be in the CampainBehavior class.

C#:
class MyCampainBehavior : CampaignBehaviorBase
{
    public override void RegisterEvents()
    {
        CampaignEvents.SettlementEntered.AddNonSerializedListener(this, SettlementEnteredEvent);
    }

    // Event triggered when any party enters a settlement
    private void SettlementEnteredEvent(MobileParty arg1, Settlement arg2, Hero arg3)
    {
        if (arg3 != Hero.MainHero)
            return;

        // Do not repeat this quest. (Currently don't know how to check completed quests)
        if (Campaign.Current.QuestManager.Quests.Any(q => q.GetType() == typeof(MyCampainQuest)))
            return;

        Hero quest_giver = Hero.MainHero;

        // Find nearest village around the player that is not the current settlement
        IEnumerable<Settlement> settlements = Settlement.FindSettlementsAroundPosition(quest_giver.GetPosition().AsVec2, 200.0f)
            .OrderBy(s => s.GetTrackDistanceToMainAgent())
            .Where(s => s.IsVillage && s != arg2);

        // Start the quest in that settlement
        Settlement settlement = settlements.First();
        if (settlement != null)
            new MyCampainQuest(quest_giver, settlement).StartQuest();
    }

    public override void SyncData(IDataStore dataStore)
    {
        // Not sure what this does. Multiplayer maybe?
    }

    public class MyCampainQuestBehaviorTypeDefiner : CampaignBehaviorBase.SaveableCampaignBehaviorTypeDefiner
    {
        public MyCampainQuestBehaviorTypeDefiner()
            // This number is the SaveID, supposed to be a unique identifier
            : base(846_000_000)
        {
        }

        protected override void DefineClassTypes()
        {
            // Your quest goes here, second argument is the SaveID
            AddClassDefinition(typeof(MyCampainBehavior.MyCampainQuest), 1);
        }
    }
}



Creating the quest itself

To create a class create a new inner inter class to MyCampainBehavior called MyCampainQuest that inherits from QuestBase.
You will need to override a few abstract methods but they should be pretty easy to understand.

SetDialogs() is the function where you will setup all the quest's dialogs. This should be called twice. Once in the constructor, and once in InitializeQuestOnGameLoad. This is not known why, but that's what TaleWorlds seems to be doing (need more info).
The way dialog work should need a whole tutorial of their own, I am not going to go into too much details. I added some comments in the code below to hopefully make it understandable.

In the following example, I created a simple dialog that can go two ways:
  1. Positive: The quests ends successfully
  2. Negative: A new task is added (travel a distance of x units) before the quest can be completed

C#:
// Could also use StoryModeQuestBase, but it's a lot more restrictive
internal class MyCampainQuest : QuestBase
{
    [SaveableField(1)]
    private bool _metAntiImperialMentor;
    [SaveableField(2)]
    protected float _TotalDistanceTraveled;

    // Save JournalLog so we can update it or just keep track of it
    protected JournalLog _Task1;
    protected readonly int _DistanceToTravel = 100;
    protected Vec2 _PreviousPos;


    private TextObject _StartQuestLog
    {
        get
        {
            TextObject parent = new TextObject("Find and meet {HERO.LINK} and tell him he is your dad. He is currently in {SETTLEMENT}.");
            StringHelpers.SetCharacterProperties("HERO", StoryMode.StoryMode.Current.MainStoryLine.AntiImperialMentor.CharacterObject, null, parent);
            parent.SetTextVariable("SETTLEMENT", StoryMode.StoryMode.Current.MainStoryLine.AntiImperialMentor.CurrentSettlement.EncyclopediaLinkWithName);
            return parent;
        }
    }

    private TextObject _EndQuestLog
    {
        get
        {
            TextObject parent = new TextObject("You talked with {HERO.LINK}.");
            StringHelpers.SetCharacterProperties("HERO", StoryMode.StoryMode.Current.MainStoryLine.AntiImperialMentor.CharacterObject, null, parent);
            return parent;
        }
    }

    private TextObject _YouShouldRunLog
    {
        get
        {
            TextObject parent = new TextObject("Your \"dad\" wants you to run {DISTANCE} units.");
            parent.SetTextVariable("DISTANCE", _DistanceToTravel);
            return parent;
        }
    }

    private TextObject _YouShouldRunText
    {
        get
        {
            TextObject parent = new TextObject("How dare you? How about you run {DISTANCE} units, just for fun.");
            parent.SetTextVariable("DISTANCE", _DistanceToTravel);
            return parent;
        }
    }

    public override TextObject Title
    {
        get
        {
            TextObject parent = new TextObject("Meet with your \"dad\" {HERO.NAME}");
            StringHelpers.SetCharacterProperties("HERO", StoryMode.StoryMode.Current.MainStoryLine.AntiImperialMentor.CharacterObject, (TextObject) null, parent);
            return parent;
        }
    }

    public override bool IsRemainingTimeHidden
    {
        get
        {
            return false;
        }
    }

    public MyCampainQuest(Hero questGiver, Settlement settlement)
        : base("my_campain_story_mode_quest", questGiver, duration: CampaignTime.DaysFromNow(20), rewardGold: -100 )
    {
        _metAntiImperialMentor    = false;
        SetDialogs();
        HeroHelper.SpawnHeroForTheFirstTime(StoryMode.StoryMode.Current.MainStoryLine.AntiImperialMentor, settlement);
        AddTrackedObject(settlement);

        AddLog(_StartQuestLog);

        AddTwoWayContinuousLog(new TextObject("This is a two way continuous Log. We are not going to use it."), new TextObject("Task 2"), 0, 10);
    }

    protected override void RegisterEvents()
    {
        CampaignEvents.TickEvent.AddNonSerializedListener(this, Tick);
    }

    protected void Tick(float dt)
    {
        if (_Task1 != null && !_Task1.HasBeenCompleted())
        {
            // Compute total distance traveled on the map.
            Vec2 current_pos        = Hero.MainHero.GetPosition().AsVec2;
            _TotalDistanceTraveled    += current_pos.Distance(_PreviousPos);

            _Task1.UpdateCurrentProgress((int)Math.Floor(_TotalDistanceTraveled));

            _PreviousPos = current_pos;
        }
    }

    protected override void InitializeQuestOnGameLoad()
    {
        SetDialogs();
    }

    protected override void SetDialogs()
    {
        // Dialog to offer the quest?
        OfferDialogFlow = DialogFlow.CreateDialogFlow(QuestManager.QuestOfferToken, 100)
            .NpcLine(new TextObject("{=!}You shouldn't see this."))
            .Condition(() => Hero.OneToOneConversationHero == QuestGiver)
            .CloseDialog();

        // Dialog to discuss the quest?
        DiscussDialogFlow = DialogFlow.CreateDialogFlow(QuestManager.QuestDiscussToken, 100)
            .NpcLine(new TextObject("{=!}You shouldn't see this."))
            .Condition(() => Hero.OneToOneConversationHero == QuestGiver)
            .CloseDialog();

        // Dialog when starting a conversation with the AntiImperialMentor if we have NOT met him
        DialogFlow unmet_dialog = DialogFlow.CreateDialogFlow(QuestManager.NpcLordStartToken, 110)
            .NpcLine(new TextObject("So. Who are you, and what brings you to me?"))
            // This whole dialog will only start if the following conditions are met
            .Condition(() => Hero.OneToOneConversationHero != null && Hero.OneToOneConversationHero == StoryMode.StoryMode.Current.MainStoryLine.AntiImperialMentor && !_metAntiImperialMentor)

            .PlayerLine(new TextObject("Hi dad, it's me, your boy."))
            .NpcLine(new TextObject("Is that true? Well, that is interesting."))
            // Player option: Starts with BeginPlayerOptions() and ends with EndPlayerOptions()
            .BeginPlayerOptions()
                // PlayerOption will trigger the following dialog flow up to the next CloseDialog() when selected
                .PlayerOption(new TextObject("Yes, it's really me dad."), null)
                    .NpcLine(new TextObject("Good. I don't have anything to say."))
                    .Consequence(new ConversationSentence.OnConsequenceDelegate(GoodBoy))
                .CloseDialog()
                .PlayerOption(new TextObject("Actually I was just joking."), null)
                    .Consequence(new ConversationSentence.OnConsequenceDelegate(BadBoy))
                    .NpcLine(_YouShouldRunText)
                    .NpcLine(new TextObject("Now, GO!"))
                .CloseDialog()
            .EndPlayerOptions()
            .CloseDialog();

        // Dialog when starting a conversation with the AntiImperialMentor if we have met him
        DialogFlow met_dialog = DialogFlow.CreateDialogFlow(QuestManager.NpcLordStartToken, 110)
            .NpcLine(new TextObject("So, did you run like I told you to?"))
            // This whole dialog will only start if the following conditions are met
            .Condition(() => Hero.OneToOneConversationHero != null && Hero.OneToOneConversationHero == StoryMode.StoryMode.Current.MainStoryLine.AntiImperialMentor && _metAntiImperialMentor)

            .BeginPlayerOptions()
                .PlayerOption(new TextObject("Yes I did."), null)
                .Condition(() => _Task1.HasBeenCompleted())
                    .NpcLine(new TextObject("Ok Great. Now leave"))
                    .Consequence(() => Campaign.Current.ConversationManager.ConversationEndOneShot += new Action(CompleteQuestWithSuccess))
                .CloseDialog()
                .PlayerOption(new TextObject("Actuall no, I didn't."), null)
                    .NpcLine(new TextObject("Don't come back until you did."))
                .CloseDialog()
            .EndPlayerOptions()
            .CloseDialog();

        Campaign.Current.ConversationManager.AddDialogFlow(unmet_dialog, this);
        Campaign.Current.ConversationManager.AddDialogFlow(met_dialog, this);
    }

    private void GoodBoy()
    {
        AddLog(new TextObject("You were a good boy"));
        Campaign.Current.ConversationManager.ConversationEndOneShot += new Action(CompleteQuestWithSuccess);
        _metAntiImperialMentor = true;
    }

    private void BadBoy()
    {
        AddLog(new TextObject("You were a bad boy"));
        _Task1 = AddDiscreteLog(_YouShouldRunLog, new TextObject("Units ran."), currentProgress: 0, targetProgress: _DistanceToTravel);
        _PreviousPos            = Hero.MainHero.GetPosition().AsVec2;
        _TotalDistanceTraveled    = 0.0f;
        _metAntiImperialMentor    = true;
    }

    // Called after the quest is finished
    protected override void OnFinalize()
    {
        base.OnFinalize();
        AddLog(_EndQuestLog);
    }
}



Journal logs

A journal log is an entry in the quest log that you see when opening the Quest Book. It can be used to display a text message, or a progression bar.
There are multiple types of journal logs available:
  • Blue: regular log, it just displays text. AddLog(new TextObject("This is a regular Log"));
  • Turquoise: two way continuous log. AddTwoWayContinuousLog(task_text, new TextObject("Task 2"), 0, 10);
  • Purple: discrete log, shows a progression bar, AddDiscreteLog(_YouShouldRunLog, new TextObject("Units ran."), currentProgress: 0, targetProgress: _DistanceToTravel);
WYbOA3O.png


This screenshot also shows a couple more things:
  • Orange: Quest title
  • Pink: Time remaining, can be disabled when overriding IsRemainingTimeHidden
  • Green: Quest giver



Quest events

As explained earlier, triggering the quest is as easy as creating a new MyCampainQuest object and calling StartQuest().
You can pretty much start a new quest whenever you want, as long as the game was completely loaded first. (Exact starting point not known).
If you try to create a quest before that point, it will actually create the quest but it won't be visible. The quest logic will still work though.

You can complete the quest by calling either one of these functions:
  • CompleteQuestWithBetrayal
  • CompleteQuestWithCancel
  • CompleteQuestWithFail
  • CompleteQuestWithTimeOut
  • CompleteQuestWithSuccess (protected)
Which in return will call the following events that can be overridden from the QuestBase class:
This could be used to start additional quests, update the quest log and so on.
  • OnCanceled()
  • OnFailed()
  • OnBetrayal()
  • OnCompleteWithSuccess()
  • OnFinalize()
  • OnStartQuest()
  • OnTimedOut()
 
Last edited:
Same problem as above ^ unable to resolve CampaignStoryMode and "StoryMode" in QuestBase. Do you have a GitHub repository available?
 
Oops, sorry about that, I can't really share this on GitHub because that's just the project I use to mess around with the code, so it's very messy
I referenced all the game's DLLs so I didn't have this issue

I believe the bit of code you are mentioning is the check in OnGameStart right?
I believe this check is not necessary for creating a quest, that's just to make sure you only create the quests if your gamemode is the native mode. You don't want to create the quests for other gamemodes for instance.

If you still want this check, you will need to reference the following DLL from the StoryMode module:
Modules\StoryMode\bin\Win64_Shipping_Client\StoryMode.dll

Edit:
I edited the code and added a comment to make it clearer
 
Last edited:
Just a verry short question, as my coding knowledge is basic at best. Is there / or will there be a simple way of creating own quests? Like, I don't know, some kind of framework or guideline enabling potentialy talented writers with limited to no coding experience to create own quests.
 
Should be added somewhere on the forum as useful resources for modders. Not in the process to create quest so far but will be happy to find this thread again when i'll be working on one.
 
Just a verry short question, as my coding knowledge is basic at best. Is there / or will there be a simple way of creating own quests? Like, I don't know, some kind of framework or guideline enabling potentialy talented writers with limited to no coding experience to create own quests.

I believe this is fairly simple, there is relatively few lines of code to be honest. The most wordy part of this guide is the dialog flow. There might be a way to load dialog flows with XMLs, but I believe there is currently no way to do that. So even that you would have to do by yourself.
Otherwise for the quests logic, no other way around this sadly (But then again, not that difficult, might just turn into spaghetti if not made properly)
 
So, I want to add a quest to my mod, but I'm worried about this "SaveId" thing. What if there is another mod with the same SaveId. If a user installs two mods with the same SaveId, can they damage the user's save file? Maybe we should use something like a hash sum function to generate SaveIds?
 
This save ID thing is not very clear for me either, I believe TaleWorlds generate theirs at compile time so we can't really see what they do from decompiling the libraries.
Maybe a good way would be to hash the full name of your mod (com.yourcompany.yourapp)
Or maybe the game already handles IDs per submodules so you can use any ID you want
This is an area that still needs a lot of research
 
Don't forget the ID is an int. Many tools i see to make or have save Ids use only positive numbers, so we loose half of the range offered.

The hash of the full name could be a good idea but it's quite random too. Even if the chance you use the same ID of someone else is low, it could still happen. I personally think it would be better to have something structured, a sort of convention everyone use to avoid this randomness, so something based on real int number more than a Hash. (Even if i use a hash so far ... Cause well nothing better for now.)
 
Back
Top Bottom