Modding Quests

From Starsector Wiki
Jump to navigation Jump to search


Icon cross.png
At least two versions out of date. Last verified for version 0.9.1. Please refer to Version History and update this page.

Description

A quest is a mission that is offered during the campaign (not to be confused with Missions). It consists of one or more stages. For example, a quest to deliver goods to a specific market is a single stage. The quest in which the player acquires the Planetary Shield building, by comparison, requires the player travel to multiple points during multiple stages.

Creating a quest requires some knowledge of Java or Kotlin(what is this?) and cannot be done only by modifying json and csv files.

Below here, this page describes the pre-0.95.0 way of creating quests, which still works but there is a new possible approach. The Internal Affairs mod is an excellent example of the 0.95.0 approach to building quests.


The bulk of a typical quest is contained in two parts; the BarEvent and the BaseIntelPlugin. Additionally, an InteractionDialog may be used to display dialogs outside of the bar.

Note: "quest" is a loose definition. A quest could be offered randomly when the player enters a system, with no BarEvent and direct them to a specific location without using Intel either; just InteractionDialogs. However, this page is going to cover the most common and expected type, which is offered at a bar and tracked using Intel.

We're going to build a quest where the player is told to travel to a random planet - a simple "go here" quest. It will only be completable once.

Structure

The three components above (BarEvent, BaseIntelPlugin, and possibly InteractionDialog) are technically all that is needed to build a quest (plus a bit more code to get the game to recognize them). However, it makes it much easier to read and manage if we have a "coordinator" class as well.

  • BarEvent defines what the player sees when they enter the bar, as well as the dialog where they can accept or reject the quest offer.
  • BarEventCreator is used by the game to create the BarEvent when needed.
  • BaseModPlugin is needed to actually add your BarEvent to the game.
  • A coordinator class tracks the player's progress, holds constant variables, and moves the player from one stage to another. It's best to make this a static class that holds no state; this will be explained later.
  • BaseIntelPlugin displays the player's progress in the Intel screen and shows them what to do next.
  • InteractionDialogPlugin can be used to show a dialog outside of the bar. For example, we may want to display "The cargo is unloaded and the manager hands you your payment of 100,000 credits" when the player lands on a planet. We're not limited to displaying dialogs on planets, however.
  • And finally, a BaseCampaignPlugin can be useful to show InteractionDialogs.

BarEvent

The place where it all starts; at the bar. Quests don't need to start at the bar, but that is the common place and is where the example quest will start.

To create our bar event, we'll need to choose and implement the interface PortsideBarEvent. There are a few ready-made implementations to chose from.

BaseBarEvent - Bare-bones, abstract implementation of PortsideBarEvent. Very flexible.

BaseBarEventWithPerson - Abstract implementation of BaseBarEvent that adds a person for the user to talk to with a customizable gender, job, faction, portrait, etc.

BaseGetCommodityBarEvent - Abstract implementation for a quest where the user needs to bring some commodity somewhere. "A concerned-looking man is sitting at a corner table..." is an example of such a quest.

We're going to get our quest from a person at the bar, so we're going to take BaseBarEventWithPerson and create a subclass of it called DemoBarEvent. It can be placed anywhere.

Now, there are a few methods that we'll want to override.

 
public class DemoBarEvent extends BaseBarEventWithPerson {
    /**
     * True if this event may be selected to be offered to the player,
     * or false otherwise.
     */
    public boolean shouldShowAtMarket(MarketAPI market) {
        // add any conditions you want
        return super.shouldShowAtMarket(market) && !getMarket().getFactionId().equals("luddic_path");
    }

