Source code for gale.ai.formation
"""
This file contains support for coordinated group movement: a
FormationPattern gives each slot in a formation an offset relative to
an anchor point (LineFormation, WedgeFormation, CircleFormation are
fixed patterns; ScalableFormationPattern adapts one to however many
members currently occupy it), SlotAssignment assigns characters to
slots minimizing total movement, and FormationManager ties it all
together as "two-level" steering: the anchor moves on its own (driven
by whatever steering behavior you give it, e.g. Arrive at a
destination), and each member is steered towards anchor.position +
pattern.slot_offset(slot, member_count) -- so members follow the
anchor's motion for free, without each one needing to know where the
group as a whole is going.
Note that "emergent" formations (a flock/crowd that stays together
without any explicit pattern) don't need any of this: they fall out of
combining Separation, a cohesion Seek towards the group's centroid, and
VelocityMatch (alignment) with BlendedSteering, all of which already
exist in gale.ai.steering.
Author: Alejandro Mujica (aledrums@gmail.com)
"""
import math
from typing import Dict, Hashable, List, Optional, Sequence
import pygame
from .steering import Kinematic
[docs]
class FormationPattern:
"""
Base class for a formation pattern: gives the offset, relative to
the formation's anchor, that a given slot should occupy.
"""
[docs]
def slot_offset(self, slot: int, member_count: int) -> pygame.Vector2:
"""
:param slot: Index of the slot to get the offset of.
:param member_count: How many members currently occupy the formation.
:returns: The offset, relative to the anchor's position, slot should be placed at.
"""
raise NotImplementedError()
[docs]
class LineFormation(FormationPattern):
"""
Slots placed in a straight line, spaced spacing apart, centered on
the anchor.
"""
def __init__(self, spacing: float = 40) -> None:
"""
:param spacing: Distance between consecutive slots.
"""
self.spacing: float = spacing
[docs]
def slot_offset(self, slot: int, member_count: int) -> pygame.Vector2:
centered_slot = slot - (member_count - 1) / 2
return pygame.Vector2(centered_slot * self.spacing, 0)
[docs]
class WedgeFormation(FormationPattern):
"""
Slots placed in a V shape opening behind the anchor (slot 0), two
per following row, spreading out and back.
"""
def __init__(self, spacing: float = 40, depth: float = 40) -> None:
"""
:param spacing: Horizontal distance between the two slots of a row.
:param depth: Distance each row sits behind the previous one.
"""
self.spacing: float = spacing
self.depth: float = depth
[docs]
def slot_offset(self, slot: int, member_count: int) -> pygame.Vector2:
if slot == 0:
return pygame.Vector2(0, 0)
row = (slot + 1) // 2
side = 1 if slot % 2 == 1 else -1
return pygame.Vector2(side * row * self.spacing / 2, row * self.depth)
[docs]
class CircleFormation(FormationPattern):
"""
Slots placed evenly around a circle centered on the anchor.
"""
def __init__(self, radius: float = 60) -> None:
"""
:param radius: Radius of the circle.
"""
self.radius: float = radius
[docs]
def slot_offset(self, slot: int, member_count: int) -> pygame.Vector2:
if member_count <= 0:
return pygame.Vector2(0, 0)
angle = 2 * math.pi * slot / member_count
return pygame.Vector2(math.cos(angle), math.sin(angle)) * self.radius
[docs]
class ScalableFormationPattern(FormationPattern):
"""
Wraps another FormationPattern and scales its spacing so the whole
formation's footprint stays close to a target size regardless of
how many members currently occupy it, instead of growing without
bound as members are added.
"""
def __init__(
self, base_pattern: FormationPattern, reference_count: int = 4
) -> None:
"""
:param base_pattern: The pattern whose offsets get scaled.
:param reference_count: Member count at which base_pattern's offsets are used unscaled.
"""
self.base_pattern: FormationPattern = base_pattern
self.reference_count: int = reference_count
[docs]
def slot_offset(self, slot: int, member_count: int) -> pygame.Vector2:
offset = self.base_pattern.slot_offset(slot, member_count)
if member_count <= 0:
return offset
scale = math.sqrt(self.reference_count / member_count)
return offset * scale
[docs]
class SlotAssignment:
"""
Assigns characters to formation slots minimizing total movement,
by greedily giving each character the closest still-available slot
-- not globally optimal, but cheap and good enough to avoid members
crossing paths on every reassignment, and stable enough that a
member usually keeps its slot across calls.
"""
[docs]
@staticmethod
def assign(
characters: Sequence[Kinematic],
pattern: FormationPattern,
anchor: pygame.Vector2,
) -> Dict[int, Kinematic]:
"""
:param characters: The characters to assign to slots.
:param pattern: The formation pattern to assign slots from.
:param anchor: The formation's current anchor position.
:returns: A mapping from slot index to the character assigned to it, for slots 0..len(characters) - 1.
"""
member_count = len(characters)
slot_positions = {
slot: anchor + pattern.slot_offset(slot, member_count)
for slot in range(member_count)
}
remaining_slots = set(slot_positions.keys())
assignment: Dict[int, Kinematic] = {}
for character in characters:
best_slot = min(
remaining_slots,
key=lambda slot: (
character.position - slot_positions[slot]
).length_squared(),
)
assignment[best_slot] = character
remaining_slots.remove(best_slot)
return assignment
[docs]
class FormationManager:
"""
Drives a group of characters as a formation: an anchor Kinematic
represents the group's overall position/orientation (steer it
yourself with any SteeringBehavior, e.g. Arrive at a destination),
and each member gets a per-slot target Kinematic positioned at
anchor.position + pattern.slot_offset(slot, member_count) for you
to steer the member towards (e.g. with Arrive). Call update() once
a frame after moving the anchor, and add_member/remove_member as
the group's membership changes -- both trigger a fresh
SlotAssignment so slots stay filled without gaps.
Usage example:
anchor = Kinematic(x=0, y=0)
manager = FormationManager(anchor, WedgeFormation())
manager.add_member(soldier_1)
manager.add_member(soldier_2)
# In the game loop, after steering the anchor with your own
# behavior (e.g. anchor_arrive.get_steering() then anchor.update(...)):
manager.update()
for member in manager.members:
slot_target = manager.slot_kinematic(member)
Arrive(member, slot_target).get_steering(dt)
"""
def __init__(self, anchor: Kinematic, pattern: FormationPattern) -> None:
"""
:param anchor: The Kinematic representing the formation's overall position.
:param pattern: The formation pattern to arrange members in.
"""
self.anchor: Kinematic = anchor
self.pattern: FormationPattern = pattern
self.members: List[Kinematic] = []
self.roles: Dict[int, str] = {}
self._slot_kinematics: Dict[Hashable, Kinematic] = {}
self._assignment: Dict[int, Kinematic] = {}
[docs]
def set_pattern(self, pattern: FormationPattern) -> None:
"""
Switch to a different formation pattern (e.g. a different
tactical "play"), reassigning slots on the next update().
:param pattern: The new pattern to arrange members in.
"""
self.pattern = pattern
[docs]
def add_member(self, character: Kinematic, role: Optional[str] = None) -> None:
"""
:param character: The character to add to the formation.
:param role: An optional label for whichever slot this character ends up assigned to (e.g. "leader", "flank"). The default value is None.
"""
self.members.append(character)
if role is not None:
self.roles[id(character)] = role
[docs]
def remove_member(self, character: Kinematic) -> None:
"""
:param character: The character to remove from the formation.
"""
self.members.remove(character)
self.roles.pop(id(character), None)
self._slot_kinematics.pop(id(character), None)
[docs]
def update(self) -> None:
"""
Reassign slots (via SlotAssignment) and refresh each member's
slot-target Kinematic position/orientation. Call this once a
frame, after moving self.anchor.
"""
self._assignment = SlotAssignment.assign(
self.members, self.pattern, self.anchor.position
)
member_count = len(self.members)
for slot, character in self._assignment.items():
target = self._slot_kinematics.setdefault(id(character), Kinematic())
target.position = self.anchor.position + self.pattern.slot_offset(
slot, member_count
)
target.orientation = self.anchor.orientation
[docs]
def slot_kinematic(self, character: Kinematic) -> Kinematic:
"""
:param character: A member of this formation.
:returns: The Kinematic representing character's current slot target, to be steered towards (e.g. with Arrive). Only valid after update() has been called at least once since character was added.
:raises KeyError: If character has never been assigned a slot yet.
"""
return self._slot_kinematics[id(character)]
[docs]
def role_of(self, character: Kinematic) -> Optional[str]:
"""
:param character: A member of this formation.
:returns: The role character was added with, or None if it wasn't given one.
"""
return self.roles.get(id(character))