How can I combine ECS with SwiftUI? (entity component system)

396 Views Asked by At

Maybe you could shine some light on my dark and ignorant path? My destination is a SwiftUI app where I can inject components into views and have systems run some logic on those components. Every time I think I've arrived; weaknesses in the foundation makes me landslide back to the start. Let me show you what I've tried!

protocol DataComponent: Component {
    associatedtype DataType
    var data: DataType { get set }
    init()
    init(data: DataType)
}

struct NameComponent: DataComponent { var data = "default" }

Here's the basic data component. I'm using EntitasKit for my entity-component-system.

enum Entities {
    case source
    ...
}

protocol EntityServicing {
    var context: Context { get }
    func entity(_ entity: Entities) -> Entity
}

final class EntityService: EntityServicing {
    let context = Context()
    func entity(_ entity: Entities) -> Entity {
        switch entity {
        case .source:
            guard let first = context.entities.first else {
                return context.createEntity()
            }
            return first
        ...
        }
    }
}

Here's the application scoped EntityService which holds the context for entities and their components. It is injected using Resolver.

Now, here's what a view looks like in SwiftUI.

struct ContentView: View {
    @EntityState(.source, "Dale Cooper") var name: NameComponent
    var body: some View {
        Button(action: { name = NameComponent(data: "Dougie Jones") }) {
            Text(name.data).padding()
        }
    }
}

EntityState is just what it sounds; a property wrapper that captures the state of an entity's component and makes it visible to SwiftUI.

@propertyWrapper
struct EntityState<C>: DynamicProperty where C: DataComponent {

    @State private var state = C()
    @Injected private var entityService: EntityServicing
    private let entityID: Entities
    private var entity: Entity { entityService.entity(entityID) }
    
    init(_ id: Entities, _ data: C.DataType? = nil) {
        self.entityID = id
        if let data = data {
            entity.set(C(data: data))
        } else {
            guard entity.has(C.cid) else {
                entity.set(state)
                return
            }
        }
        _state = State(wrappedValue: entity.get()!)
    }

    public var wrappedValue: C {
        get { entity.get()! }
        nonmutating set { state = newValue }
    }
        
    mutating func update() {
        entity.set(state)
    }
}

SwiftUI notices the state change in the dynamic property. Clicking the button re-renders the view and updates the component on the entity.

This foundation starts to crumble when I add a system that changes the component.

class NameSystem: ReactiveSystem {
    
    @Injected var entityService: EntityServicing
    lazy var collector: Collector = Collector(
        group: entityService.context.group(
            Matcher(
                all: [NameComponent.cid]
            )
        ),
        type: .added
    )
    let name = "Name System Added Here!"

    func execute(entities: Set<Entity>) {
        for e in entities {
            if let data = e.get(NameComponent.self)?.data {
                e.set(NameComponent(data: "Special Agent " + data))
            }
        }
    }
}

The problem is that the SwiftUI state is never notified, but I want SwiftUI to know about the changes happening to components when they are updated by systems.

I have tried many different ways:

  • Component/Entity/Context as ObservableObject using @Published. My understanding is that this is a good way to connect your data model to SwiftUI. I’ve @Published components, the data in components and entities, but the problem that keeps stopping me is getting the EntityState to re-render the view. Since the Context is injected I cannot use other property wrappers like @ObservedObject.
  • Bindings. I’ve tried observing a component object in the EntityState and passing a component binding to the entity, but I can’t store bindings to different DataComponents in the same dictionary. I’m not even sure if changes to the bindings would get back to the EntityState to re-render the view. This same limitation goes for bindings to @States. Anyhow, I’ve got myself stuck down this road.
  • Publisher and Subscriber. Turning EntityState into a subscriber so it can receive values requires it to be mutable. To make EntityState mutable it must become a class, but this breaks the SwiftUI re-rendering. The publisher could be a Subject of some kind or a custom publisher for components. I just can’t solve how to receive values and assigning them to the state so SwiftUI re-renders. The whole Combine area is a bit fuzzy for me. There’s probably a smart way to do something amazing that I don’t know about.

How can I assign components to the state from a system so that SwiftUI re-renders?

I hope you find this an interesting problem to solve, and that it made you as curious as I am to the solution. It gets me really excited at least.

Have a nice day everyone!

0

There are 0 best solutions below