Add a section to the platform side panel
Selecting a platform opens a side panel, and a mod can add modules to it — the same way the building side panel works, with one difference that will bite you immediately.
Providers are registered per island definition, and most islands have none
IslandsModulesLookup maps an IslandDefinitionId to a single
IIslandModuleDataProvider. Vanilla only registers one for islands that have something
particular to say — space belts, space pipes, converters, research stations, train
stations, loaders, miners. Ordinary building platforms have no provider at all.
So wrapping the providers that already exist is not enough. That covers converters and space belts and silently does nothing for the platforms the player spends all their time on. You need to add providers for definitions that have none:
public class MyPanelModules : IIslandModulesRewirer
{
public void AddModules(IslandsModulesLookup modulesLookup)
{
Lookup = modulesLookup;
// Wrap what vanilla registered, so its modules still show above ours.
Dictionary<IslandDefinitionId, IIslandModuleDataProvider> providers =
modulesLookup.IslandModulesMap;
foreach (IslandDefinitionId definitionId in providers.Keys.ToArray())
{
providers[definitionId] = new Provider(providers[definitionId], this);
}
}
/// Called once a map is known, and again as islands appear.
public void EnsureProvider(IslandDefinitionId definitionId)
{
Dictionary<IslandDefinitionId, IIslandModuleDataProvider> providers = Lookup?.IslandModulesMap;
if (providers == null || providers.ContainsKey(definitionId))
{
return;
}
providers.Add(definitionId, new Provider(null, this));
}
}
IslandModulesMap is private; the publicizer makes it reachable. Note AddModuleProvider
uses Dictionary.Add, so it throws on a definition that already has a provider — assign
through the dictionary when replacing.
To discover which definitions are actually in play, walk map.Islands when a map loads and
subscribe to IMapModel.OnIslandAdded for ones placed later. Register the rewirer with
GameRewirers.AddRewirer; the Shifter calls AddModules from a postfix on
GameSessionOrchestrator.InjectIslandsModuleProviders.
Reuse the efficiency gauge
HUDSidePanelModuleBuildingEfficiency is the needle-and-percentage widget you get when
selecting a machine. No vanilla island panel uses it, but you can build one:
new HUDSidePanelModuleBuildingEfficiency.Data(
default(BuildingModel), // only assigned, never read - a default is safe
someLocalizedSimulation, // used for Simulator.GetSimulationTimeFor
someItemLane, // the lane it will hook and time
beltSpeedId,
baseSecondsPerItem);
Three things make this work, none of them obvious:
The BuildingModel is unused. InitFromData assigns it to a private field that
nothing reads, so a platform — which has no building — can pass default.
It measures whatever lane you hand it. The module installs its own AcceptHook on that
lane, timestamps arrivals, and averages the gaps. It never moves items through the lane.
Its 100% mark is baseDuration / (speedValue / 100). It re-applies the research
multiplier itself. If you already know the true ceiling, multiply by that same factor so
the two cancel and the gauge agrees with your own numbers:
float speedFactor = speeds.GetSpeedValue(beltSpeedId) / 100f;
float baseSecondsPerItem = 60f / myCeilingPerMinute * speedFactor;
Get ISimulationSpeedsProvider from the session container —
orchestrator.DependencyContainer.Resolve<ISimulationSpeedsProvider>() — and the belt
research id from orchestrator.GetBeltBuildingSpeedId().
Aggregate several lanes into one gauge
Because the module only ever listens to the lane, the lane does not have to be real.
Implement IItemLane and IHookableItemReceiver as a stand-in that reports arrivals from
a whole group, and one gauge can measure a space belt's twelve parallel lanes, or every
output port on a platform, as a single flow:
public class AggregateLane : IItemLane, IHookableItemReceiver
{
public AcceptHookDelegate AcceptHook { get; set; }
public PreAcceptHookDelegate PreAcceptHook { get; set; }
public PostAcceptHookDelegate PostAcceptHook { get; set; }
public IItemReceiver NextLane { get; set; }
// Nothing is routed through this; it exists to be listened to.
public Steps MaxStep_S => Steps.Zero;
public Steps FreeStepsAtTheEnd => Steps.Zero;
public int ItemCount => 0;
public bool HasItem => false;
public IBeltItem GetItem(int index) => null;
public void Clear() { }
public bool CanAcceptItem(IBeltItem itemToTransfer) => false;
public void HandOverItem(IBeltItem item, Ticks remainingTicks) => Report(item, remainingTicks);
public void Report(IBeltItem item, Ticks remainingTicks)
{
AcceptHookDelegate hook = AcceptHook;
if (hook == null) return;
IBeltItem forwarded = item;
Ticks remaining = remainingTicks;
hook(this, ref forwarded, ref remaining);
}
}
Fire it from wherever you already count items — a PostAcceptHook on each real lane, for
instance — and pass the group's combined ceiling as the base duration. Reporting
Ticks.Zero for the remaining ticks means "arrived now"; the gauge averages gaps over a
minute, so quantising to the update an item landed in does not move the average.
Without this the gauge reads one lane against the whole group's target — a space belt shows roughly a twelfth of its real flow.
Text modules do not update
HUDSidePanelModuleInfoText and HUDSidePanelModuleStats set their content once in
InitFromData. HUDSidePanelModuleStats looks live because HUDSingleStat has an
OnUpdate, but that only animates the tutorial highlight — GetContent is called once
when the stat is assigned.
So a number in a text module is a snapshot from when the panel opened. Assigning a
live-computing IText does not help either: HUDLocalizedText.Text only rebuilds when
the IText object differs (if (!object.Equals(value, _Text))), and the module
assigns it once.
Three stock modules do keep polling, and between them they cover most needs:
| Module | Polls | Renders |
|---|---|---|
HUDSidePanelModuleBuildingEfficiency |
its hooked lane | needle + percentage |
HUDSidePanelModuleRocketProgress |
two Func<float> |
fill bar + percentage |
HUDSidePanelModuleBeltItemContents |
a Func<IEnumerable<IBeltItem>> |
item icons in fixed slots |
HUDSidePanelModuleRocketProgress is misnamed. Its prefab reference is
HUDSidePanelModulesResources.GenericProgress; the converter is just its only vanilla
user, for rocket progress. It is the generic current-versus-maximum bar, and the right
choice for a buffer, a queue, or anything with a capacity:
yield return new HUDSidePanelModuleRocketProgress.Data(
icon,
"my-mod.buffer".T(),
Color.cyan,
() => store.Count, // called every frame
() => store.Capacity);
No stock module renders an arbitrary live number. The progress view hardcodes its
value text to FormatGeneralPercentage, and its header comes from InitFromData and
never changes. So "12 / 25" is not reachable without your own HUDSidePanelModule and a
Unity prefab to go with it — the live percentage is what you get. Pick a capacity that
makes the percentage readable if the exact count matters.
yield return new HUDSidePanelModuleInfoText.Data(new RawText("18 machines, 3 backed up"));
That is fine for counts and states. For a rate, use the gauge.
Register each definition exactly once
IslandsModulesLookup.AddModuleProvider is a plain Dictionary.Add, so claiming an id
twice throws:
ArgumentException: An item with the same key has already been added. Key: My_Island
at IslandsModulesLookup.AddModuleProvider
at ShapezShifter.Flow.Atomic.IslandModulesExtender.AddModules
It happens during InjectIslandsModuleProviders, which runs while the main menu is
being built — so the whole game fails to start, not just your island.
The trap is that AtomicIslands.Extend() always registers a provider for the
definition it carries. WithoutModules() does not mean "no registration"; it registers a
NoModulesProvider. So if your island goes through the extender chain, its id is already
claimed by the time your IIslandModulesRewirer runs.
Pick one owner per id:
// Definitions that travel the chain: give the provider to the chain.
.WithCustomModules(new MyPanelModules(kind))
// Definitions the chain never sees — a mirrored flip partner, say — only those go here.
public void AddModules(IslandsModulesLookup modulesLookup)
{
modulesLookup.AddModuleProvider(MyIds.Mirrored(kind), new MyPanelModules(kind));
}
If you are wrapping providers vanilla already registered (the pattern at the top of this
page), you are replacing entries rather than adding them, which is fine — the collision
only arises when two registrations both try to Add the same new id.
See also
- Measure what a factory is actually moving — where the numbers come from
- Add a section to the building side panel — the per-building equivalent
- Speed upgrades and buffs —
ISimulationSpeedsProvider