Sunday, April 17, 2011

D&D BattleMap Using the UDK - Torches and Lighting

This post, I eagerly wanted to get torches back into our BattleMap.  The iconic dungeon torch is oddly comforting for players.  Telling them "you can see 5 squares from where you stand" and actually having a lit torch on the map are two different things.  You'd be surprised to find that they'll huddle around a torch bearer when they can actually see the range of their vision.

The first time we tried to implement this we made the torch radius configurable through the command console.  Not so great for trying to setup a candle or a sunrod, while not affecting every other torch on the map.  This time, we're going to add mouse selection and keyboard commands to modify individual torches.

BattleMapTorch.uc

The first thing we need is an object that looks like fire.  I simply followed the 3D Buzz Video Tutorials on particle emitters.  Literally, click-for-click.  The only thing I did different was turn off the smoke, since it would obscure the map from our top-down view.

Once you have the emitter built, that becomes the base for our torch object.  But, we need to add some features to it.  Drop one onto a map, then open the properties window.  There's a nifty little icon in the upper right hand corner that looks like a wrench.  Choose "Copy To Clipboard", then paste the contents into an editor.  What you see are the properties that would be set in the DefaultProperties section of an emitter.uc object.  That's what we'll start with.  (Not all of those properties are necessary, but it was certainly easier to start with that than trying to figure them all out from scratch.)

Next, we add some network replication functionality.  The BattleMap does work with multiple clients, however we found that using InputDirector is easier.  (See the post on the setup.)

Moving the torch is simply a matter of switching between two states: one that updates the torch's location from the mouse position and one that doesn't.  We turn off collision detection when we're moving it around to avoid it getting stuck on walls or terrain.  Why does stopping the torch also include updating the location?  That has to do with network replication.  For efficiency, synchronization information is not always transmitted.  Some packets are skipped.  (That's why you can be aiming right at someone's head but still miss a shot.)  So, when the torch is no longer moving it may actually appear to be in different locations on clients.  This forces every copy of the torch to be in exactly the same spot.

Lastly, we add a LightAttachment component to the torch to actually create the torch light.  Otherwise, it would just be a flame in the dark.

class BattleMapTorch extends EmitterSpawnable;
var Color LightColor;
var float LightScale;
var PointLightComponent LightAttachment;
var Vector VerticalOffset;
var repnotify Vector TorchLoc,TorchStopLoc;
var float TorchRadius;
replication
{
 if (bNetDirty)
  TorchLoc,TorchStopLoc;
}
// called on clients when TorchStopLoc gets replicated
simulated event ReplicatedEvent( name VarName )
{
 if (VarName == nameof(TorchLoc) )
  SetLocation( TorchLoc );
 else if (VarName == nameof(TorchStopLoc) )
  StopTorch( TorchStopLoc );
 else
  super.ReplicatedEvent( VarName );
}
// this function should be run on both clients and server
simulated function MoveTorch( )
{
 bCollideWorld = false;
 SetCollision(false, false);
 GotoState( 'TorchMoving' );
}
simulated function StopTorch( Vector NewTorchStopLoc )
{
 bCollideWorld = true;
 SetCollision(true, true);
 GotoState('TorchStopped');
 TorchStopLoc = NewTorchStopLoc;
 SetLocation(NewTorchStopLoc);
}
simulated state TorchMoving
{
 simulated event Tick( float DeltaTime )
 {
  // move the torch...
  TorchLoc = BattleMapPlayerController(Owner).MouseOrigin + VerticalOffset;
  SetLocation(TorchLoc);
 }
}
simulated state TorchStopped
{
 simulated event Tick( float DeltaTime )
 {
  // stop the torch...
 }
}
simulated function PostBeginPlay()
{
 LightAttachment = new(self)class'PointLightComponent';
 LightAttachment.SetLightProperties(LightScale, LightColor);
 LightAttachment.SetEnabled(true);
 LightAttachment.Radius = TorchRadius;
 LightAttachment.SetTranslation(vect(0, 0, 1) * 50);
 if ( LightAttachment != None )
 {
  self.AttachComponent(LightAttachment);
 }
 super.PostbeginPlay();
}
DefaultProperties
{
 TorchRadius=600
 VerticalOffset=(X=0,Y=0,Z=50)
 LightColor=(B=0,G=200,R=255,A=255)
 LightScale=4.0
    DrawScale=2.000000
 Begin Object Name=ParticleSystemComponent0
        Template=ParticleSystem'BattleMapAssets.Particles.PS_Torch'
        ReplacementPrimitive=None
        LightingChannels=(bInitialized=True,Dynamic=True)
  SecondsBeforeInactive=0
 End Object
 ParticleSystemComponent=ParticleSystemComponent0
 Begin Object Class=CylinderComponent Name=CollisionCylinder
  CollisionRadius=+0064.000000
  CollisionHeight=+0064.000000
  BlockNonZeroExtent=true
  BlockZeroExtent=true
  BlockActors=true
  CollideActors=true
 End Object
 CollisionComponent=CollisionCylinder
 Components.Add(CollisionCylinder)
   RemoteRole=ROLE_SimulatedProxy
   bAlwaysRelevant=true
   bReplicateMovement=true
   bNetTemporary=false
   bCanBeDamaged = false
   bCollideActors = true
   bBlockActors = true
   bCollideWorld = true
   bStatic=false
   bNoDelete=false
   bMovable=true
}