    /**
     * Set up the text that appears when the player goes to the bar
     * and the option for them to start the conversation.
     */
    @Override
    public void addPromptAndOption(InteractionDialogAPI dialog, Map<String, MemoryAPI> memoryMap) {
        // Calling super does nothing in this case, but is good practice because a subclass should
        // implement all functionality of the superclass (and usually more)
        super.addPromptAndOption(dialog, memoryMap);
        regen(dialog.getInteractionTarget().getMarket()); // Sets field variables and creates a random person

        // Display the text that will appear when the player first enters the bar and looks around
        dialog.getTextPanel().addPara("A small crowd has gathered around a " + getManOrWoman() + " who looks to be giving " +
                "some sort of demonstration.");

        // Display the option that lets the player choose to investigate our bar event
        dialog.getOptionPanel().addOption("See what the demonstration is about", this);
    }

    /**
     * Called when the player chooses this event from the list of options shown when they enter the bar.
     */
    @Override
    public void init(InteractionDialogAPI dialog, Map<String, MemoryAPI> memoryMap) {
        super.init(dialog, memoryMap);
        // Choose where the player has to travel to
        DemoQuestCoordinator.initQuest();

        // If player starts our event, then backs out of it, `done` will be set to true.
        // If they then start the event again without leaving the bar, we should reset `done` to false.
        done = false;

        // The boolean is for whether to show only minimal person information. True == minimal
        dialog.getVisualPanel().showPersonInfo(person, true);

        // Launch into our event by triggering the "INIT" option, which will call `optionSelected()`
        this.optionSelected(null, OptionId.INIT);
    }

    /**
     * This method is called when the player has selected some option for our bar event.
     *
     * @param optionText the actual text that was displayed on the selected option
     * @param optionData the value used to uniquely identify the option
     */
    @Override
    public void optionSelected(String optionText, Object optionData) {
        if (optionData instanceof OptionId) {
            // Clear shown options before we show new ones
            dialog.getOptionPanel().clearOptions();

            // Handle all possible options the player can choose
            switch ((OptionId) optionData) {
                case INIT:
                    // The player has chosen to walk over to the crowd, so let's tell them what happens.
                    dialog.getTextPanel().addPara("You walk over and see that the " + getManOrWoman() +
                            " is showing the crowd how to create quest mods for a video game.");
                    dialog.getTextPanel().addPara("It seems that you can learn more by traveling to " +
                            DemoQuestCoordinator.getDestinationPlanet().getName());

                    // And give them some options on what to do next
                    dialog.getOptionPanel().addOption("Take notes and decide to travel to learn more", OptionId.TAKE_NOTES);
                    dialog.getOptionPanel().addOption("Leave", OptionId.LEAVE);
                    break;
                case TAKE_NOTES:
                    // Tell our coordinator class that the player just started the quest
                    DemoQuestCoordinator.startQuest();

                    dialog.getTextPanel().addPara("You take some notes. Quest mods sure seem like a lot of work...");
                    dialog.getOptionPanel().addOption("Leave", OptionId.LEAVE);
                    break;
                case LEAVE:
                    // They've chosen to leave, so end our interaction. This will send them back to the bar.
                    // If noContinue is false, then there will be an additional "Continue" option shown
                    // before they are returned to the bar. We don't need that.
                    noContinue = true;
                    done = true;

                    // Removes this event from the bar so it isn't offered again
                    BarEventManager.getInstance().notifyWasInteractedWith(this);
                    break;
            }
        }
    }

    enum OptionId {
        INIT,
        TAKE_NOTES,
        LEAVE
    }
}


Now that we have created our quest offer at the bar, we need to actually tell Starsector to offer it at the bar.

BarEventCreator

The BarEventCreator is a small class Starsector uses to, unsurprisingly, create bar events. The game keeps a list of these creators and periodically creates bar events for the player to find.

There is an abstract default implementation of BarEventCreator that we will use; BaseBarEventCreator. For missions that should be offered frequently and constantly, DeliveryBarEventCreator is also an option.

This class can be implemented with minimal code.

public class DemoBarEventCreator extends BaseBarEventCreator {
    @Override
    public PortsideBarEvent createBarEvent() {
        return new DemoBarEvent();
    }
}

