Reactive programming
Work that runs when a value changes can be written on the dependent side rather than at the call site. When a field carrying the attribute changes, the work watching that field runs.
The four attributes
Section titled “The four attributes”| Attributes | Attached to | When it runs |
|---|---|---|
[Reactive] | Field | What gets tracked for changes. Make it private |
[Computed] | Properties | A value derived from other fields. Updated when a source changes |
[Effect] | Methods | It runs when a dependency changes. It also runs once at startup, so use this when you want the display correct from the moment it starts |
[On(...)] | Methods | It runs the instant the trigger changes. It doesn’t run at startup, so use this when you only want the moment of change |
Placing two attributes
Section titled “Placing two attributes”A field whose changes are tracked, and the work that watches it. Touching it changes the field, and the work that paints the color runs. No call is written anywhere.
using UnityEngine;using Tsukimi;
public class ReactiveFieldAndEffect : TsukimiBehaviour{ // A field tracked for changes. Make it private. [Reactive] private int charge = 100;
// Runs when a dependency changes. Also runs once at startup. [Effect] private void ApplyLook() { Renderer r = GetComponent<Renderer>(); if (r != null) r.material.color = charge > 0 ? Color.cyan : Color.gray; }
public override void Interact() { charge = charge - 20; if (charge < 0) charge = 0; }}Putting a derived value in between
Section titled “Putting a derived value in between”Lit is derived from two fields. The effect watches Lit, so charge going from 100 to 80 leaves Lit unchanged and no repaint happens.
using UnityEngine;using Tsukimi;
public class ReactiveComputed : TsukimiBehaviour{ [Reactive] private int charge = 100; [Reactive] private bool power = true;
// A value derived from other fields. Updated when a source changes. [Computed] private bool Lit => power && charge > 0;
[Effect] private void ApplyLook() { Renderer r = GetComponent<Renderer>(); if (r != null) r.material.color = Lit ? Color.cyan : Color.gray; }
public void TogglePower() { power = !power; }}Catching only the moment of change
Section titled “Catching only the moment of change”Parameters are optional. If you write them, write all of them in the same order and with the same types as the triggers. What you receive is the value from just before the change.
using UnityEngine;using Tsukimi;
public class ReactiveOnTrigger : TsukimiBehaviour{ [Reactive] private int charge = 100;
// Runs the moment a trigger changes. Doesn't run at startup. // Parameters are optional; if you write them, write all of them in the same order and types as the triggers. [On(nameof(charge))] private void OnCharge(int prevCharge) { Debug.Log(prevCharge + " -> " + charge); }
public override void Interact() { charge = charge - 20; }}Writing the dependencies yourself
Section titled “Writing the dependencies yourself”List names in the parentheses and only those are watched. A change to unrelated doesn’t run it.
using UnityEngine;using Tsukimi;
public class ReactiveEffectDependencies : TsukimiBehaviour{ [Reactive] private int width = 2; [Reactive] private int height = 3; [Reactive] private int unrelated;
// Writing the dependencies makes it watch only what you wrote. Moving unrelated doesn't make it run. [Effect(nameof(width), nameof(height))] private void ShowArea() { Debug.Log(width * height); }
public void Grow() { width = width + 1; unrelated = unrelated + 1; }}Writing the effect on the class
Section titled “Writing the effect on the class”[Effect] can also be placed on a class. The method named in Calls becomes the effect, and what it points at is an ordinary method, so nothing is written on the declaration itself.
using UnityEngine;using Tsukimi;
// the effect is written on the class. Calls points at an ordinary method, and nothing is written on the declaration// dependencies can be written too: [Effect(nameof(level), Calls = nameof(ShowLevel))]// naming a method that does not exist is an error[Effect(Calls = nameof(ReactiveClassEffect.ShowLevel))]public class ReactiveClassEffect : TsukimiBehaviour{ [Reactive] private int level;
public void ShowLevel() { Debug.Log(level); }
public override void Interact() { level = level + 1; }}Deciding whether it changed
Section titled “Deciding whether it changed”| Types | Check |
|---|---|
| Numbers, booleans, strings | Compared directly |
Types carried by value (Unity’s value types, plus System.DateTime, decimal, and the like) | Compared with the typed Equals. Unlike ==, it doesn’t swallow floating-point error |
| Reference types | Judged by whether it points to the same instance |
Quaternion and Color | Comparing components goes through float’s Equals, so NaN is considered equal to NaN (other value types compare components with ==, so a NaN makes them change forever) |
Forms that produce an error
Section titled “Forms that produce an error”| Circular dependencies | A cycle within the same Behaviour is a compile-time error. Writing back to your own trigger counts as one |
| How to fix it | Split the raw value and the rounded value into separate fields. Nothing cycles as long as the target you write to isn’t a trigger |
[Reactive] private int hp;
// This errors. It reads hp and writes hp, so it is circular.[Effect]private void Clamp(){ if (hp > 100) hp = 100;}Forms that produce a warning
Section titled “Forms that produce a warning”The following 4 don’t stop compilation and don’t error when run (each is a form that isn’t doing what you wrote, so the warning is the only clue).
| Number | Form | When it appears |
|---|---|---|
TUKI0119 | An effect that runs only once, at startup | Appears on an effect with [Effect] when no entry point that writes a dependency can be found. The one run at startup does happen, so from the writer’s side it looks like it’s working |
TUKI0113 | An effect that nothing runs | Appears on an effect with [On(...)] when its trigger doesn’t change in any entry point |
TUKI0115 | An attribute on a synced field | Propagation is inserted only in the entry point that writes the value, so a value that arrives over the network doesn’t run the effect. This is a warning about the shape of the declaration, so writing a path that runs it on the receiving side doesn’t clear it. If you keep the attribute, call the same update from OnDeserialization; if you drop it, remove the attribute from the synced field and write the update yourself |
TUKI0111 | Propagation can’t be built | Appears when the attribute was read but not one piece of change-propagating code could be placed. The effect isn’t run automatically |
Forms that don’t compile
Section titled “Forms that don’t compile”| Form | What happens now |
|---|---|
| A dependency cycle spanning Behaviours | Not detected (it only looks inside the same Behaviour) |
| Using a synced field as a trigger | The value on the receiving side may not match the sender’s. The sync layer has no “changed” check |