BattleMapPlayerInput.uc

Now, we need to add functions to our PlayerInput object to manipulate torches.  First is a method to spawn a torch.  We created an all-purpose spawn function, and will use keybind parameters to pass in what to spawn:

exec function BMSpawn(string ObjectToSpawn)
{
 if (WorldInfo.NetMode == NM_Standalone || WorldInfo.NetMode == NM_ListenServer)
 {
  switch(ObjectToSpawn)
  {
   case "Torch":
    Spawn(class'BattleMapTorch',Outer,,MouseOrigin,,,true );
    break;
  }
 }
}
Next, we want to activate functionality of an object when it's selected.  In this case, we want the torch to start moving when it's clicked.  But each object may do something different.  For example, the triggers we implemented earlier will simply "turn on" -- nothing to continue doing.  So, the function will first stop whatever a currently selected object may be doing (stop moving if we had already selected a torch) then start whatever the newly selected object needs to do.  Replace the BMInteractObject() function with the following:
function string ParseObjectName(Name ObjectName)
{
 local string ObjectType;

 ObjectType = Repl(ObjectName, "BattleMap", "");
 if (InStr(ObjectType, "_") > -1)
  ObjectType = Mid(ObjectType, 0, InStr(ObjectType, "_"));
 return ObjectType;
}

exec function BMInteractObject()
{
 local BattleMapTorch To;
 local Trigger Tr;
 local int i, j, k;

 if (WorldInfo.NetMode == NM_Standalone || WorldInfo.NetMode == NM_ListenServer)
 {
  // stop doing whatever the currently selected object is doing
  if (ObjectSelected != none)
  {
   switch(ParseObjectName(ObjectSelected.Name))
   {
    case "Torch":
     To = BattleMapTorch(ObjectSelected);
     To.StopTorch(MouseOrigin);
     ObjectSelected = none;
     break;
   }
  }
  //start doing something with a newly selected object
  switch(ParseObjectName(ObjectUnderMouse))
  {
   case "Torch":
    foreach DynamicActors(class'BattleMapTorch', To)
     if (To.Name == ObjectUnderMouse)
     {
      ObjectSelected = To;
      To.MoveTorch();
     }
    break;
   case "Trigger":
    // activate each Kismet event attached to this trigger
    foreach DynamicActors(class'Trigger', Tr)
     if (Tr.Name == ObjectUnderMouse)
      for (i=0; i<Tr.GeneratedEvents.Length; i++)
       for (j=0; j<Tr.GeneratedEvents[i].OutputLinks.Length; j++)
        for (k=0; k<Tr.GeneratedEvents[i].OutputLinks[j].Links.Length; k++)
         Tr.GeneratedEvents[i].OutputLinks[j].Links[k].LinkedOp.ForceActivateInput(Tr.GeneratedEvents[i].OutputLinks[j].Links[k].InputLinkIdx);
    break;
  }
 }
}
As we add more interactive objects to our BattleMap, we'll insert the method to trigger them in this function.  Also, add this declaration to the top of the code along with GridZoomDelta:

