Turning the fireworks off without touching the content¶
Someone wants a reduced-effects mode. Maybe it is an accessibility setting, maybe it is the low-spec tier, maybe it is a capture build where the particles are in the way. Whatever the reason, the ask is the same: stop the effects exploding, and do not make me re-author two hundred pickup definitions to get it.
Recipe
Systems required: Pickups. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene. Public API only. It assumes: the effect chains you want silenced are built while this is installed — which is a narrower set than it sounds, and the box below says exactly which. It installs itself into the factory on enable. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.
Read this first: it silences chains built at runtime, not the ones already on your pickups¶
A world pickup's chain was built in the editor, and a registry installed at OnEnable is years too late for it
PickupEffectFactory.BuildEffect is the only thing that consults the registry, and in the shipped product it is called from exactly three places: the two editor authoring routes (PickupPrefabFactory, PickupCreatorWizard), and the Inventory-to-Pickups item-use bridge.
The editor routes bake the finished chain into a *_Effect.asset and assign it to the prefab, so TriggerPickup.effect and the interactable pickups already hold a decorated PickupEffect. Nothing in the Pickups runtime rebuilds it. The framework says this about itself, in the bridge's own remarks:
The world route honours it because the editor pipeline bakes the decorator into the generated asset; this route builds from the definition at runtime.
So this component reaches:
- pickup definitions used as inventory item-use effects, resolved through
Integrations/Inventory/PickupsIntegration— those are built per use, at runtime; - anything your own code builds by calling
PickupEffectFactory.BuildEffect, which is public.
It does not reach a pickup in the scene whose effect was baked by the wizard or assigned by hand. For those, silence the decorator on the definition and re-run the wizard, or swap the effect reference — there is no runtime seam between a built chain and the pickup holding it.
The registry is still the right seam for what it covers, and the rest of this page is about using it well. It is worth knowing the shape of the reach before you build a settings toggle on it.
The part that is not obvious¶
The factory resolves every decorator through a registry you are allowed to replace:
public static void SetRegistry(IPickupDecoratorRegistry registry);
There is no getter. You cannot ask the factory what is installed, which is the same wall the craftable search box hits with SetSearch and the floor store hits in Economy.
But this time you can rebuild the default, because it is public
DefaultPickupDecoratorRegistry is a public class with a public parameterless constructor. So while you still cannot read what is installed, you can construct the thing that shipped and delegate to it:
_installed = new FilteredRegistry(new DefaultPickupDecoratorRegistry());
That one difference — an accessibility keyword — is the difference between decorating a default and transcribing one. DefaultInventorySearch and DefaultInventorySorter are internal, so those recipes had to re-answer every case the default answered. This one forwards everything it does not care about and stays about fifteen lines long.
Silence resolves to a creator, not to null¶
The obvious way to suppress a decorator is to return null from Find. It works: the factory logs, skips the entry, and leaves the effect chain intact.
It works, and it is still the wrong answer
A null from Find is indistinguishable from a decorator type nobody registered. The factory cannot tell a deliberate veto from a missing creator, so it says the only true thing it knows:
[PickupEffectFactory] No creator for decorator type VFX on 'Health Potion'.
Once per silenced decorator, per pickup, every time an effect is built, for as long as the mode is on. The warning is #if UNITY_EDITOR, so a player build pays nothing — the cost lands entirely on whoever is working in the editor, in the form of a console they stop reading. That is the expensive one. A reduced-effects toggle that buries real warnings under complaints about your own content is a toggle nobody leaves on.
The recipe answers with a creator that hands back the effect it was given:
public PickupEffect Create(PickupEffect baseEffect, PickupEffectDefinitionBase def)
=> baseEffect;
Same outcome — nothing is added to the chain — but said deliberately. Nothing is logged, and nothing is allocated: every shipped creator builds a decorator through ScriptableObject.CreateInstance, and this one builds nothing at all.
Install from a scene component, not from your own static hook¶
PickupEffectFactory re-seeds a clean built-in registry from SubsystemRegistration:
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics() => _registry = new DefaultPickupDecoratorRegistry();
That is there for a good reason — with domain reload off, a static field initialiser runs only on first class load, so session-one customisations would otherwise leak into session two and the second play would not match the first.
It also means anything you install before that point is thrown away
SubsystemRegistration runs before the first scene loads, so installing from OnEnable lands after it, every session, with no ordering question to answer. Installing from a SubsystemRegistration hook of your own would be racing a reset that is guaranteed to run — for no benefit, since there is no scene yet to have an opinion about effects.
Standing down means delegating, not uninstalling¶
public static void SetRegistry(IPickupDecoratorRegistry registry)
{
if (registry != null) _registry = registry; // null is ignored, deliberately
}
There is no way to hand the factory back what it had. So disabling this component does not uninstall it — it empties the silenced set, and the wrapper forwards every call to the default it is holding. The observable behaviour is identical to never having installed anything, which is the honest version of "off".
Lockdown refuses politely for the same reason, and the craftable search box stands down the same way in Inventory. It is becoming a house rule: one-way installs stand down by behaving like the thing they replaced.
Two limits, stated rather than hidden¶
Creators registered before this enables are not carried across
The wrapper is built around a fresh DefaultPickupDecoratorRegistry, so anything you added through PickupEffectFactory.RegisterCreator before this component enabled is not in it — there is no getter to collect those from. Register your creators after this installs, or through this component's registry, which forwards Register and Unregister unchanged.
The switch is global, because the seam is
PickupEffectFactory is static. This silences a decorator type everywhere — not per pickup, not per actor. If you need it narrower, the decision belongs in a Conditional decorator on the definition rather than here.
Drop it in¶
using System.Collections.Generic;
using UnityEngine;
using RevGaming.RevFramework.Pickups.Core;
using RevGaming.RevFramework.Pickups.Decorators;
using RevGaming.RevFramework.Pickups.Definitions;
namespace RevGaming.RevFramework.Cookbook.QuietPickups
{
/// <summary>
/// Silences chosen pickup decorator types for every effect chain built while it is installed — a
/// reduced-effects switch that leaves every pickup definition untouched.
/// </summary>
/// <remarks>
/// <para><b>Read the reach before building a settings toggle on this.</b>
/// <c>PickupEffectFactory.BuildEffect</c> is the only thing that consults the registry, and in the
/// shipped product it is called from three places: the two <b>editor</b> authoring routes
/// (<c>PickupPrefabFactory</c>, <c>PickupCreatorWizard</c>) and the Inventory-to-Pickups
/// <b>item-use</b> bridge. The editor routes bake the finished chain into a <c>*_Effect.asset</c>
/// and assign it to the prefab, so <c>TriggerPickup.effect</c> already holds a decorated
/// <see cref="PickupEffect"/> and nothing in the Pickups runtime rebuilds it — the bridge's own
/// remarks say so: <i>"the world route honours it because the editor pipeline bakes the decorator
/// into the generated asset; this route builds from the definition at runtime."</i> So this reaches
/// pickup definitions used as inventory item-use effects, and anything your own code builds through
/// the public <c>BuildEffect</c>. It does <b>not</b> reach a pickup in the scene whose effect was
/// baked by the wizard or assigned by hand.</para>
///
/// <para><b>This is the first seam in the Cookbook that can be decorated rather than
/// re-implemented, and one keyword is the whole reason.</b> Like
/// <c>SetSearch</c> and <c>SetSorter</c>, <c>PickupEffectFactory.SetRegistry</c> has no getter:
/// there is no way to read the registry that is currently installed. Unlike those two, the
/// shipped implementation — <see cref="DefaultPickupDecoratorRegistry"/> — is <b>public, with a
/// public parameterless constructor</b>. So you cannot read what is installed, but you can
/// rebuild what ships and delegate to it, which is the difference between wrapping a default and
/// transcribing one.</para>
///
/// <para><b>A silenced type resolves to a pass-through creator, not to <c>null</c>.</b> Returning
/// <c>null</c> from <c>Find</c> works — the factory logs and skips the entry, leaving the chain
/// intact — but it is indistinguishable from a decorator type nobody registered, so every
/// silenced pickup writes <i>"No creator for decorator type X"</i> to the console every time an
/// effect is built. That warning is <c>#if UNITY_EDITOR</c>, so this costs a player build
/// nothing and costs whoever is working in the editor a console they stop reading — which is the
/// expensive one. A creator that returns the effect it was handed is the same outcome, said
/// deliberately: nothing is logged, nothing is allocated, and the entry is visibly answered
/// rather than dropped.</para>
///
/// <para><b>Install from a scene component, not from your own static initialiser.</b>
/// <c>PickupEffectFactory</c> re-seeds a clean built-in registry from
/// <c>SubsystemRegistration</c>, so that session-one customisations cannot leak into session two
/// when domain reload is off. That phase runs before the first scene loads, so an
/// <c>OnEnable</c> install lands after it every session. A rival <c>SubsystemRegistration</c>
/// hook of your own would be racing it for no gain.</para>
///
/// <para><b>Standing down means delegating, not uninstalling.</b> <c>SetRegistry</c> ignores a
/// null, deliberately, so there is no way to hand the factory back what it had. Disabling this
/// component therefore empties the silenced set and leaves the wrapper forwarding every call —
/// behaviour identical to the default registry, rather than a factory holding something inert.
/// <c>Lockdown</c> and <c>CraftableSearch</c> reach the same shape from different systems.</para>
///
/// <para>Two known limits, stated rather than hidden. The wrapper is built around a <i>fresh</i>
/// default, so creators registered through <c>RegisterCreator</c> <b>before</b> this component
/// enables are not carried across — there is no getter to collect them from. Register yours
/// after this installs, or through this component's own registry. And the switch is global
/// because the seam is: <c>PickupEffectFactory</c> is static, so this silences a decorator type
/// everywhere, not per pickup or per actor.</para>
/// </remarks>
[AddComponentMenu("RevFramework/Cookbook/Quiet Pickups")]
public sealed class QuietPickups : MonoBehaviour
{
[Tooltip("Decorator types to silence while this component is enabled. Everything else " +
"resolves exactly as it would without this component.")]
[SerializeField] private DecoratorType[] silenced = { DecoratorType.VFX, DecoratorType.Sound };
/// <summary>The registry this component installs. Created once and reused across enables.</summary>
private FilteredRegistry _installed;
private void OnEnable()
{
_installed ??= new FilteredRegistry(new DefaultPickupDecoratorRegistry());
_installed.Silenced = BuildSet();
PickupEffectFactory.SetRegistry(_installed);
}
private void OnDisable()
{
// Not an uninstall -- there is no such call. Emptying the set makes the wrapper forward
// everything, which is what the factory would have done on its own.
if (_installed != null)
_installed.Silenced = null;
}
/// <summary>Re-reads the serialized list into the live registry.</summary>
/// <remarks>
/// Public so a settings screen can flip the switch without disabling the component. Does
/// nothing until the component has enabled at least once, because there is nothing installed
/// to update before then.
/// </remarks>
public void Refresh()
{
if (_installed != null && isActiveAndEnabled)
_installed.Silenced = BuildSet();
}
private HashSet<DecoratorType> BuildSet()
{
if (silenced == null || silenced.Length == 0)
return null;
var set = new HashSet<DecoratorType>();
for (int i = 0; i < silenced.Length; i++)
set.Add(silenced[i]);
return set;
}
/// <summary>
/// A registry that answers silenced types itself and forwards everything else.
/// </summary>
/// <remarks>
/// <c>Register</c> and <c>Unregister</c> forward unconditionally, so
/// <c>PickupEffectFactory.RegisterCreator</c> keeps working while this is installed. Only
/// resolution is intercepted, and only for the types named.
/// </remarks>
private sealed class FilteredRegistry : IPickupDecoratorRegistry
{
private readonly IPickupDecoratorRegistry _inner;
private readonly PassThroughCreator _passThrough = new();
/// <summary>Types to answer with the pass-through creator. Null or empty forwards everything.</summary>
internal HashSet<DecoratorType> Silenced;
internal FilteredRegistry(IPickupDecoratorRegistry inner) => _inner = inner;
public void Register(IPickupDecoratorCreator creator) => _inner.Register(creator);
public void Unregister(IPickupDecoratorCreator creator) => _inner.Unregister(creator);
public IPickupDecoratorCreator Find(DecoratorType type)
{
if (Silenced != null && Silenced.Contains(type))
return _passThrough;
return _inner.Find(type);
}
}
/// <summary>
/// Answers any type by handing back the effect it was given.
/// </summary>
/// <remarks>
/// Every shipped creator allocates a decorator through <c>ScriptableObject.CreateInstance</c>
/// and points its <c>wrappedEffect</c> at the incoming effect. This one returns the incoming
/// effect, which the factory adopts as the unchanged chain. <c>CanHandle</c> is
/// unconditionally true because the wrapper above has already decided that this type is the
/// one being silenced; the creator is never consulted about anything else.
/// </remarks>
private sealed class PassThroughCreator : IPickupDecoratorCreator
{
public bool CanHandle(DecoratorType type) => true;
public PickupEffect Create(PickupEffect baseEffect, PickupEffectDefinitionBase def)
=> baseEffect;
}
}
}
Wiring it up¶
- Put the component on a scene object that lives for the session — a settings manager, a bootstrapper. It installs in
OnEnable, which lands afterPickupEffectFactory.ResetStaticsre-seeds the built-in registry atSubsystemRegistration, every session. - Choose the
silencedtypes. It ships silencingVFXandSound, which is the reduced-effects ask;DecoratorTypeis a closed enum of five, so the choice is from that set. - Flip it at runtime by editing
silencedand callingRefresh(), rather than by disabling the component. Disabling empties the set and leaves the wrapper forwarding everything, which is the same behaviour as the default registry but is not an uninstall —SetRegistryignores a null, so there is no way to hand the factory back what it had. - Register your own creators after this installs, or through this component's registry. The wrapper is built around a fresh default, so creators registered through
PickupEffectFactory.RegisterCreatorbefore it enables are not carried across; there is no getter to collect them from. - Do not run two of these. The second wraps its own fresh default and displaces the first, so the first's silenced set stops applying with nothing logged.
Related¶
- A search box that knows what you can make — the same missing getter, and what you have to do when the default is
internalinstead of public. - Lockdown — the other one-way install, and the same polite stand-down.