There are additional methods (viewable here) that can be overriden to customize how frequently and/or rarely the player should encounter the event.

BaseModPlugin

A BaseModPlugin is what we use to tell Starsector what to load and when. It has hooks (methods) that are called by the game itself.

If this guide has been followed sequentially, we now have a BarEvent and a BarEventCreator, but the game doesn't yet know how to add these to a bar. We can use the onGameLoad method to do so.

public class DemoBaseModPlugin extends BaseModPlugin {

    /**
     * Called when the player loads a saved game.
     *
     * @param newGame true if the save game was just created for the first time.
     *                Note that there are a few `onGameLoad` methods that may be a better choice than using this parameter
     */
    @Override
    public void onGameLoad(boolean newGame) {
        super.onGameLoad(newGame);

        BarEventManager barEventManager = BarEventManager.getInstance();

        // If the prerequisites for the quest have been met (optional) and the game isn't already aware of the bar event,
        // add it to the BarEventManager so that it shows up in bars
        if (DemoQuestCoordinator.shouldOfferQuest() && !barEventManager.hasEventCreator(DemoBarEventCreator.class)) {
            barEventManager.addEventCreator(new DemoBarEventCreator());
        }
    }
}

All that's left is to add this to mod_info.json:

"modPlugin":"your.class.package.DemoBaseModPlugin"

and with that, Starsector will add the quest to bars around the sector! Of course, the code won't compile yet, because we still need to add the coordinator class.

Coordinator

A coordinator class handles all of the quest's logic and tracks the player's progress.

Note: This class isn't strictly necessary, as the logic it contains could instead be distributed throughout the rest of the quest's classes, but having one central class makes the quest code easier to understand and follow.

This class should be stateless, meaning that it has no field variables (unless they are read-only). Being stateless has a few advantages.

Despite being stateless, the coordinator is able to track the player's quest progress. It does so by keeping state in the player's save file, rather than in the coordinator class, using either Global.getSector().getMemory() or Global.getSector().getPersistentData(). These work equally well, although persistent data is simpler.

Coordinator classes will not be automatically saved in the player's save game.

Here is a simple implementation for our Demo quest:

/**
 * Coordinates and tracks the state of Demo quest.
 */
class DemoQuestCoordinator {
    /**
     * The tag that is applied to the planet the player must travel to.
     */
    private static String TAG_DESTINATION_PLANET = "Demo_destination_planet";

    static SectorEntityToken getDestinationPlanet() {
        return Global.getSector().getEntityById(TAG_DESTINATION_PLANET);
    }

    static boolean shouldOfferQuest() {
        return true; // Set some conditions
    }

    /**
     * Called when player starts the bar event.
     */
    static void initQuest() {
        chooseAndTagDestinationPlanet();
    }

    /**
     * Player has accepted quest.
     */
    static void startQuest() {
        Global.getSector().getIntelManager().addIntel(new DemoIntel());
    }

    /**
     * Very dumb method that idempotently tags a random planet as the destination.
     */
    private static void chooseAndTagDestinationPlanet() {
        if (getDestinationPlanet() == null) {
            StarSystemAPI randomSystem = Global.getSector().getStarSystems()
                    .get(new Random().nextInt(Global.getSector().getStarSystems().size()));
            PlanetAPI randomPlanet = randomSystem.getPlanets()
                    .get(new Random().nextInt(randomSystem.getPlanets().size()));
            randomPlanet.addTag(TAG_DESTINATION_PLANET);
        }
    }
}

Intel

Starsector's version of a quest log is the Intel Manager.

Intel must implement the IntelInfoPlugin, which is most easily done by creating a subclass of either BaseIntelPlugin or BreadcrumbIntel.

BaseIntelPlugin implements basic intel logic, but nothing more. Choose this for the most flexibility.