var Object ObjectSelected;
Finally, once we have a torch selected we have the ability to modify just that instance of the object.  In this case, we want to be able to modify its light radius.  Another frustrating issue we had recently, due to the summer time change, was seeing the projected map in a lit room.  Even with the lights off, it can be difficult to see what you thought was a brightly lit map in the editor.  So, we wanted the ability to adjust the map's brightness.  Therefore if no object is selected, the default behavior will be to manipulate the lights in a map.  Note that this only works for dynamic lights.  Static lights are... static.  Add the following function:

exec function BMModifyObject(int Modifier)
{
 local BattleMapTorch T;
 local PointLightToggleable L;
 local LightComponent C;

 if (WorldInfo.NetMode == NM_Standalone || WorldInfo.NetMode == NM_ListenServer)
 {
  if (ObjectSelected != none)
  {
   switch(Left(ObjectSelected, 14))
   {
    case "BattleMapTorch":
     T = BattleMapTorch(ObjectSelected);
     T.LightAttachment.Radius = T.LightAttachment.Radius * (1 + (0.1 * Modifier));
     break;
   }
  }
  else
  {
   foreach AllActors(class'PointLightToggleable', L)
   {
    C = L.LightComponent;
    C.SetLightProperties(C.Brightness * (1 + (0.25 * Modifier)));
   }
  }
 }
}
DefaultInput.ini

All that's left is to bind keys to the functions we created.  A "T" will spawn a torch.  Left clicking will interact with objects. And the plus/minus keys ("Equals"/"Underscore") will manipulate selected objects.  Replace the "I" binding in our BattleMap Bindings section with the following:

.Bindings=(Name="T",Command="BMSpawn Torch")
.Bindings=(Name="LeftMouseButton",Command="BMInteractObject")
.Bindings=(Name="Equals",Command="BMModifyObject 1")
.Bindings=(Name="Underscore",Command="BMModifyObject -1")

Here's what it looks like in action:



BattleMap mode source files

Tuesday, April 5, 2011

D&D BattleMap Using the UDK - Making It Interactive

So far, we're able to view a UDK map top-down and overlay it with a grid.  That's great for replacing hand drawn or tiled maps - we can show a tremendous amount of detail.  But to really impress our players, we want to be able to move objects on an interactive map.  Fortunately, the UDK provides that capability with Kismet and Matinee.  However, we have two problems.

Once we position a room and overlay the grid, we don't want to have to move around to activate triggers.  (Remember, positioning the camera actually involves moving our invisible avatar.)  Besides, our pawn is an empty mesh with no Collision Model so it wouldn't activate triggers anyway.  What we need is the ability to trigger actions with keyboard input.

There are probably a dozen or more ways to accomplish this, but one goal we had from the beginning was to keep the map and script separate.  We could easily script our own Kismet actions, but then the map builder would have to have our scripts installed beforehand to use its custom actions.  Not the ideal solution.  We want to be able to have anyone make any map, then launch it with our scripts to turn it into a D&D battlemap.  So, we're going to try to use default Kismet triggers.

BattleMapPlayerController.uc

First, we're going to add a new variable to our PlayerController to store what the mouse cursor is floating over.  The functionality we're about to implement could all be done in the PlayerInput object when a key is pressed, but as long as we're tracing the mouse position every tick anyway we might as well store what our trace hit so we can use it later and save ourselves some cycles.  Add this to the top:
var name ObjectUnderMouse;
Then update the MouseOrigin functions so that the HUD can pass in the object that it traced:
simulated function SetMouseOrigin(Vector NewMouseOrigin, name NewObjectUnderMouse)
{
 // avoid spamming if the cursor isn't moving
 if (MouseOrigin != NewMouseOrigin)
 {
  MouseOrigin = NewMouseOrigin;
  ObjectUnderMouse = NewObjectUnderMouse;
  ServerMouseOrigin(NewMouseOrigin, NewObjectUnderMouse);
 }
}

