Difference between revisions of "Osiris Design Patterns"
m (→Breaking off a Loop) |
(→Game Events) |
||
Line 40: | Line 40: | ||
== Game Events == | == Game Events == | ||
+ | These events are thrown when the game engine has finished initialising, but before any level has been loaded. This means that even global objects are not accessible at this point, and many Osiris calls and queries will fail because the necessary context is missing. The main purpose of these events is to allow for generic story initialisation. | ||
+ | |||
+ | Two events exist in this category. | ||
+ | ; [[Osiris/API/GameEventSet|GameEventSet]] : This event also existed in DOS1. As the documentation page mentions, it is only called with one particular parameter at this time. | ||
+ | ; [[Osiris/API/GameModeStarted|GameModeStarted]] : This event is new in DOS2. It is thrown right after the previous one. Its parameter indicates whether the game has been started in campaign (adventure/store), game master, or arena mode. You can use this to only initialise parts of story that are relevant to the selected mode, by making all goals children of a goal that only completes if the desired game mode started. As of DOS2 Patch 6, all story goals distributed with the main game use this functionality to prevent interference with mods that can be used with more than one game mode. | ||
== Level Events == | == Level Events == |
Revision as of 15:47, 14 January 2018
Contents
Introduction
Here we give an overview of a number of common design patterns that are useful when programming in Osiris.
Program Initialisation
There are several ways to initialise Osiris code, each with their own use-cases. A safe and very common pattern is as follows:
- Make goals that contain global helper functionality top-level goals, so they are activated as soon as Osiris is initialised. Either do not rely on functionality from other goals during the initialisation of these goals, or ensure the goals on which you depend are initalised before yours:
- they should also be top-level goals (either in your Mod, or in another Mod)
- their name should be alphabetically sorted before your goal's name. Underscores are an often used trick to get to the front.
- Per level, create one goal (e.g., named the same as this level) that completes when the RegionStarted event for this level is triggered.
- For every quest in that level, create a sub-goal for the level goal.
We will now have a more detailed look at the various initialisation mechanics.
INIT section
The actions in an INIT section of a goal execute as soon as the goal becomes active. Its main uses are
- Define databases used in that same goal. E.g. in a goal about crime bribes, define databases to look up the bribe range for characters of a certain level.
- Define databases used by other goals, but related to the current goal. E.g. in a goal about a certain quest, define the dialogs of the NPCs specific to this quest.
- Initialise the default state of objects related to the current goal. E.g. in a goal about a quest, set a character off-stage in case it should appear only at a specific point in the quest.
Direct Dependencies
If you call PROCs from your INIT code, ensure that these are defined in goals that are guaranteed to already have been intialised. You will not get a compile-time nor run-time error if you call a routine declared in a goal that is not yet active; the call will simply not do anything.
Example: consider the screenshot of the Goal Initialisation and Completion section. Calling a PROC defined in the FTJ_Origins_Ifan goal from the INIT code (or from a PROC called from the INIT code) of the AtaractionArtifacts goal will silently fail here. The reason is that both goals are subgoals of the _Start goal, and goals are initialised and activated in alphabetical order. So when the AtaractionArtifacts goal gets initialsed, the FTJ_Origins_Ifan goal is not yet active.
Indirect Dependencies
If you declare databases in your INIT code, or in a PROC called from your INIT code, and another goal is supposed to react to this database declaration, ensure that this other goal gets initialised before you.
Example: you define a DB_ShovelArea() fact in your goal to create a secret dirt pile. The goal that handles the setup of these secret dirt piles is __GLO_Shovel in the Shared Mod. It sets up these dirt piles by declaring event handlers such as
IF DB_ShovelArea((TRIGGERGUID)_Trigger, (STRING)_Reward, (ITEMGUID)_DirtMound) THEN ..
This means that if you define such a database before the __GLO_Shovel goal has been activated, nothing will happen since the event handler that reacts to this definition is not yet active. Even if the __GLO_Shovel goal initialises and activates afterwards, the above event handler will not be called for already existing DB_ShovelArea() facts. The reason is that the DB-event only fires when the DB gets defined.
Simply ensuring that your goal has a name that is alphabetically sorted after __GLO_Shovel is not sufficient in this case to ensure that the __GLO_Shovel goal has already initialised when your goal initialises. The reason is that __GLO_Shovel is a subgoal of the Shared Mod's __Start goal. This means that it will only activate when this __Start goal completes.
The Shared mod's __Start goal completes when the GameEventSet("GAMEEVENT_GameStarted") event triggers. In practice, you should seldom use this event, and rely instead on Level Events. These level events are guaranteed to trigger after the GAMEEVENT_GameStarted event. If you follow rule 3 from the summary above and put your DB_ShovelArea declarations in the INIT sections of subgoals of your level initialisation goals, this happens automatically.
Game Events
These events are thrown when the game engine has finished initialising, but before any level has been loaded. This means that even global objects are not accessible at this point, and many Osiris calls and queries will fail because the necessary context is missing. The main purpose of these events is to allow for generic story initialisation.
Two events exist in this category.
- GameEventSet
- This event also existed in DOS1. As the documentation page mentions, it is only called with one particular parameter at this time.
- GameModeStarted
- This event is new in DOS2. It is thrown right after the previous one. Its parameter indicates whether the game has been started in campaign (adventure/store), game master, or arena mode. You can use this to only initialise parts of story that are relevant to the selected mode, by making all goals children of a goal that only completes if the desired game mode started. As of DOS2 Patch 6, all story goals distributed with the main game use this functionality to prevent interference with mods that can be used with more than one game mode.
Level Events
These events can be used to execute code when a level gets entered by players:
The difference between these two events is explained on the linked pages. Also note that these events are thrown when loading a savegame (for the level in which the game was saved), so do not tie them to rules that should be executed exactly once without adding safeguards. One way is to catch this event in a top-level goal that is then immediately completed as explained in the Program Initialisation section. All one-time initialisation can then be done in the INIT sections of the child goals.
Doing Something Once
The event-based nature of Osiris makes it easy to consistently perform actions whenever something happens, but sometimes things should happen only once. Several patterns can be used on this depending on the situation.
Goal-Based Initialisation
When a goal becomes active, all of the actions in its INIT section are executed. So you can make it a subgoal of another goal that completes when this initialiation should occur, like with the Program Initialisation above.
Breaking off a Loop
In many cases you will find yourself iterating a database checking for a condition to be fulfilled, and executing certain actions when this is the case. Often, these actions should only be executed once, for the first fact that fullfills to the conditions. For example, take this slightly simplified code from the Peeper quest, whereby we look for a chicken to hatch the Peeper egg:
DB_RC_DW_VC_Chickens((CHARACTERGUID)S_RC_DW_VC_Chicken_001_9053f0be-7d06-4e88-ab20-f411e2e790d0,"RC_DW_VC_Chicken1","RC_DW_VC_AD_Chicken1",0,(TRIGGERGUID)S_RC_DW_VC_Chicken01_DeadTrigger_51b9f0bc-04bf-4f68-9e0d-43058d4a4580); // The 0 means "currently hatching an egg" DB_RC_DW_VC_Chickens(S_RC_DW_VC_Chicken_002_d8699c1d-3ca1-444b-b985-95de1dc8c708,"RC_DW_VC_Chicken2","RC_DW_VC_AD_Chicken2",0,S_RC_DW_VC_Chicken02_DeadTrigger_629dcb32-e251-48b8-8a33-e710dda8c3fc); DB_RC_DW_VC_Chickens(S_RC_DW_VC_Chicken_003_bcd34f89-8ff2-4d94-8c30-fe09bdbc1163,"RC_DW_VC_Chicken3","RC_DW_VC_AD_Chicken3",0,S_RC_DW_VC_Chicken03_DeadTrigger_72400c76-d222-4996-bef1-b787f449fcc2); DB_RC_DW_VC_Chickens(S_RC_DW_VC_Chicken_004_66188471-2b54-4f53-8efa-8278125e2c60,"RC_DW_VC_Chicken4","RC_DW_VC_AD_Chicken4",0,S_RC_DW_VC_Chicken04_DeadTrigger_7d824686-06ce-472c-a4ce-cd85300f57b6); DB_RC_DW_VC_Chickens(S_RC_DW_VC_Chicken_Scared_81ef9840-7b6e-4d3d-9b60-4c7ed7a286a7,"RC_DW_VC_ChickenScared","RC_DW_VC_AD_ChickenScared",0,S_RC_DW_VC_ScaredChicken_DeadTrigger_65e32575-6cd8-44dc-9af3-ded6982d0
..
PROC Proc_RC_DW_VC_SetNewBroodChicken() AND DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger) AND CharacterIsDead((CHARACTERGUID)_chicken,0) AND NOT DB_RC_DW_VC_DoneOnce(1) THEN DB_RC_DW_VC_DoneOnce(1); NOT DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger); DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,1,_trigger); DB_RC_DW_VC_DroppingEgg(_chicken,"0_GoingToEggAsNewHatchingChicken"); ProcCharacterMoveTo(_chicken,TRIGGERGUID_S_RC_DW_VC_HelplessEggLocation_92d4c501-c342-4e25-b08e-32fb7d396ee9,0,"RC_DW_VC_ChickenWalkToEgg"); CharacterSetReactionPriority(_chicken,"DefaultWanderInTrigger",0); // Very important: ensure we will loop again next time this proc is called! PROC Proc_RC_DW_VC_SetNewBroodChicken() THEN NOT DB_RC_DW_VC_DoneOnce(1);
When the Proc_RC_DW_VC_SetNewBroodChicken gets called, it iterates over the chickens in the DB_RC_DW_VC_Chickens() database. Next, for each such chicken it checks whether it is alive (not dead), and if so, it selects that chicken as the one that should hatch the egg. Of course, we only want one chicken to do so. Therefore, as soon as we found an available chicken, we define the DB_RC_DW_VC_DoneOnce(1) fact and we also check on this condition to ensure that we do not select additional chickens afterwards.
Note that the check for NOT DB_RC_DW_VC_DoneOnce(1) is the very last condition. This is important, because as explained in the section on the Osiris rule evaluation state, semantically Osiris iterates over databases by making a local table that contains all matching facts and then iterating over those without revisiting conditions higher up. This means that the iteration would not abort if we wrote the check like this:
PROC Proc_RC_DW_VC_SetNewBroodChicken() AND NOT DB_RC_DW_VC_DoneOnce(1) AND DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger) AND CharacterIsDead((CHARACTERGUID)_chicken,0) THEN DB_RC_DW_VC_DoneOnce(1);
The reason is that Osiris would find that DB_RC_DW_VC_DoneOnce(1) is not defined at the time Proc_RC_DW_VC_SetNewBroodChicken() gets called. Next, it will start iterating over the DB_RC_DW_VC_Chickens() database without checking the DB_RC_DW_VC_DoneOnce() database ever again.
Factoring Out Checks to Queries
Queries offer an easy way to factor out searching logic and the associated bookkeeping from a proc. For example, we could rewrite the above using a query as follows:
QRY QRY_RC_DW_VC_FindBoodChicken() AND DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger) AND CharacterIsDead((CHARACTERGUID)_chicken,0) AND NOT DB_RC_DW_VC_FindBoodChicken(_) THEN DB_RC_DW_VC_FindBoodChicken(_chicken);
PROC Proc_RC_DW_VC_SetNewBroodChicken() AND QRY_RC_DW_VC_FindBoodChicken() AND DB_RC_DW_VC_FindBoodChicken(_chicken) AND DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger) THEN NOT DB_RC_DW_VC_FindBoodChicken(_chicken); NOT DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger); ...
In this case, there is no real gain to do this. However, if you need to look up several independent things in a loop, this pattern can make your code much clearer as all the "only once" checks are isolated in their individual queries. Be careful to never use this pattern (using a result DB from a query in the same rule that called the query) in rules whose trigger condition(s) consist exclusively of DB-checks, because otherwise you will trigger an Osiris bug.
QueryOnlyOnce
QueryOnlyOnce is a useful helper query that uses the principle above as a handy shortcut to execute something only once. Its documentation page contains a usage example. The query itself is also very simple:
QRY QueryOnlyOnce((STRING)_OnlyOnceUUID) AND NOT DB_OnlyOnce(_OnlyOnceUUID) THEN DB_OnlyOnce(_OnlyOnceUUID);
The query will only succeed if that DB is not yet defined. When it succeeds, it defines the DB so that future invocations of this query will fail.