Mega Tree API
Requirements
Gating a node behind a condition or a store level, why the predicate is polled three times a second, and what happens when it throws.
Two kinds of gate
| Field | Answers |
|---|---|
IsUnlocked + LockedReason |
Anything you can write in C#. Your condition, your wording. |
RequiredStoreLevel |
Just the store level, per level of your line. Mega Tree writes the reason. |
They combine. A node with both is buyable only when the predicate is true and the store level is high enough, and the card shows the level requirement first - it is a number the player can check for themselves.
IsUnlocked
new UpgradeDefinition
{
Id = "bigboxes.auto",
// ...
IsUnlocked = () => MegaTreeApi.GetLevel("bigboxes.capacity") >= 2,
LockedReason = () => "Needs Bigger Boxes II",
}While IsUnlocked returns false the whole line is drawn grey and
cannot be bought, and the hover card shows LockedReason instead of the
effect text. When it flips to true the node becomes buyable within about a third of a second - the
player does not have to reopen anything.
LockedReason is
optional, and leaving it out means the card shows a bare "Locked". That is barely better than
blank, and a grey node with no explanation is indistinguishable from a bug - which is what lands
in your comments section.
How often it is called
The tree repaints node states about three times a second, and asks every visible node whether it is unlocked. Mega Tree caches your answer for 0.25 seconds per node, so a predicate is called at most about four times a second no matter how many nodes are on screen.
That still means hundreds of calls a minute while the window is open. Keep it cheap.
| Fine | Not fine |
|---|---|
| Comparing an int you already have | Resources.FindObjectsOfTypeAll<T>() |
MegaTreeApi.GetLevel(...) | FindObjectsOfType<T>() |
| Reading a singleton field | Reading a file, or anything allocating per call |
| A cached bool your own code updates on an event | LINQ over a scene-wide collection |
If you must look at the scene
Do the expensive part on your own schedule and let the predicate read the answer:
private static bool _hasFreezer;
// Recomputed twice a second on Mega Tree's own pass, not on every repaint.
OnApply = () =>
{
_hasFreezer = UnityEngine.Object.FindObjectsOfType<Freezer>().Length > 0;
},
IsUnlocked = () => _hasFreezer,RequiredStoreLevel
Given a 1-based level of your line, return the store level needed to buy it.
0, or leaving the field null, means no requirement.
// One step per level
RequiredStoreLevel = lvl => lvl * 10,
// Or an explicit table, which is easier to balance and to read back
RequiredStoreLevel = lvl => lvl switch { 1 => 5, 2 => 12, _ => 20 },The number is the game's own player level - the one that gates hiring and store growth. Mega
Tree reads it from ExperienceManager, so it is the same number the
player sees in their own UI, and the card says "Needs store level 12" without you writing
that string.
StoreLevelRequirements in BepInEx/config/MegaTree.cfg disables level gating across the whole tree. When it is off your requirement is ignored along with everyone else's.
You do not need to handle that case, and you should not try to work around it. Someone who turned it off asked for exactly this.
Failure is open, not closed
If your predicate throws, Mega Tree treats the node as unlocked and logs a warning naming your node and plugin.
The two failure modes are not symmetric. A node wrongly buyable is a mod bug with an obvious cause. A node locked forever with no explanation is a player staring at a grey circle they can never buy, filing a report against the wrong mod. Between those, the noisy one is better - and a mod poking a hole in its own gate is a problem for the mod author, not for the player.
Repeated identical exceptions stop being logged after five lines, so a predicate that throws every quarter second does not fill the log file.
Common gates
Depend on another of your nodes
IsUnlocked = () => MegaTreeApi.GetLevel("bigboxes.capacity") >= 2,
LockedReason = () => "Needs Bigger Boxes II",Depend on a Mega Tree built-in
Built-in ids are stable and have no dot: walk,
sprint, reach,
push, cashier,
fullday, training, and the six
mv_* movement branches.
IsUnlocked = () => MegaTreeApi.GetLevel("cashier") >= 3,
LockedReason = () => "Needs Fast Cashier III",Depend on the store
IsUnlocked = () => SingletonBehaviour<ExperienceManager>.Instance.CurrentLevel >= 10,
LockedReason = () => "Available from store level 10",
// ...though for exactly this, RequiredStoreLevel is the better field:
// it writes the reason for you and honours the player's config toggle.Depend on another mod, safely
// Compute it once at Awake, so the predicate is just a field read and cannot throw.
private static bool _hasNightOwl;
private void Awake()
{
_hasNightOwl = Chainloader.PluginInfos.ContainsKey("NightOwl");
// ...
}
IsUnlocked = () => _hasNightOwl,
LockedReason = () => "Requires the Night Owl mod",Locked is not hidden
A locked node is still drawn, still shows its name and its price. That is deliberate: the tree is meant to be read as a plan, and an upgrade the player cannot see yet is an upgrade they will never work towards. If you truly want something invisible until a condition holds, register it late instead of gating it.
Locked versus ComingSoon
IsUnlocked false | ComingSoon true | |
|---|---|---|
| Means | You can have this, once you meet the condition | This does not exist yet |
| Card shows | Your LockedReason | "Soon" |
| Can become buyable in-session | Yes | No, it needs a mod update |