mirror of
https://gitlab.com/cc-ru/ocelot/ocelot-desktop.git
synced 2025-12-20 02:59:19 +01:00
parent
47149fe825
commit
a1660155a2
294
doc/ocelot-desktop.md
Normal file
294
doc/ocelot-desktop.md
Normal file
@ -0,0 +1,294 @@
|
||||
# Things you need to know to poke around Ocelot Desktop
|
||||
Ocelot Desktop is a desktop (duh) application offering a graphical interface to ocelot-brain.
|
||||
Like ocelot-brain, Ocelot Desktop is written in Scala 2.13; and unlike ocelot-brain, it bears no resemblance to the code of OpenComputers.
|
||||
Ocelot Desktop deals with rendering the interface, handling user input, laying out the UI widget hierarchy, dispatching events, playing sounds, and tying all of the above to ocelot-brain's state.
|
||||
|
||||
It's highly recommended to read the ocelot-brain primer first.
|
||||
The library lays down a framework for Ocelot Desktop to build upon, and this document will frequently reference concepts mentioned in the ocelot-brain explainer.
|
||||
|
||||
Now, assuming you're all set, let's start with the last item from that giant list of responsibilities above: how Ocelot Desktop manages its (and ocelot-brain's) state.
|
||||
|
||||
## Nodes
|
||||
ocelot-brain, being that abstracted away from reality, is able to treat everyone truly the same (as an `Entity`).
|
||||
Ocelot Desktop is not as naive and idealistic, and so it introduces a separation between "tile entities", contained directly in a workspace, and things you put inside them.
|
||||
The former is called **nodes** in Ocelot Desktop.
|
||||
The naming should make sense, because in the interface these are the nodes you can drag around and connect together.
|
||||
Yet at the same time it can be extremely confusing, since ocelot-brain *also* has nodes, and, to aggravate the pain, these things are linked together.
|
||||
Network connections between tile entities in ocelot-brain are represented as connections between Ocelot Desktop nodes.
|
||||
But they don't map 1-to-1 to each other: an Ocelot Desktop node can potentially house multiple network nodes (this is how we support "sided" blocks like relays and racks) or none at all (like a chest).
|
||||
(The chest isn't even exposed to ocelot-brain, in fact.
|
||||
We'll talk about that later.)
|
||||
|
||||
So, nodes.
|
||||
Each extends the `Node` class, or, if they are backed by an Ocelot entity, `EntityNode`.
|
||||
|
||||
1. **Persistence** is achieved by `Node` via its `load` and `save` methods.
|
||||
2. `Node` also defines **event listeners** to allow the node to move around and connect to other nodes.
|
||||
3. Nodes have **context menus**.
|
||||
4. The two lifecycle methods, `update` and `dispose`, are also defined in `Node`.
|
||||
- `update` is called every frame in the rendering thread (which is not the same as the ocelot-brain update thread).
|
||||
- `dispose` is after the node is removed from the workspace to allow cleaning up its resources.
|
||||
|
||||
The workspace is rendered in several passes by `WorkspaceView`.
|
||||
One of the `draw` methods is called on every node during a single pass.
|
||||
If your node doesn't need any special rendering, it suffices to override the `icon`.
|
||||
|
||||
### Ports
|
||||
To allow nodes to connect to each other, they provide an array of **ports**.
|
||||
Each port corresponds to a single ocelot-brain network node and can optionally be associated with a side.
|
||||
Ocelot Desktop uses the port's side to choose its color during rendering.
|
||||
|
||||
By default, `EntityNode`s get a single port that maps to their entity's node.
|
||||
If the returned array is empty, the node won't be able to connect to other nodes.
|
||||
Ex.: chest.
|
||||
Finally, sided inventories return multiple ports, and each port is bound to a specific side.
|
||||
|
||||
### Persistence
|
||||
Nodes are serialized to NBT and restored from it by `WorkspaceView`.
|
||||
|
||||
When serializing, the node's class name is written to NBT.
|
||||
During deserialization, the class name is used to discover the node class.
|
||||
If it's a subclass of `EntityNode`, its entity is deserialized first and then passed to the constructor.
|
||||
Otherwise the default constructor is used for instantiation.
|
||||
|
||||
The usual caveats apply.
|
||||
- You **must not** rename a node's class nor move it around, or saves will break.
|
||||
- That node will simply be removed from the workspace.
|
||||
- `EntityNode`s **must** provide a unary constructor that accepts the entity they manage.
|
||||
- Other `Node`s **must** provide a nullary (default) constructor.
|
||||
- And you **must not** use Scala's optional arguments in these constructors, as Scala will compile them away, and the constructor won't be found via reflection.
|
||||
|
||||
## Items, slots, and inventories
|
||||
Some nodes act as a container for other things.
|
||||
To support this, Ocelot Desktop provides an inventory subsystem.
|
||||
Here's the terminology we'll use throughout this section:
|
||||
|
||||
- An **inventory** is a collection of **inventory slots**.
|
||||
- Each **slot** has an index and may contain an **item**.
|
||||
- In the UI **slots** are rendered via **slot widgets**.
|
||||
- When you left-click a **slot widget**, you'll see an **item chooser** menu.
|
||||
- The **item chooser** menu shows a list of **item groups**.
|
||||
- An **item group** contains **item factories** (possibly one).
|
||||
- An **item factory** provides an icon, a name, and other attributes to the item chooser and then builds an **item** when its entry is chosen.
|
||||
|
||||
### Items
|
||||
Everything that can be put into a slot implements the `Item` trait.
|
||||
There are a couple other useful item traits that make sense to use:
|
||||
the most common are `ComponentItem` for items that manage a brain component and `PersistableItem` for items that require persisted state.
|
||||
All items are defined in the `ocelot.desktop.inventory.item` package, and you can use classes there as a reference.
|
||||
|
||||
The base trait only requires to implement the `factory` method.
|
||||
To explain what factories are, it's easier to go with an example — like a CPU item.
|
||||
It has a tier, an address, a name, and an icon.
|
||||
We could define these properties in the `Item` subclass directly, and for the most part it would be fine.
|
||||
However, we'd also like it to show up in the item chooser list.
|
||||
We can't just put an `Item` there — remember, the item has an already-generated, specific address;
|
||||
when selecting the CPU entry from the item chooser we'd rather generate a new one instead.
|
||||
|
||||
For this reason the item chooser deals with **item factories**.
|
||||
In addition to the actual `build()` method, which constructs a new item instance, the factory also defines a number of attributes used to display its item chooser menu entry.
|
||||
These attributes include an icon, a name, and a tier.
|
||||
They are also present in the `Item` trait — for convenience the default implementation of `Item` delegates such calls to the factory.
|
||||
Therefore usually you only need to define these attributes in the factory.
|
||||
|
||||
If an item manages a brain component, you should extend the `ComponentItem` trait.
|
||||
On the type system level, it only requires you to define the `component` method.
|
||||
But it imposes an additional requirement that the item have a unary constructor that accepts the component.
|
||||
The reasoning is the same as for nodes: when loading from NBT, the item's class name is looked up, its brain component is deserialized, and the class is instantiated with that component.
|
||||
|
||||
#### Accessing the slot
|
||||
The `slot` method returns a reference to the slot an item is currently in.
|
||||
You can use this to remove the item from the slot or replace it with another one.
|
||||
|
||||
You'll usually need this when you're changing some aspects of the brain component that the brain does not expect to change while the item is in an inventory.
|
||||
For instance, before you change the address, you should first remove the item from the inventory; then put it back once you're done.
|
||||
A convenience method `reinserting` does exactly that.
|
||||
See its uses in your IDE to get an idea when you'd want to reach for it.
|
||||
|
||||
#### Communication
|
||||
It's rather straightforward to make an item handle some event from its host inventory, since the inventory knows the exact type of its contents — you just call the method on the item.
|
||||
Communicating in the other direction, however, is more difficult: an item cannot make any assumptions about the inventory.
|
||||
As a rule of thumb, you should avoid item-to-slot communication, but sometimes there's no other viable choice.
|
||||
|
||||
In such cases the item can send out a `Notification` to the slot it's in — see the `notifySlot` method.
|
||||
The slot will pass the notification over to the slot's observers, which can act on them.
|
||||
The dispatch happens synchronously.
|
||||
|
||||
To create a new notification, you should subclass `Item.Notification`.
|
||||
Scala's `case class` and `case object` may be useful for that, as they admit pattern-matching.
|
||||
|
||||
#### Tooltips and menus
|
||||
Two other `Item` methods, `fillTooltip` and `fillRmbMenu`, are called to add entries to the tooltip and the context menu, respectively.
|
||||
Make sure to dispatch to the superclass when overriding them.
|
||||
For consistency:
|
||||
- call `super.fillTooltip()` **before** you add your own lines
|
||||
- call `super.fillRmbMenu()` **after** adding your entries
|
||||
|
||||
Consult other `Item` implementations for examples.
|
||||
|
||||
### Inventories
|
||||
Items are stored in inventories, so let talk about them next.
|
||||
Ocelot Desktop has three kinds of inventory classes:
|
||||
- a base `Inventory` trait
|
||||
- a `PersistedInventory` trait
|
||||
- and a `SyncedInventory`
|
||||
|
||||
Though probably you'll mostly be dealing with the latter, each of the successive trait builds on top of the preceding ones.
|
||||
Let's go over them in order.
|
||||
|
||||
#### The base Inventory trait
|
||||
An `Inventory` lays down the basic functionality of the inventory system.
|
||||
Here you'll find the definitions of the `Inventory#Slot` and `Inventory.SlotObserver` classes as well as its accessor methods.
|
||||
|
||||
All access to individual elements of an inventory has to go through the `Slot` wrapper.
|
||||
Calling `Slot(i)` returns a proxy to the ith slot in the inventory.
|
||||
It provides methods to retrieve and modify its contents as well as register slot observers.
|
||||
|
||||
Changing the contents of an inventory triggers calls to handler methods.
|
||||
In all cases the modification happens first, then the `Inventory`'s `onItemAdded` or `onItemRemoved` methods are called, followed by the corresponding calls to observers for the updated slot.
|
||||
In particular, at the point `onItemRemoved` is called, the original item is no longer in the inventory.
|
||||
Please avoid changing the contents of the inventory in these callbacks if you value your sanity and well-being.
|
||||
|
||||
Note that a slot only keeps weak references to its observers.
|
||||
To avoid the observer getting garbage-collected, you must retain a storng reference to it for the lifetime of the observer.
|
||||
You should still remove the observer once you no longer need it: weak references are only intended to prevent memory leaks.
|
||||
|
||||
#### PersistedInventory
|
||||
This trait augments the `Inventory` with the ability to persist its contents.
|
||||
It requires items implement the `PersistableItem` trait.
|
||||
|
||||
During serialization, the class name of each item is recorded in the NBT.
|
||||
Later, to load the item, the name is looked up and its default constructor (with 0 parameters) is called.
|
||||
Again, your items **must** have a constructor (not necessarily primary) that has exactly **zero** parameters — including optional.
|
||||
|
||||
For items extending `ComponentItem` the persistence works a bit differently.
|
||||
In addition to the class name, when saving, the component is stored separately in the NBT, so you don't need to include it in your item's `load` implementation.
|
||||
Loading then first constructs the component; once finished, the class is looked up and instantiated using a unary constructor accepting the component.
|
||||
Note, however, that the parameter type doesn't need to be an exact match: its superclass will also work fine.
|
||||
|
||||
While loading, the inventory starts out empty and items are added one by one, triggering invocation of callbacks.
|
||||
Make sure you don't rely on the partial state in your callbacks by mistaking it for complete:
|
||||
for instance, getting the contents of a slot that has not yet been loaded will give you a `None` even though it had an item while saving.
|
||||
|
||||
#### SyncedInventory
|
||||
A `PersistedInventory` is perfectly suitable for Desktop-only inventories such as chests.
|
||||
However, the majority of inventories needs to deal with their brain counterparts: cases, servers, disk drives, etc.
|
||||
Each of these entities implements the brain's `ComponentInventory` trait, which cannot store Desktop items.
|
||||
In such case deriving from a `SyncedInventory` is more appropriate.
|
||||
|
||||
A `SyncedInventory` is a `PersistedInventory` that synchronizes its contents with a brain inventory.
|
||||
|
||||

|
||||
|
||||
The synchronization is bi-directional and is only performed for one slot at a time.
|
||||
There are three modes of synchronization:
|
||||
- desktop-to-brain
|
||||
- brain-to-desktop
|
||||
- reconciliation
|
||||
|
||||
When the Desktop inventory initiates an inventory change, **desktop-to-brain** synchronization is performed.
|
||||
It attempts to copy the contents of the desktop slot to the brain inventory, possibly removing or replacing the entity there.
|
||||
|
||||
When the Desktop inventory receives a brain event notifying it of a brain inventory change, **brain-to-desktop** synchronization occurs.
|
||||
Usually this happens in response to a Desktop-originated change;
|
||||
however, there are rare cases where ocelot-brain itself initiates the inventory update (such as invoking the `eject()` method of a disk drive).
|
||||
The brain-to-desktop synchronization propagates the change over to the Desktop side.
|
||||
If the brain entity is removed, the Desktop's item is removed as well.
|
||||
However, the code also handles the case where an entity appears in the brain inventory instead by **recovering** the entity's item.
|
||||
See the section on recoverers below.
|
||||
Though currently this case only occurs due to broken saves or Ocelot bugs.
|
||||
|
||||
Finally, the third mode, **reconciliation**, acts as a recovery mechanism and is performed after loading the inventory contents.
|
||||
Its goal is to ensure a slot in the Desktop inventory and the corresponding slot in the brain inventory have the same contents.
|
||||
If the contents diverge, it tries to preserve as much data as possible:
|
||||
- when one of the slots is empty, desktop-to-brain or brain-to-desktop sync is done to fill the empty slot with the contents of the other
|
||||
- when both slots are non-empty, desktop-to-brain sync is performed because items contain more data than entities (the entity is removed and replaced as a result)
|
||||
|
||||
The desktop-to-brain synchronization in either of those cases should never arise during normal operation.
|
||||
The brain-to-desktop sync is primarily used to load old saves made before the inventory system was introduced to Ocelot Desktop.
|
||||
|
||||
The synchronization algorithm is iterative, and it runs until it reaches a fixpoint.
|
||||
To demonstrate this, here's what happens when either of the inventories changes — say, a Desktop item is inserted.
|
||||
|
||||
```plantuml
|
||||
autonumber
|
||||
|
||||
actor User
|
||||
control SyncedInventory as Sync
|
||||
control "brain Inventory" as Brain
|
||||
|
||||
User -> Sync: inserts an item
|
||||
activate Sync
|
||||
|
||||
Sync -> Sync: updates the contents
|
||||
Sync -> Sync: onItemAdded()
|
||||
|
||||
activate Sync
|
||||
Sync -> Sync: sync(DesktopToBrain)
|
||||
|
||||
activate Sync
|
||||
Sync -> Brain: inserts the item's entity
|
||||
|
||||
activate Brain
|
||||
Brain -> Brain: updates the contents
|
||||
Brain -> Brain: onEntityAdded()
|
||||
|
||||
activate Brain
|
||||
Brain -> Brain: sends an InventoryEntityAddedEvent
|
||||
|
||||
activate Brain
|
||||
Brain -> Sync: dispatches the event
|
||||
|
||||
activate Sync
|
||||
Sync -> Sync: sync(BrainToDesktop)
|
||||
|
||||
activate Sync
|
||||
Sync -> Sync: the slots are in sync, no-op
|
||||
|
||||
autonumber stop
|
||||
|
||||
return
|
||||
return
|
||||
return
|
||||
return
|
||||
return
|
||||
return
|
||||
return
|
||||
return
|
||||
```
|
||||
|
||||
As you can see on this diagram, `sync` is recursively called to handle the brain inventory update, even though the first sync was the one to trigger the update.
|
||||
This is handled as follows: when the two items are in sync, calling the `sync` method does not change the state in any way, and the method is a no-op.
|
||||
This allows the recursion to terminate.
|
||||
|
||||
More complicated cases arise if one round-trip is not enough for the state to converge, which happens if a callback triggered on a slot update changes the contents of the slot.
|
||||
In general, this is a pathological scenario which you should avoid if at all possible.
|
||||
The `sync` method will still work in this case as long as the contents converge to a synchronized state.
|
||||
But to avoid endless recursion and crashes, it puts a limit on the number of calls to `sync` by tracking its "fuel".
|
||||
The first call starts out with the maximum amount of fuel.
|
||||
Each call decrements it until it reaches zero.
|
||||
Once the first call finishes, the fuel is again reset to the maximum value.
|
||||
|
||||
If the amount of fuel reaches zero, `sync` assumes it is stuck in an infinite loop and breaks out of it by forcibly removing the contents of the conflicting slots.
|
||||
It doesn't perform any synchronization during the removal, so the inventories may end up in an inconsistent state after that.
|
||||
This mechanism is mainly intended to prevent Ocelot from crashing outright, but otherwise the code should not rely on it:
|
||||
endless synchronization is a bug that has to be fixed.
|
||||
|
||||
### Recoverers
|
||||
Whenever a `SyncedInventory` needs to recover an item from an entity, it does so by using a **recoverer**.
|
||||
Its job is, given the entity, create an item that manages it.
|
||||
Recoverers are provided by item factories: one for each source type (or possibly none).
|
||||
They are added to the registry when the factory is registered there.
|
||||
|
||||
The recoverer is then chosen from the registry based on the source entity's runtime class.
|
||||
While searching for the recoverer, it also checks the source entity's superclasses (including traits), stopping when it finds an entry for the most specific class.
|
||||
(Ex.: since the `CPU` class extends the `GenericCPU` trait, a recoverer for the `GenericCPU`, should it be registered, will be used, provided no more specific one exists.)
|
||||
|
||||
### Registry and item groups
|
||||
The item registry stores all item factories available in Ocelot Desktop.
|
||||
The factories are organized into groups, which directly correspond to the item chooser's menu entries.
|
||||
If you add a new item, you should register it so it shows in the list.
|
||||
|
||||
### Slot widgets
|
||||
- [ ] TODO: different kinds of slot widgets
|
||||
- [ ] TODO: item admissibility and item chooser's list filtering
|
||||
4
doc/ocelot-inv.svg
Normal file
4
doc/ocelot-inv.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
Loading…
x
Reference in New Issue
Block a user