A use case: soldiers attacking the player (setup) - makingthematrix/gailibrary GitHub Wiki

In this scenario, the player enters a room and is attacked by two NPCs. The NPCs should come into the range of sight of the player in order to shoot her, but they should try to maintain a reasonable distance. They should also maintain a distance from each other. Ideally, they should attack the player from different angles. When wounded, they should try to hide, but still shooting at the player, unless they're out of ammo.

First, let's draw a map of the room.

click here (It's a very professional graph made with very professional tools, I know)

We register the Player cell type and create one cell of this type with the name „player” and default values:

let player = new CellType {
	type = GAI.ids.cellType(„player”),
	values = { „pos” -> Point2D(0, 0), „rot” -> Arrow2D(1.0, 0.0), „health” -> Percent(100) },
	lazyVals = {},
	randomSeed = None
}
GAI.cellTypes.register(player)

We register the NPC (non-player character) cell type and create two cells of this type, „npc_1” and „npc_2”, with their positions, respectively at nodes 4 and 6:

let npc = new CellType {
	type = GAI.ids.cellType(„NPC”),
	values = { „pos” -> Point2D(0, 0), „rot” -> Arrow2D(1.0, 0.0), „health” -> Percent(100), „ammo” → Percent(100) },
	lazyVals = {},
	randomSeed = None
}
GAI.cellTypes.register(npc)

We assume that the amount of ammo of the player play no role in AI decisions.

We register a spacial graph called „pos”:

Click here to see a table with the graph's data Very professional, too. I'm not sure yet how I want to implement graph creation. Imagine it's awesome.

We invert the position graph, remove the edge (4,6) and register the results as „outofsight” for checking where to hide.

GAI.graphs.register(„outofsight”, graphs(„pos”).copy().invert().remove_edge(4,6))

We copy the position graph, remove node „1” and register the result as „tag”.

GAI.graphs.register(„tag”, graphs(„pos”).copy().clear_refs())

This is the same as the position graph, but with refs showing where the NPCs want to go, not where they are.

After that we need to register „pos” as the graph which will be updated at the beginning of each iteration with current positions of the player and the NPCs. Evey time NPCs will need to know where they are and where is the player, they will access the graph instead of references to their cells.

Actually, the player cell type does not need any other function than this.

As for the NPCs’ functions, we need a lot of them :) But first let’s discuss what we want to achieve. How NPCs should behave.

Each NPC should have a designated initial position in the room. If there is no player in the room, the NPC should go to that position: the node „4” for „npc_1”, and the node „6” for „npc_2”. If there is a player in the room, the NPC should check its own health and ammo, if the player is visible, and if the NPC is turned towards the player. This is where lazy vals become useful: if the player is not visible, there is no point in checking if the NPC is turned towards her. But if the player is visible, and NPC is turned towards her, and if NPC has ammo, it should start shooting. Note that „turn_towards” may return true even if NPC is not exactly turned towards. Actually, that would be impractical, because both NPCs and the player move constantly. „turned_towards” should then have some tolerance, eg. 10 degrees wide cone where it’s ok to shoot. So, even if „turned_towards” returns true, NPC should still try to turn towards the player if it has ammo. In the corner case it will just have no effect. If NPC has no ammo, it should turn around and run to the closest point invisible to the player which is not tagged by another NPC. The same if health is below certain level, but then it should not turn around, but still shoot. If health is above that level, NPC should move to the closest node visible to the player and not tagged by another NPC (nor the player). Furthermore, if health is low and NPC is retreating, this point does not have to be empty. Tagging of nodes is for tactics: if one node is already tagged by „npc_1”, „npc_2” will look for another node from which it will be able to shoot at the player and quite often that will result in assaulting the player from different directions.

Shooting cones and computing rotation from positions shows immediately how much vector geometry is needed. There should be a math lbirary for that – I don’t want to do it myself :) I can only provide additional data types: points and "arrows".

Now we can define lazy vals:

„player_in_room”: { _  => graph(„pos”).contains_ref(„player”) }

„is_healthy”: { npc => npc(„health”) >= 50 }

„has_ammo”: { npc => npc(„ammo”) > 0 }

„player_visible”: { npc =>
  let pos = graph(„pos”)
  ! graph(„outofsight”).edge( pos.node_of(npc.id) , pos.node_of(„player”) ).exists() 
}

„attack_node”: { npc =>
  let pos = graph(„pos”)
  let oos = graph(„outofsight”)
  let player_pos = pos.node_of(„player”)
  let valid_nodes = graph(„tag”).nodes.filter { n => n.refs.empty && ! oos.edge(n , player_pos).exists() }
  pos.closest( valid_nodes, pos.node_of(npc.id) ).getOrElse(pos.node_of(npc.id))
}

„retreat_node”: { npc =>
  let pos = graph(„pos”)
  let oos = graph(„outofsight”)
  let player_pos = pos.node_of(„player”)
  let valid_nodes = oos.nodes.filter { n => oos.edge(n , player_pos).exists() }
  pos.closest( valid_nodes, pos.node_of(npc.id) ).getOrElse( pos.closest( npc(„designated_pos”) ) )
}

„is_turned”: { npc =>
  let arrow = npc(„pos”).arrow(npc.refs(„player”)(„pos”))
  arrow.mul( npc(„rot”) ) <= 0.1
}

Finally, we can define three functions which will compute result values:

„turn_towards”: { npc =>
  let pos = graph(„pos”)
  if ( !npc(„player_in_room”) ) npc(„rot”) 
	// player not in the room – do nothing
  else if ( !npc(„has_ammo”) )  
	// player in the room, but no ammo – turn around and run away
    pos.path( pos.node_of(npc.id), npc(„retreat_node” ).nextOrClosest().position
  else if ( !npc(„player_visible”) ) 
	// ammo ok, player not visible – go where you see her
    pos.path( pos.node_of(npc.id), npc(„attack_node”) ).nextOrClosest().position
  else npc.refs(„player”)(„pos”) // player visible – turn towards her
}

„shoot”: { npc => npc(„player_in_room”) && npc(„player_visible”) && npc(„has_ammo”) && npc(„is_turned”) } 

„move_towards”: { npc =>
 let pos = graph(„pos”)
 let npc_node = pos.node_of(npc.id)
 let goal_node = if ( !npc(„player_in_room”) ) npc_node // player not in the room – do nothing
  else if ( !npc(„has_ammo”) || !npc(„is_healthy”)  
	// player in the room, but no ammo or wounded – retreat
    pos.path( npc_node, npc(„retreat_node”) ).nextOrClosest()
  else // ammo ok, not wounded – attack
    pos.path( npc_node, npc(„attack_node”) ).nextOrClosest()

  let tag = graph("tag")
  match tag.node_of(npc.id) {
    Some(node) if node == goal_node =>
    Some(node) => 
      node.clear_ref(npc.id)
      tag.add_ref(goal_node.id, npc.id)
    None => tag.add_ref(goal_node.id, npc.id)
  }

  goal_node.position
}

These three are registered as functions, the rest as lazy vals. By registering them as lazy vals we declare that they return only one value. The returned value will be stored in the lazy value map of the cell under the same id as the function id. Standard function may return a value, or None, and they may use npc.set(id: ValueId, value: Value) to set more (kinda like procedures in Pascal). The returned value will be added to the list of results of the cell. This approach is useful when creating new values („results”) which then will be read by external code. The second approach – setting values directly – might make sense if we want to update the values of the cell for the next iteration.