BreadcrumbIntel is a subclass of BaseIntelPlugin that adds support for pointing the player to a destination, and optionally showing a source destination and an arrow between them. Has some potentially unwanted default behavior, such as using the "Exploration" tag. Choose this to guide the player to some destination.

A piece of intel has a few different parts.

  • Icon: The quest icon. Vanilla icons are 40x40 px pngs.
  • Name: The current title of the quest, eg "Delivery - Completed". "Name" is part of BreadcrumbIntel, but not BaseIntelPlugin, which requires the title be added to the IntelInfo section manually.
  • Info: Text that appears underneath the name showing a quick objective or status, eg " - 45,000 reward".
  • Small Description: A bit of a misnomer, this is the description panel on the right side of the Intel Manager.
  • Intel Tags: The tag(s) that will appear in the Intel Manager and display this intel if selected.
Anatomy of Intel Manager

Here is our fairly barebones implementation of BreadcrumbIntel

public class DemoIntel extends BreadcrumbIntel {
    public DemoIntel(SectorEntityToken foundAt, SectorEntityToken target) {
        super(foundAt, target);
    }

    @Override
    public String getIcon() {
        return "graphics/icons/intel/player.png";
    }

    @Override
    public String getName() {
        return "Demo Quest" + (DemoQuestCoordinator.isComplete() ? " - Completed" : "");
    }

    /**
     * The small list entry on the left side of the Intel Manager
     *
     * @param info the text area that shows the info
     * @param mode where the info is being shown
     */
    @Override
    public void createIntelInfo(TooltipMakerAPI info, ListInfoMode mode) {
        // The call to super will add the quest name, so we just need to add the summary
        super.createIntelInfo(info, mode);

        info.addPara("Destination: %s", // text to show. %s is highlighted.
                3f, // padding on left side of text. Vanilla hardcodes these values so we will too
                super.getBulletColorForMode(mode), // color of text
                Misc.getHighlightColor(), // color of highlighted text
                DemoQuestCoordinator.getDestinationPlanet().getName()); // highlighted text

        // This will display in the intel manager like:
        // Demo Quest
        //     Destination: Ancyra
    }

    @Override
    public void createSmallDescription(TooltipMakerAPI info, float width, float height) {
        info.addImage("graphics/illustrations/fly_away.jpg", // path to sprite
                width,
                128, // height
                10f); // left padding

        info.addPara("You learned a little about quest design at a bar on " + foundAt.getName() +
                        " and are traveling to %s to learn more.", // text to show. %s is highlighted.
                10f, // padding on left side of text. Vanilla hardcodes these values so we will too
                Misc.getHighlightColor(), // color of highlighted text
                target.getName()); // highlighted text

        // The super call adds the text from `getText()` (which we'll leave empty)
        // and then adds the number of days since the quest was acquired, which is
        // typically the bottom-most thing shown. Therefore, we'll make the call to
        // super as the last thing in this method.
        super.createSmallDescription(info, width, height);
    }

    /**
     * Return whatever tags your quest should have. You can also create your own tags.
     */
    @Override
    public Set<String> getIntelTags(SectorMapAPI map) {
        return new HashSet<>(Arrays.asList(Tags.INTEL_EXPLORATION, Tags.INTEL_STORY));
    }
}


Interaction Dialogs

An interaction dialog is simply a dialog window with options. Bar events are displayed using interaction dialogs; in order to display text, we used dialog.getTextPanel().addPara(text). dialog is a reference to the bar event's interaction dialog.

However, interaction dialogs can also be displayed on their own at any point, not just in bars, and we're going to leverage that to give the end of our demo quest a custom dialog that appears instead of the normal planet dialog.

There are two different interfaces/classes to choose from.

We don't need pages of options, so we'll implement this using InteractionDialogPlugin.

public class DemoEndDialog implements InteractionDialogPlugin {
    protected InteractionDialogAPI dialog;

