Статьи

Взгляд на агентов, цели и поведение в GameplayKit

GameplayKit  — это одна из новых платформ, представленных на WWDC 2015. Она включает в себя некоторые интересные функции, которые предназначены не только для игр, таких как  генерация случайных чиселконечные автоматы  и поиск путей. 

В этой статье я расскажу об агентах, целях и поведении, которые позволяют создавать агентов, которые следуют простым динамическим правилам, таким как сплоченность и разделение. Чтобы продемонстрировать влияние правил и целей на агентов, я создал небольшое демонстрационное приложение  GameplayKitAgents , которое создает систему из 250 агентов с пользовательским интерфейсом для перемещения препятствий и поиска целей и изменения веса различных целей.

Агенты

Система компонентов GameplayKit начинается с экземпляра GKComponentSystem, экземпляр которого создается с классом компонента, который мы хотим использовать в качестве его членов. GKComponentSystem является однородной, и этот класс компонентов является неизменным. В случае моего приложения я буду использовать GKAgent2D, поэтому моя строка открытия выглядит так:

    let agentSystem =  GKComponentSystem(componentClass: GKAgent2D.self)

I’m going to create an array of 250 agents, since GKAgent2D is a class and passed by reference, if I do this:

    let agents = [GKAgent2D](count: 250, repeatedValue: GKAgent2D())

I actually only get one agent, referenced 250 times in the array, so I populate the agent system’s components in a loop:

    for _ in 0 ... 250
    {
        agentSystem.addComponent(GKAgent2D())
    }

Goals

Next, I create some goals. These are instances of GKGoal and define the forces that act upon each agent. Rather than creating a multitude of sub-classes, Apple have chosen to implement different goals with different constructors, for example wandering and cohesion are created with these initialisers:

public convenience init(toCohereWithAgents agents: [GKAgent], 
 maxDistance: Float, 
 maxAngle: Float)

public convenience init(toWander speed: Float)

Since there’s no way to interrogate a goal after creation to see what type it is, I’ve created my own class, NamedGoal, which includes name and weight properties. So my cohesion and wander goals look like this:

   lazy var cohesionGoal: NamedGoal =
    {
        [unowned self] in
        NamedGoal(name: "Cohesion",
            goal: GKGoal(toCohereWithAgents: self.agentSystem.getGKAgent2D(),
                maxDistance: 20,
                maxAngle: Float(2 * M_PI)),
            weight: 50)

    }()

    let wanderGoal = NamedGoal(name: "Wander",
        goal: GKGoal(toWander: 25),

        weight: 60)

I’ve also added an extension to GKComponentSystem so that I can get a typed array of GKAgent2D to use when constructing goals that reference the agents, for example, the cohesion goal:

    extension GKComponentSystem
    {
        func getGKAgent2D() -> [GKAgent2D]
        {
            return components
                .filter({ $0 is GKAgent2D })
                .map({ $0 as! GKAgent2D })
        }

    }

My system also includes a seek target (in blue) and obstacles to avoid (in red). These are also defined as goals. For the obstacles, I’ve created an array of four GKCircleObstacle, 

  let obstacles = [GKCircleObstacle(radius: 100),
        GKCircleObstacle(radius: 100),
        GKCircleObstacle(radius: 100),

        GKCircleObstacle(radius: 100)]

…and passed that to an avoid goal:

   lazy var avoidGoal: NamedGoal =
    {
        [unowned self] in
        NamedGoal(name: "Avoid",
            goal: GKGoal(toAvoidObstacles: self.obstacles,
                maxPredictionTime: 2),
            weight: 100)

    }()

The seek goal uses an agent, 

  let targets = [GKAgent2D()]

    lazy var seekGoal: NamedGoal =
    {
        [unowned self] in
        NamedGoal(name: "Seek",
            goal: GKGoal(toSeekAgent: self.targets.first!),
            weight: 50,
            weightMultiplier: 0.01)

    }()

Once I’ve crafted all of my NamedGoal instances, I create an array to hold them:

lazy var namedGoals: [NamedGoal] =
    {
        [unowned self] in
        [self.wanderGoal, self.cohesionGoal, self.avoidGoal, self.seekGoal]

    }()

Behaviours

To apply the goals to the system, I use a GKBehavior. 

    let behaviour = GKBehavior()

By looping over my namedGoals array, I can add each goal to the behaviour by simply invoking setWeight which adds or changes the weight of a goal on that behaviour:

  for namedGoal in namedGoals
    {
         behaviour.setWeight(namedGoal.weight, forGoal: namedGoal.goal)
    }

Then a loop over each of the agents in the system allows me to apply my behaviour to each one:

    for agent in agentSystem.getGKAgent2D()
    {
        agent.behavior = behaviour

    }

Animation and Rendering

To animate the system, I use a CADisplayLink which will fire step with each screen refresh:

 lazy var displayLink: CADisplayLink =
    {
        [unowned self] in
        return CADisplayLink(target: self, selector: Selector("step"))

    }()

    // in init()
    displayLink.addToRunLoop(NSRunLoop.mainRunLoop(),

        forMode: NSDefaultRunLoopMode)

I’m drawing the agents by constructing a single UIBezierPath with a small circle for each agent and setting that as the path for my view’s layer. So, my step method does two things, first updates the system so each agent has its position changed by the forces of the goals acting upon it, then second it creates the path and applies it: 

    func step()
    {
        agentSystem.updateWithDeltaTime(0.05)

        let bezierPath = UIBezierPath()

        for agent in agentSystem.getGKAgent2D()
        {
            bezierPath.appendCircleOfRadius(agent.radius,
                atPosition: agent.position,
                inFrame: frame)
        }

        agentsLayer.path = bezierPath.CGPath

    }

appendCircleOfRadius is an extension to UIBezierPath that, unsurprisingly, appends a circle to a path and is happy accepting GameplayKit’s vector_float2 and Float rather than CoreGraphics CGPoint and CGFloat:

    extension UIBezierPath
    {
        func appendCircleOfRadius(radius: Float, atPosition position: vector_float2, inFrame frame: CGRect)
        {
            let position = CGPoint(x: frame.width / 2 + CGFloat(position.x),
                y: frame.height / 2 + CGFloat(position.y))

            let circle = UIBezierPath(ovalInRect: CGRect(origin: position.offset(dx: radius, dy: radius),
                size: CGSize(width: CGFloat(radius * 2), height: CGFloat(radius * 2))))

            appendPath(circle)
        }

    }

User Interface

Finally, the user interface is built from a few components. 

  • The main view, AgentsView, contains the system, the display link and pretty much everything discussed in this post.
  • The editor view, AgentsEditor, contains a UITableView with a row for each NamedGoal, when the sliders in the table view change, the editor invokes goalWeightDidChange on its AgentsEditorDelegate. In my demo app, the view controller acts as the AgentsEditorDelegate and invokes setWeight on the AgentsView.

The obstacles and target can be moved around with a touch/drag which is handled inside AgentsView.

In Conclusion

GameplayKit offers some pretty high level dynamic behaviours with a very simple API. I’ve coded similar systems in the past (see my Swarm Chemistry stuff) and it can be tricky to get right. Performance is pretty impressive, on my iPad Pro, this app breezes along with 500 agent, but I’ve set the default to 250 agents.

I don’t think this framework is limited to games, my first thought for agents, goals and behaviours was for a simple crowd simulation application.

As always, the source code for this demo is available at my GitHub repository here. Enjoy!