The playable game, corresponding with this post, can be found online here.
And the source code for this part can be found on GitHub here.
Movement clipping
If you gave the game a go last time you’ll have found that the player (controlled by the cursor keys) could be moved freely through the walls. Correcting that is fairly straightforward, our movement method needs to check if the map cell the player is attempting to move into is allowed:
private static GameState Move((GameState input, double delta) args, double speed)
{
var posX = args.input.Camera.Position.X;
var posY = args.input.Camera.Position.Y;
var dirX = args.input.Camera.Direction.X;
var dirY = args.input.Camera.Direction.Y;
var newMapX = (int) (posX + dirX * speed);
var newMapY = (int) (posY + dirY * speed);
// By checking if you can move into new x and y cells independently we get the "slide along the walls"
// effect from the original game. Otherwise you would stop dead which would feel very weird indeed.
var newPosition = new Vector2D(
X: args.input.CanPlayerTraverse((newMapX, (int)posY)) ? (float) (posX + (dirX * speed)) : posX,
Y: args.input.CanPlayerTraverse(((int) posX, newMapY)) ? (float) (posY + (dirY * speed)) : posY
);
return args.input with { Camera = args.input.Camera with { Position = newPosition}};
}
We perform the CanPlayerTraverse check independently on both X and Y movements otherwise the player comes to a screeching halt when they move into a wall at an angle. By checking independently the player will “slide” along a wall as they did in the original game.
The actual traversal check is very simple, it basically just looks for a wall or a not open (opening, closing, closed) door in the target cell:
private static bool CanTraverse(GameState input, (int x, int y) toPosition, bool checkPlayer)
{
if (!toPosition.InMap()) return false;
if (checkPlayer && toPosition == input.Camera.Position.ToMap()) return false;
var canPassCell = input.Map[toPosition.y][toPosition.x] switch
{
Door door => input.Doors[door.DoorIndex].Status == DoorStatus.Open,
Wall _ => false,
_ => true
};
if (canPassCell) return !input.GameObjects.Any(go => go.CommonProperties.MapPosition == toPosition);
return false;
}
public static bool CanPlayerTraverse(this GameState input, (int x, int y) toPosition)
{
return CanTraverse(input, toPosition, false);
}
I’ve written this code in this two part way as we’ll also be using it when we come to implement enemy AI. They follow the same rules as the player.
Doors and the action key (doors)
Ok. With walls and doors now impeding progress we’re basically stuck in the first room unless we do something about the doors. We’ve already done the hard work of in the raycaster, getting them to open in response to the action key (space bar) is comparatively simple.
First off we need to know if the player is stood in front of a door. You might remember in the last part I mentioned tracking some information during the wall rendering that would be useful later? Turns out its useful for a door check. This code has been added to the wall renderer:
wallRenderResult = wallRenderResult with
{
ZIndexes = wallRenderResult.ZIndexes.Add(perpendicularWallDistance),
WallInFrontOfPlayer =
viewportX == viewportSize.width / 2
? (rayCastResult.MapHit.x, rayCastResult.MapHit.y)
: wallRenderResult.WallInFrontOfPlayer,
DistanceToWallInFrontOfPlayer =
viewportX == viewportSize.width / 2
? perpendicularWallDistance
: wallRenderResult.DistanceToWallInFrontOfPlayer,
IsDoorInFrontOfPlayer =
viewportX == viewportSize.width / 2
? cellHit switch {Door => true, _ => false}
: wallRenderResult.IsDoorInFrontOfPlayer
};
Basically if we’re in the middle of the viewport we hang on to the result of our ray cast - how far we are from the wall in front of us and if that wall is a door or not. I’ve added a DoAction method to our update loop (which it strikes me I also need to write about - next time) and the core of it looks like this:
const double actionDistanceTolerance = 0.75;
// doors are 0.5 recessed which means we need to extend the action activation distance
const double actionDoorDistanceTolerance = actionDistanceTolerance + 0.5;
if (wallRenderingResult.IsDoorInFrontOfPlayer &&
wallRenderingResult.DistanceToWallInFrontOfPlayer <= actionDoorDistanceTolerance)
{
var (actionWallX, actionWallY) = wallRenderingResult.WallInFrontOfPlayer;
return
args.input.Map[actionWallY][actionWallX] switch
{
Door door => TryOpenDoor(door.DoorIndex),
_ => args.input
};
}
return args.input;
Animating the doors is fairly straightforward - we basically track an offset into the door texture at which to begin drawing and increment (or decrement if closing) this offset based on how long the door has been in a transitioning state. The method UpdateTransitioningDoors handles this. At its core its very simple:
var startingState = (doors: ImmutableArray<DoorState>.Empty, compositeAreas: args.input.CompositeAreas);
var updates =
args.input.Doors.Aggregate(startingState, (state, doorState) =>
{
var newTimeRemainingInAnimationState = doorState.TimeRemainingInAnimation - args.delta;
return doorState.Status switch
{
DoorStatus.Opening => UpdateOpeningDoor(state, newTimeRemainingInAnimationState, doorState),
DoorStatus.Open => UpdateOpenDoor(state, newTimeRemainingInAnimationState, doorState),
DoorStatus.Closing => UpdateClosingDoor(state, newTimeRemainingInAnimationState, doorState),
_ => (state.doors.Add(doorState), state.compositeAreas)
};
});
return args.input with {Doors = updates.doors, CompositeAreas = updates.compositeAreas};
(I’ll come back to composite areas in a future post, they are related to sound propagation)
And then an example state transition looks like the below, where you can see us updating the offset based on how long the door has been opening (or rather how long it has left to be opening):
(ImmutableArray<DoorState> doors, ImmutableArray<CompositeArea> compositeAreas) UpdateOpeningDoor(
(ImmutableArray<DoorState> doors, ImmutableArray<CompositeArea> compositeAreas) state,
double newTimeRemainingInAnimationState,
DoorState doorState)
{
var newDoorState =
newTimeRemainingInAnimationState < 0.0
? doorState with
{
Status = DoorStatus.Open, TimeRemainingInAnimation = DoorOpenTime, Offset = 64
}
: doorState with
{
TimeRemainingInAnimation = newTimeRemainingInAnimationState,
Offset = (DoorOpeningTime - newTimeRemainingInAnimationState) / DoorOpeningTime * 64.0
};
return (state.doors.Add(newDoorState), state.compositeAreas);
}
Gameplay wise that’s it for this post - but I want to cover a few C# from an F# perspective things in the above.
Happily FOLDing
It turns out, to my delight, that C# does have a version of fold - they call it aggregate for some reason and you can see it in use in the example above. Without fold its really quite awkward to do state updates in an immutable fashion that feels worthwhile and I was literally on the verge of writing a simple implementation when I discovered this.
Ok so what is a fold and why is it so useful some might well be asking. Essentially its a construct that operates on a set iterating over it and calling a function for each item. So far so select. However the additional thing a fold does is that it takes an initial state and the function that is called for each item has an argument of the same type and must return a result of the same type. The initial call to the function will be given the initial state and the first item in the set, the second call will be given the result of the first function call and the second item in the set and so on until at the end the final result returned by the function for the last item in the set is returned to the caller.
You can see an example in the door state updating code where I iterate over the existing set of doors and return from that iteration a new set of doors and a composite area array that has been calculated along the way.
The reference for the C# Aggregate method has the following simpler example:
string[] fruits = { "apple", "mango", "orange", "passionfruit", "grape" };
// Determine whether any string in the array is longer than "banana".
string longestName =
fruits.Aggregate("banana", (longest, next) =>
next.Length > longest.Length ? next : longest, fruit => fruit.ToUpper()
);
Console.WriteLine("The fruit with the longest name is {0}.", longestName);
// This code produces the following output:
// The fruit with the longest name is PASSIONFRUIT.
Its an incredibly useful construct in functional programming. There’s also an unfold in functional programming which we might come to at some point in this series. When I first came across that it hurt my brain.
Type Inference (the lack thereof)
This is just an observation really but without type inference I’m finding that method declarations are getting very long and unwieldy when using tuples a lot. I find tuples to be really useful for those limited scope “on the fly” data types in F# where you just need to package a couple of things together inside a scoped function. When I do the same in C# the method signatures are getting huge (you can see a couple above) due to the lack of type inference.
Immutable arrays
@ythos pointed me at the immutable collections in .NET (e.g. ImmutableArray) and I’ve begun to use those for my data types. They still don’t support value equality (pro’s and con’s) but I’m happy I’m prevented from accidentally updating things in situ.
However a drawback, compared to the immutable types in F#, is that some of the immutable behaviour is only enforced at runtime. For example the below will quite happily compile:
var myImmutableArray = new ImmutableArray<(int x, int y)> { (initialMapX,initialMapY) }
However it will give you an exception at runtime. I mean ok, this is due to how initializers work in C#, but its not obvious. Instead you need to use factory methods such as the below:
var myImmutableArray = ImmutableArray.Create((initialMapX, initialMapY));
This has definitely caught me out a few times.
Next Steps
In the next part we’ll tackle rendering sprites. Guards, dog food, ammunition etc.
If you want to discuss this or have any questions then the best place is on GitHub. I’m only really using Twitter to post updates to my blog these days.