    /**
     * Called when the dialog is shown.
     *
     * @param dialog the actual UI element being shown
     */
    @Override
    public void init(InteractionDialogAPI dialog) {
        // Save the dialog UI element so that we can write to it outside of this method
        this.dialog = dialog;

        // Launch into our event by triggering the invisible "INIT" option,
        // which will call `optionSelected()`
        this.optionSelected(null, DemoBarEvent.OptionId.INIT);
    }

    /**
     * This method is called when the player has selected some option on the dialog.
     *
     * @param optionText the actual text that was displayed on the selected option
     * @param optionData the value used to uniquely identify the option
     */
    @Override
    public void optionSelected(String optionText, Object optionData) {
        if (optionData instanceof OptionId) {
            // Clear shown options before we show new ones
            dialog.getOptionPanel().clearOptions();

            // Handle all possible options the player can choose
            switch ((OptionId) optionData) {
                // The invisible "init" option was selected by the init method.
                case INIT:
                    dialog.getTextPanel().addPara("As soon as your shuttle touches down, your mind is filled " +
                            "with knowledge of how to create quest mods. How amazingly convenient.");
                    dialog.getTextPanel().addPara("You resolve to go off and create your very own!");

                    // The quest is completed as soon as the plot is resolved
                    DemoQuestCoordinator.completeQuest();

                    dialog.getOptionPanel().addOption("Leave", OptionId.LEAVE);
                    break;
                case LEAVE:
                    dialog.dismiss();
                    break;
            }
        }
    }

    enum OptionId {
        INIT,
        LEAVE
    }

    // The rest of the methods must exist, but can be ignored for our simple demo quest.
    @Override
    public void optionMousedOver(String optionText, Object optionData) {
        // Can add things like hints for what the option does here
    }

    @Override
    public void advance(float amount) {
    }

    @Override
    public void backFromEngagement(EngagementResultAPI battleResult) {
    }

    @Override
    public Object getContext() {
        return null;
    }

    @Override
    public Map<String, MemoryAPI> getMemoryMap() {
        return null;
    }
}


We now have a dialog that nearly wraps up the quest; all that's left is to show it to the player. For that, we'll create our own Campaign Plugin.

Showing a Dialog

To finish up our quest, we need to show a dialog when the player interacts with the destination planet.

There are two main options for doing this; CampaignPlugin and rules.csv.

  • BaseCampaignPlugin is the programmatic way that trades the complexity of rules.csv for the complexity of code. This method makes the quest mod arguably easier to understand, as there's less "magic"; any IDE will be able to see that an interaction dialog is being created in this class, whereas it's harder to determine how a dialog is being launched if it's done by rules.csv.
  • rules.csv; ah, rules.csv. Launching a dialog using this is the best choice for mod compatibility, as it will allow other modders to override your dialog launch trigger by the other mod using rules.csv.
    • To give an example, the Gates Awakened mod originally showed a dialog whenever the player interacted with a gate, triggered using a CampaignPlugin. However, Vayra's Sector had a special interaction that could occurr occasionally when the player interacted with a gate, triggered using rules.csv. Because the CampaignPlugin was always overriding anything in rules.csv, the Vayra's Sector interaction was never triggered as long as both mods were enabled. The fix was for Gates Awakened to trigger its dialog using rules.csv, and for Vayra's Sector to set the dialog trigger to a higher priority than the one in Gates Awakened.



The game chooses which interaction dialog to use to handle a player interaction by:

  1. First looking at all CampaignPlugins and seeing if any can handle the interaction.
  2. If multiple CampaignPlugins can handle the interaction, it chooses the one with the highest priority, as determined by PickPriority.
  3. If multiple plugins have the same priority, it chooses one somehow ("in an undefined way").
  4. If the plugin that is picked is RuleBasedInteractionDialogPluginImpl, then it will look at all possibilities in rules.csv and pick the one with the highest score.

Campaign Plugin

TODO