reliable server function ServerMouseOrigin(Vector NewMouseOrigin, name NewObjectUnderMouse)
{
 MouseOrigin = NewMouseOrigin;
 ObjectUnderMouse = NewObjectUnderMouse;
}
BattleMapHUD.uc

Replace the PostRender event with the following.  We're now capturing the actor that was hit by the trace and passing it to our PlayerController.  If nothing was hit, we just pass it the base WorldInfo so we don't have to test for nulls.

(We also did an additional bit of cleanup here and fixed the starting point of the trace since we changed our Camera object last blog post.)
event PostRender()
{
 local BattleMapPlayerController bmPlayerController;
 local vector startTrace;
 local Vector endTrace;
 local Vector mouseOrigin;
 local Vector mouseDirection;
 local string StringMessage;
 local TraceHitInfo hitInfo;
 local Actor traceHit;

 super.PostRender();

 StringMessage = "";
 bmPlayerController = BattleMapPlayerController(PlayerOwner);
 bmPlayerController.MousePosition = GetMouseCoordinates();
 //Deproject the mouse from screen coordinate to world coordinate and store World Origin and Dir.
 Canvas.DeProject(bmPlayerController.MousePosition, mouseOrigin, mouseDirection);
 if (bmPlayerController.Pawn != none)
 {
  startTrace = bmPlayerController.Pawn.Location;
  startTrace.Z += BattleMapCamera(bmPlayerController.PlayerCamera).CameraZOffset;
  endTrace = startTrace + (mouseDirection * 20000);
  traceHit = Trace(mouseOrigin, mouseDirection, endTrace, startTrace, true, , hitInfo);
 }
 if (traceHit == none)
  traceHit = WorldInfo;
 // save final mouse 3d position (on ground)
 bmPlayerController.SetMouseOrigin(mouseOrigin, traceHit.Name);

 StringMessage = "NetMode=" @ WorldInfo.NetMode @ "\nMousePosition=" @ bmPlayerController.MousePosition.X @ "," @ bmPlayerController.MousePosition.Y @ "\nMouseOrigin=" @ mouseOrigin.X @ "," @ mouseOrigin.Y @ "," @ mouseOrigin.Z @ "\nObjectUnderMouse=" @ traceHit.Name @ "\nObjectClass=" @ traceHit.Class;
 //`Log(StringMessage);

 Canvas.DrawColor = GreenColor;
 Canvas.SetPos( 10, 10 );
 Canvas.DrawText( StringMessage, false, , , TextRenderInfo );
}
BattleMapPlayerInput.uc

This is where the magic happens and is what keeps us from having to create custom Kismet actions.  Add the following function to our PlayerInput object.  This will get called when a key is pressed.  Essentially, this function loops through all the Trigger actors in the map and finds the one we moused over.  Then it manually activates all the actions linked to the Trigger.  This gives us a tremendous amount of flexibility.
exec function BMInteractObject()
{
 local Trigger T;
 local int i, j, k;

 // activate each Kismet event attached to this trigger
 if (WorldInfo.NetMode == NM_Standalone || WorldInfo.NetMode == NM_ListenServer)
  foreach DynamicActors(class'Trigger', T)
   if (T.Name == ObjectUnderMouse)
    for (i=0; i<T.GeneratedEvents.Length; i++)
     for (j=0; j<T.GeneratedEvents[i].OutputLinks.Length; j++)
      for (k=0; k<T.GeneratedEvents[i].OutputLinks[j].Links.Length; k++)
       T.GeneratedEvents[i].OutputLinks[j].Links[k].LinkedOp.ForceActivateInput(T.GeneratedEvents[i].OutputLinks[j].Links[k].InputLinkIdx);
}

DefaultInput.ini

Finally, we just need to connect our function with a key.  In the BattleMap Bindings section, add these two lines to make the "i" key interact with the map:
.Bindings=(Name="BMInteractObject",Command="BMInteractObject")
.Bindings=(Name="I",Command="BMInteractObject")

Now, anything you can animate in your map using Kismet and Matinee can be triggered on command.  Things like opening doors, turning on lights, activating traps, etc.

This shows how quickly and easily you can whip together a map:



BattleMap mode source files