Saturday, March 26, 2011

D&D BattleMap Using the UDK - More Dungeon Less UT

Now that we have an idea of how to setup a top-down camera and a grid overlay, it's time to clean things up and drop the Unreal Tournament functionality.  We don't need an avatar running around while we're displaying a map and we want to be able to move independently of CSG and meshes in the level.

Make a backup copy of the files we've created so far.  You'll have to save the copy outside the /Development/Src directory, otherwise the FrontEnd will try to compile them as well.

I'll outline the changes below if you want to follow along.  Otherwise, links to archives of both versions are below.

BattleMapMain.uc

All we're doing here is changing the object we're extending from, to the base GameInfo class.  We'll no longer be playing an Unreal Tournament match when we start a level:
class BattleMapMain extends GameInfo;

...
BattleMapPawn.uc

Changing the base class again, as well as dropping the first person mesh fix (since there won't be any) and supplying an empty pawn.  We also set a few properties to enable the pawn to move freely around the level.  Essentially, we put the pawn in "flying" mode instead of "walking" mode.  That allows us to move vertically as well as horizontally, and effectively zooms and pans the level.

Replace the entire BattleMapPawn.uc code with the following:

class BattleMapPawn extends Pawn;
DefaultProperties
{
 //this is just an empty skeletal mesh to prevent log errors
 Begin Object Class=SkeletalMeshComponent Name=WPawnSkeletalMeshComponent
 End Object
 Mesh=WPawnSkeletalMeshComponent
 Components.Add(WPawnSkeletalMeshComponent)

 bCollideWorld=false
 bCollideActors=false
 WalkingPhysics=PHYS_Flying
 LandMovementState=PlayerFlying
 AirSpeed=+01200.000000
}
BattleMapHUD.uc

All we're doing here is removing the laser sight code.  Delete the following three lines:
// laser sight from pawn to reticle
bmPlayerController.Pawn.GetActorEyesViewPoint(pawnEyeLocation, pawnEyeRotator);
Draw3DLine(pawnEyeLocation, mouseOrigin, RedColor);
BattleMapPlayerController.uc

A lot of changes here.  First, change the base object:
class BattleMapPlayerController extends PlayerController
 config(Game);
Delete the following blocks.  These manipulated the behavior of the UT pawn:

// This basically locks the camera to the pawn's location, and adds CameraZOffset to the Z of the camera.
simulated event GetPlayerViewPoint( out vector out_Location, out Rotator out_Rotation )
{
...
}

// Force WASD to be NWSE, so use world rotation instead of pawn rotation
state PlayerWalking
{
...
}

// This makes our weapons aim to the pawn's rotation, not the controller's rotation
function Rotator GetAdjustedAimFor(Weapon W, vector StartFireLoc)
{
...
}

// Turn the pawn toward the cursor
function UpdateRotation( float DeltaTime )
{
...
}
Add the following block.  This handles the "flying" mode that we enabled in BattleMapPawn.uc:

// Force WASD to be NWSE, so use world rotation instead of pawn rotation
state PlayerFlying
{
 function PlayerMove(float DeltaTime)
 {
  local vector X,Y,Z;

  //GetAxes(Rotation,X,Y,Z);
  GetAxes(WorldInfo.Rotation,X,Y,Z);

  Pawn.Acceleration = PlayerInput.aForward*X + PlayerInput.aStrafe*Y + PlayerInput.aUp*vect(0,0,1);;
  Pawn.Acceleration = Pawn.AccelRate * Normal(Pawn.Acceleration);

  if ( bCheatFlying && (Pawn.Acceleration == vect(0,0,0)) )
   Pawn.Velocity = vect(0,0,0);
  // Update rotation.
  UpdateRotation( DeltaTime );

  if ( Role < ROLE_Authority ) // then save this move and replicate it
   ReplicateMove(DeltaTime, Pawn.Acceleration, DCLICK_None, rot(0,0,0));
  else
   ProcessMove(DeltaTime, Pawn.Acceleration, DCLICK_None, rot(0,0,0));
 }
}
Finally, replace the DefaultProperties section.  We dropped our CameraZOffice parameter and added a reference to a new camera object we're going to create:
DefaultProperties
{
 //this makes us stop immediately instead of decelerating
 bCheatFlying=true

 InputClass=class'BattleMap.BattleMapPlayerInput'
 CameraClass=class'BattleMap.BattleMapCamera'
}
BattleMapCamera.uc

This is a new class to handle the camera.  The base PlayerController object doesn't include the same functionality as UTPlayerController, so we're moving our top-down camera code here.

All we're doing is moving the camera position and POV after the real UpdateViewTarget "does its thing".  We also want to force ApplyCameraModifiers() which handles things like camera shake.  Because the camera will be so far from the source, the engine may try to skip that processing.

We wrap our changes in a check to see if we're in CinematicMode.  We don't want to change the camera if we are.  This allows us to do things like fly-throughs of a big room before a boss fight:

class BattleMapCamera extends Camera;
var int CameraZOffset;
// let it do it's fancy thing, then just set our own LOC and FOV
function UpdateViewTarget(out TViewTarget OutVT, float DeltaTime)
{
 local vector            Loc, Pos;
 local rotator           Rot;

 super.UpdateViewTarget(OutVT, DeltaTime);
 if (!PCOwner.bCinematicMode)
 {
  Rot = rotator(vect(0,0,-1));
  Loc = PCOwner.Pawn.Location;
  Loc.Z = Loc.Z + CameraZOffset;
  Pos = Loc - Vector(Rot);
  OutVT.POV.Location = Pos;
  OutVT.POV.Rotation = Rot;
  OutVT.POV.FOV = DefaultFOV;
 }

 ApplyCameraModifiers(DeltaTime, OutVT.POV);
}

DefaultProperties
{
 CameraZOffset=6000
 DefaultFOV=35
}
BattleMapPlayerInput.uc

Lastly, we just need to change the base object of our PlayerInput class:
class BattleMapPlayerInput extends PlayerInput within BattleMapPlayerController;

...
Now when you launch the game, the Necropolis level should start behaving more like a D&D dungeon map.  Use the WASD keys to pan the map and Space or C to zoom in and out.  Hit G to turn on the grid and resize it with the mouse wheel.

(Note: If you zoom in using the C key and the camera suddenly shifts and "sticks" for a few seconds, what's happening is that your pawn is dying.  You've hit the Kill-Z for the level, just as if your avatar had run off a cliff.  The camera should reset in a few seconds.  In our own levels, we'll set Kill-Z low enough to compensate for the camera height.)

The original top-down UT mode source files
The new BattleMap mode source files

Tuesday, March 8, 2011

D&D BattleMap Using the UDK - The Grid

Building on our previous post, let's add the iconic D&D grid.  This is significantly easier using the Flash UI.  I'll summarize at the end what we had to do to get it to work using just the UDK.

Flash
In Flash, we simply created the grid using the drawing tools.  Vertical and horizontal lines, using a hairline stroke, spaced 20 units apart, covering the entire scene:


In the ActionScript of our Actions layer, we added a couple of functions that will be called by UnrealScript.  The first will show/hide the grid, the second will scale it.  We hide the grid initially to make positioning easier:
function GridToggle()
{
 GridInst._visible = !GridInst._visible;
}

function GridZoom(Param1:Number)
{
 GridInst._xscale = GridInst._xscale + Param1;
 GridInst._yscale = GridInst._xscale;
 GridInst._x = stage.width*.5 - GridInst._width*.5;
 GridInst._y = stage.height*.5 - GridInst._height*.5;
}

GridInst._visible = false;

From now on, whenever you make a change to the HUD you must:
  1. Publish the movie
  2. Open the UDK Editor and reimport the movie into your package using the Content Browser.
  3. Save the package
  4. Copy the package from the UDKGame\Content\Misc folder to the UDKGame\CookedPC folder.
Since we haven't actually referenced the package from the BM-Necropolis.udk map that we're testing with, the Unreal Frontend doesn't include it when it Cooks.  Believe me, I've lost a lot of time trying to figure out why my ActionScript functions weren't being called when I simply forgot step 4 above.

BattleMapGfxHud.uc
Add two wrapper functions for the ActionScript functions above:
function CallGridToggle()
{
 ActionScriptVoid("GridToggle");
}

function CallGridZoom( float Param1 )
{
 ActionScriptVoid("GridZoom");
}

BattleMapPlayerController.uc
All we need to do here is reference a new Input class that we're going to create to interpret keyboard/mouse commands.  Add this to the DefaultProperties section:
 InputClass=class'BattleMap.BattleMapPlayerInput'

BattleMapPlayerInput.uc
This is a new class that will call our wrapper functions based on input events.  I arbitrarily chose a delta of 5.0% when scaling the grid:
class BattleMapPlayerInput extends UTPlayerInput within BattleMapPlayerController;
var float GridZoomDelta;
exec function BMGridToggle()
{
 BattleMapHUD(myHUD).CrossHairMovie.CallGridToggle();
}

exec function BMGridZoomIn()
{
 BattleMapHUD(myHUD).CrossHairMovie.CallGridZoom(-GridZoomDelta);
}

exec function BMGridZoomOut()
{
 BattleMapHUD(myHUD).CrossHairMovie.CallGridZoom(GridZoomDelta);
}

DefaultProperties
{
 GridZoomDelta = 5.0f
}

I know.  We seem to have a lot of functions just calling each other.  Here's the flow:

Input Event > PlayerInput > GFxMoviePlayer > ActionScript

DefaultInput.ini
Lastly, we need to specify what keyboard/mouse events will trigger those actions.  In UDKGame\Config\DefaultInput.ini, scroll all the way down past "Editor Bindings".  Add a new section:
;-------------------
; BattleMap Bindings
;-------------------
.Bindings=(Name="BMGridToggle",Command="BMGridToggle")
.Bindings=(Name="BMGridZoomIn",Command="BMGridZoomIn")
.Bindings=(Name="BMGridZoomOut",Command="BMGridZoomOut")
-Bindings=(Name="G",Command="GBA_SwitchToBestWeapon")
-Bindings=(Name="MouseScrollUp",Command="GBA_PrevWeapon")
-Bindings=(Name="MouseScrollDown",Command="GBA_NextWeapon")
.Bindings=(Name="G",Command="BMGridToggle")
.Bindings=(Name="MouseScrollUp",Command="BMGridZoomOut")
.Bindings=(Name="MouseScrollDown",Command="BMGridZoomIn")

This is doing three things:
  • Maps Input Binding commands with UnrealScript (PlayerInput) functions (I just happened to have named them the same)
  • Unbinds previously specified inputs from their default commands
  • Binds those inputs to our new commands
Essentially, we use the "G" key to toggle the grid and the mouse wheel to scale the grid.

The result is:



For reference, to implement this just using the UDK and UnrealScript, we had to:
  • Paint a grid texture
  • Model a grid mesh (essentially, a 2D plane)
  • Create a BattleMapGridActor in UnrealScript which instantiated the grid mesh, applied the texture, and included several functions for moving ("scaling" by moving up and down, and positioning horizontally)
  • Include numerous functions in BattleMapPlayerInput for spawning, moving and snapping the grid
It was a huge amount of code, compared to the few changes above.  I wonder what else we can move from UnrealScript to Flash...

Thursday, March 3, 2011

D&D BattleMap Using the UDK - Fixed HUD!

After a very long wait...



...we finally have our UDK BattleMap fixed.  It has been shackled to the August version of the UDK since the versions from September forward replaced the HUD with ScaleForm.  That change broke the code that provided mouse coordinates.  At long last, we have have it working again with a custom ScaleForm HUD. This thread on epicgames.com provided the answer.  To prove it, here's an updated version of our very first video.  Note the custom cursor provided by our friend at monsterlayer.com.



So while we FINALLY begin the process of documenting how we created our D&D Battlemap, I'll give you a teaser and show you how we did the mod above.

But first, a couple of disclaimers:
  • This isn't going to be a step-by-step.  There are far too many well written UDK tutorials out there, so I would just be giving myself carpal tunnel syndrome.  I'll explain the code, but you should be able to find walkthroughs on installing the UDK, compiling scripts and launching a custom game.
  • We compiled bits of code from countless blogs, articles, and forum posts to get our code working.  And, embarrassingly, we recorded very few sources.  We have absolutely no intention of taking credit for anyone else's work.  So, if you see a bit of your own code in here, a flaming accusatory email isn't necessary.  Tell us it's yours and we'll be happy to give you full credit for it.
  • We were teaching ourselves UnrealScript for the first time as we were attempting this project.  There are probably simpler and more effective ways of doing what we did.  We're not experts, so don't point and laugh.  Tell us how to do it better.  We love constructive criticism.
  • I apologize for the way the code is formatted in the blog post.  Hopefully, it'll be readable once you paste it into an editor.  I'll try to find a better want to represent it for the following posts.
So, that being said, how do you play UDK top down like Alien Swarm?  First, you'll need Flash CS5 and Allar's AWESOME Blog.  I didn't use his Foreclosure UI, but his tutorials for installing Flash and setting up ScaleForm were invaluable.

Flash
This ActionScript does the magic that fixed our BattleMap:

import flash.external.ExternalInterface;
Mouse.hide();
startDrag("CursorInst", true);
var mouseListener:Object = new Object();
mouseListener.onMouseMove = function()
{
 CursorInst._x = _root._xmouse;
 CursorInst._y = _root._ymouse;

 ExternalInterface.call( "ReceiveMouseCoords", _root._xmouse, _root._ymouse );

 updateAfterEvent();
}

Mouse.addListener(mouseListener);

Even though this is just a quick demo it's the foundation of our BattleMap, so all our UnrealScript code starts in \UDK\UDK-2011-02\Development\Src\BattleMap\Classes.

BattleMapMain.uc

No explaination needed here.  This sets up the other classes:

class BattleMapMain extends UTGame;
DefaultProperties
{
 HUDType=class'BattleMap.BattleMapHUD'
 PlayerControllerClass=class'BattleMap.BattleMapPlayerController'
 DefaultPawnClass=class'BattleMap.BattleMapPawn'


 // don't wait for game start, spawn immediately
 bDelayedStart=false
}

BattleMapPawn.uc

This creates our avatar in the game.  We had to override the BecomeViewTarget() function to skip creating the first person meshes:

class BattleMapPawn extends UTPawn
 config(Game);

// we're skipping right over UTPawn.BecomeViewTarget down to Pawn.BecomeViewTarget
// since UTPawn version sets first person meshes (arms, gun, etc)
simulated event BecomeViewTarget( PlayerController PC )
{
 if (PhysicsVolume != None)
 {
  PhysicsVolume.NotifyPawnBecameViewTarget(self, PC);
 }

 // if we don't normally replicate health, but will want to do so now to this client, force an update
 if (!bReplicateHealthToAll && WorldInfo.NetMode != NM_Client)
 {
  PC.ForceSingleNetUpdateFor(self);
 }
}

// have to set bOwnerNoSee=false so that we can see our own pawn
DefaultProperties
{
 Begin Object Name=WPawnSkeletalMeshComponent
  bOwnerNoSee=false
 End Object
}
BattleMapGfxHud.uc

The wrapper for our custom ScaleForm HUD and receives its mouse coordinates:

class BattleMapGFxHud extends GFxMoviePlayer;
var int MouseX;
var int MouseY;

function Initialize()
{
 Start();
 Advance(0.0f);
}

// This is called from Flash.
function ReceiveMouseCoords( float x, float y )
{
 MouseX = x;
 MouseY = y;
}

DefaultProperties
{
 bDisplayWithHudOff = false

    // Path to your package/flash file here.
 MovieInfo = SwfMovie'BattleMapHud.BMHud'
}
BattleMapHUD.uc

Here we're instantiating the HUD, converting the mouse's 2D coordinates into 3D space and passing the vector on to our PlayerController:
class BattleMapHUD extends UDKHUD;
var BattleMapGfxHud CrosshairMovie;
var class<BattleMapGfxHud> CrosshairMovieClass;

simulated function PostBeginPlay()
{
 super.PostBeginPlay();

 if (CrosshairMovie == none)
 {
  CrosshairMovie = new CrosshairMovieClass;
  CrosshairMovie.Initialize();
 }

 SizeX = 1024.0f;
 SizeY = 764.0f;
}

singular event Destroyed()
{
 if( CrosshairMovie != none )
 {
  CrosshairMovie.Close( true );
  CrosshairMovie = none;
 }

 Destroy();
}

function vector2D GetMouseCoordinates()
{
 local Vector2D mousePos;

 mousePos.X = CrosshairMovie.MouseX;
 mousePos.Y = CrosshairMovie.MouseY;

 return mousePos;
}

event PostRender()
{
 local BattleMapPlayerController bmPlayerController;
 local vector startTrace;
 local Vector endTrace;
 local Vector mouseOrigin;
 local Vector mouseDirection;
 local string StringMessage;
 local Vector pawnEyeLocation;
 local Rotator pawnEyeRotator;

 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 += bmPlayerController.CameraZOffset;
  endTrace = startTrace + (mouseDirection * 5000);
  Trace(mouseOrigin, mouseDirection, endTrace, startTrace, true);

        // laser sight from pawn to reticle
  bmPlayerController.Pawn.GetActorEyesViewPoint(pawnEyeLocation, pawnEyeRotator);
        Draw3DLine(pawnEyeLocation, mouseOrigin, RedColor);
 }
 bmPlayerController.SetMouseOrigin(mouseOrigin);

 StringMessage = "NetMode=" @ WorldInfo.NetMode @ "\nPlayerController=" @ PlayerOwner @ "\nControllerRotation=" @ bmPlayerController.Rotation @ "\nPawn=" @ PlayerOwner.Pawn @ "\nPawnRotation=" @ bmPlayerController.Pawn.Rotation @ "\nPawnEyeRotation=" @ pawnEyeRotator;
 //`Log(StringMessage);

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

DefaultProperties
{
 CrosshairMovieClass = class'BattleMapGfxHud'
}
BattleMapPlayerController.uc

Finally, we move the camera to a top down position, convert the WASD keys to NWSE, and make the pawn aim for the cursor:

class BattleMapPlayerController extends UTPlayerController
 config(Game);

var int CameraZOffset;
var Vector2D MousePosition;
var Vector MouseOrigin;

simulated function SetMouseOrigin(Vector NewMouseOrigin)
{
 MouseOrigin = NewMouseOrigin;
 ServerMouseOrigin(NewMouseOrigin);
}

reliable server function ServerMouseOrigin(Vector NewMouseOrigin)
{
 MouseOrigin = NewMouseOrigin;
}

// This basically locks the camera to the pawn's location, and adds CameraZOffset to the Z of the camera.
simulated event GetPlayerViewPoint( out vector out_Location, out Rotator out_Rotation )
{
 // this right here is just terrible.  but after spending days trying to figure out why the server
 // thought the client was in 3rd person, but the client thought it was in 1st, I just hacked it.
 bBehindView = true;

    // let's just ignore all the fancy crap
    if(Pawn == None)
        super.GetPlayerViewPoint(out_Location, out_Rotation);
    else
        out_Location = Pawn.Location;
    out_Location.Z += CameraZOffset;
    out_Rotation = rotator(vect(0,0,-1));
}

// Force WASD to be NWSE, so use world rotation instead of pawn rotation
state PlayerWalking
{
 function PlayerMove( float DeltaTime )
 {
  local vector   X,Y,Z, NewAccel;
  local eDoubleClickDir DoubleClickMove;
  local rotator   OldRotation;
  local bool    bSaveJump;

  if( Pawn == None )
  {
   GotoState('Dead');
  }
  else
  {
   // Changed this:
   //GetAxes(Pawn.Rotation,X,Y,Z);
   GetAxes(WorldInfo.Rotation,X,Y,Z);

   // Update acceleration.
   NewAccel = PlayerInput.aForward*X + PlayerInput.aStrafe*Y;
   NewAccel.Z = 0;
   NewAccel = Pawn.AccelRate * Normal(NewAccel);

   DoubleClickMove = PlayerInput.CheckForDoubleClickMove( DeltaTime/WorldInfo.TimeDilation );
   // Update rotation.
   OldRotation = Rotation;
   UpdateRotation( DeltaTime );
   bDoubleJump = false;

   if( bPressedJump && Pawn.CannotJumpNow() )
   {
    bSaveJump = true;
    bPressedJump = false;
   }
   else
   {
    bSaveJump = false;
   }

   if( Role < ROLE_Authority ) // then save this move and replicate it
   {
    ReplicateMove(DeltaTime, NewAccel, DoubleClickMove, OldRotation - Rotation);
   }
   else
   {
    ProcessMove(DeltaTime, NewAccel, DoubleClickMove, OldRotation - Rotation);
   }
   bPressedJump = bSaveJump;
  }
 }
}

// This makes our weapons aim to the pawn's rotation, not the controller's rotation
function Rotator GetAdjustedAimFor(Weapon W, vector StartFireLoc)
{
 local Vector pawnEyeLocation;
 local Rotator pawnEyeRotator;

 if(Pawn != None)
 {
  //return Pawn.Rotation;
  Pawn.GetActorEyesViewPoint(pawnEyeLocation, pawnEyeRotator);
  return Rotator(MouseOrigin - pawnEyeLocation);
 }
    else return Rotation;
}

// Turn the pawn toward the cursor
function UpdateRotation( float DeltaTime )
{
 local Rotator NewRotation, ViewRotation;  //DeltaRot

 ViewRotation = Rotation;
 if (Pawn!=none)
 {
  ViewRotation = Rotator(MouseOrigin - Pawn.Location + (Pawn.EyeHeight * vect(0,0,1)));
  Pawn.SetDesiredRotation(ViewRotation);
 }

 //// Calculate Delta to be applied on ViewRotation
 //DeltaRot.Yaw = PlayerInput.aTurn;
 //DeltaRot.Pitch = PlayerInput.aLookUp;

 //ProcessViewRotation( DeltaTime, ViewRotation, DeltaRot );
 //SetRotation(ViewRotation);
 SetRotation(ViewRotation);

 ViewShake( deltaTime );
 NewRotation = ViewRotation;
 NewRotation.Roll = Rotation.Roll;

 if ( Pawn != None )
  Pawn.FaceRotation(NewRotation, deltatime);
}

// Make sure our custom HUD is used
reliable client function ClientSetHUD(class<HUD> newHUDType)
{
 if ( myHUD != None )
 {
  myHUD.Destroy();
 }

 //myHUD = (newHUDType != None) ? Spawn(newHUDType, self) : None;
 myHUD = (newHUDType != None) ? Spawn(class'BattleMap.BattleMapHUD', self) : None;
}

DefaultProperties
{
 CameraZOffset=480  
}

Seems rather simple now.  To get it running though, we need to make a few .INI changes.

DefaultEngine.ini

Add the following parameters to the appropriate sections in the INI.  Use whatever screen resolution is appropriate for you.  The dimensions below is the native resolution of our projector:

[UnrealEd.EditorEngine]
+ModEditPackages=BattleMap


[SystemSettings]
Fullscreen=True
ResX=1680
ResY=1050

DefaultGame.ini

This ensures that our custom code is run when using "Play From Here" in the UDK Editor:

[Engine.GameInfo]
+DefaultMapPrefixes=(Prefix="BM",bUsesCommonPackage=FALSE,GameType="BattleMap.BattleMapMain")

Almost there.  To get a quick map going, copy Content\Maps\VCTF-Necropolis.udk to Content\Maps\BM-Necropolis.udk.

Now, compile the code and cook the assets with the Unreal Frontend.  After it completes, you'll need to copy the package with your custom HUD (ours is Content\Misc\BattleMapHud.upk) to the CookedPC folder.

And lastly, add the following to the Use Url box before launching:

BM-Necropolis.udk?game=BattleMap.BattleMapMain?Listen=true

There you go!  Hopefully everything went smoothly and you're now playing Unreal top down.  The code works in multiplayer as well.  The target I was shooting at above is another game client connected.  Don't forget to try out the vehicles.  (And if anyone creates a Car Wars, AutoDuel or Darkwind clone, dibs on beta testing.)