Mortimer: First Launch
This project page was created as part of my university studies and follows the guidelines set by the institution. As such, it contains content, such as academic descriptions, and details that may not align with how I would normally present projects in my portfolio. Certain sections may feel overly detailed or irrelevant in the context of a professional portfolio, but they are included to meet academic requirements.
Programming
I was Lead Programmer on Mortimer: First Launch project, I implemented most of the gameplay logic. Here is some code snippets to show what have I done
Surface Detector
Surface detector is part of the audio system, it changes footsteps audio depending what kind of surface player is walking on. Making this was quite straight forward, only problem was to find out how Unity stores terrain layer weights to the terrain data.
namespace MortimerDreamsOfSpace.WwiseIntegration
{
public class SurfaceDetector : MonoBehaviour
{
[SerializeField] private LayerMask _groundLayerMask;
[SerializeField] private List<PhysicsMaterialMapping> physicsMaterialMappings;
[SerializeField] internal UnityEvent<SurfaceChangeEventArgs> onSurfaceChange;
[SerializeField] private SurfaceChangeEventArgs _previousArgs;
void FixedUpdate()
{
// Raycasting down
if (Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, 1f))
{
// Check layer mask
if ((hit.collider.gameObject.layer | _groundLayerMask) == 0)
return;
var args = new SurfaceChangeEventArgs();
// Check if the surface has been manually assigned to the object
if (hit.collider.TryGetComponent(out Surface surface))
{
args.AudioId = surface.AudioId;
args.Surface = surface;
}
// Checking is collider terrain and getting related terrain audio mapping
else if (hit.collider.TryGetComponent(out Terrain terrain) && hit.collider.TryGetComponent(out TerrainAudioMapping audioMapping))
{
var layer = GetDominantTextureIndex(terrain, hit.point);
if (layer < 0) // Terrain detection bug, skipping detection this time
return;
args.AudioId = audioMapping.GetAudioId(layer);
}
// Trying to solve audio for the physics material
else if (hit.collider.sharedMaterial != null)
{
var surfaceMaterial = physicsMaterialMappings.Find(o => o.physicMaterial == hit.collider.sharedMaterial);
if (surfaceMaterial == null)
return;
args.AudioId = surfaceMaterial.audioId;
}
// If previous is same as current, do nothing
if (_previousArgs != null && args.Equals(_previousArgs))
return;
// Changing audio material
Debug.Log("Surface changed to " + args);
_previousArgs = args;
onSurfaceChange?.Invoke(args);
}
}
public int GetDominantTextureIndex(Terrain terrain, Vector3 worldPos)
{
TerrainData terrainData = terrain.terrainData;
// Convert world coordinates to terrain coordinates
Vector3 terrainPos = worldPos - terrain.transform.position;
float xCoord = terrainPos.x / terrainData.size.x; // On scale of 0 to 1
float zCoord = terrainPos.z / terrainData.size.z;
// Get the alpha map position
int mapX = Mathf.RoundToInt(xCoord * terrainData.alphamapWidth);
int mapZ = Mathf.RoundToInt(zCoord * terrainData.alphamapHeight);
// BUG: Something causing a bug that causes negative coordinates
if (mapX < 0 || mapZ < 0)
return -1;
// Get the alpha map (splat map)
float[,,] alphaMap = terrainData.GetAlphamaps(mapX, mapZ, 1, 1);
// Find the dominant texture index
int dominantTextureIndex = 0;
for (int i = 0; i < alphaMap.GetLength(2); i++)
{
if (alphaMap[0, 0, i] > 0.05f)
{
dominantTextureIndex = i;
}
}
return dominantTextureIndex;
}
[System.Serializable]
public class SurfaceChangeEventArgs : System.IEquatable<SurfaceChangeEventArgs>
{
public AudioId AudioId { get; internal set; }
public Surface Surface { get; internal set; }
public bool Equals(SurfaceChangeEventArgs other)
{
return other.Surface == Surface && other.AudioId == AudioId;
}
public override string ToString()
{
string txt = string.Empty;
if (Surface == null)
txt += "nullSurface";
else
txt += Surface.name;
if (AudioId == null)
txt += " nullAudioId";
else
txt += " " + AudioId.name;
return txt;
}
}
}
}
Achievements
Game uses Steam Achievements, this was first time when implementing achievements system. I made this part quickly and it doesn’t cache current status anyhow but just sets the achievement received every time when it is triggered. This is using Unitys ScriptableObjects, so achievement can be triggered directly with UnityEvents. This is using Steamworks.NET C# Wrapper (com.rlabrecque.steamworks.net)
namespace MortimerDreamsOfSpace.Achievement
{
[CreateAssetMenu(fileName = "Achievement", menuName = "MortimerDreamsOfSpace/Achievement")]
public class Achievement : ScriptableObject
{
[SerializeField] private string apiName;
[SerializeField] string achievementName;
public void Unlock()
{
Debug.Log("Unlocking achievement: " + achievementName);
if(!SteamManager.Initialized) return;
SteamUserStats.SetAchievement(apiName);
SteamUserStats.StoreStats();
}
public void Lock()
{
Debug.Log("Locking achievement: " + achievementName);
if (!SteamManager.Initialized) return;
SteamUserStats.ClearAchievement(apiName);
SteamUserStats.StoreStats();
}
static string ConvertToUpperWithUnderscores(string input)
{
string result = Regex.Replace(input, "(?<!^)([A-Z])", "_$1");
return result.ToUpper();
}
#if UNITY_EDITOR
private void OnValidate()
{
if (string.IsNullOrEmpty(apiName))
{
apiName = "ACH_" + ConvertToUpperWithUnderscores(name);
}
}
#endif
}
}
NPC Ai
Ai brain is the core of the NPC AI system, it switches the AI state from request and if game object has multiple states that matches the request, it picks random state that satisfies the request. AI states are built hierarchy using inheritance, all the AI states base class is AiState, and if Brain gets a request to
for example, if we have following inheritance hierarchy and WorkingState is requested, it pick pick CookingState, HuntingState or PatrollState.
namespace MortimerDreamsOfSpace.AI
{
[SelectionBase]
public class AiBrain : MonoBehaviour
{
[SerializeField] public NpcSettings settings;
[SerializeField] public UnityEvent TargetReachedEvent;
[SerializeField] public UnityEvent<NpcThoughtBubbleContent> onThought;
[SerializeField, Range(0f, 1f)] internal float movementRate = 0f;
internal Transform destination;
public Vector3 HomePosition { get; private set; }
private protected AiState[] _allStates;
private AiTaskManager[] taskManager;
private AiState _currentState;
private AiState _previousState;
private int _previousAnimationState;
HashSet<Collider> _activatorColliders = new();
private List<AiTrigger> _activeTriggers = new();
private bool paused;
public Type CurrentStateType { get => _currentState ? _currentState.GetType() : null; }
public bool TargetReached { get; private set; }
public float Speed { get; private set; }
// Awake is called when the script instance is being loaded
private void Awake()
{
settings.agent = GetComponent<NavMeshAgent>();
settings.cc = GetComponent<CharacterController>();
settings.aiSensor = GetComponentInChildren<AiSensor>();
settings.animator = GetComponent<Animator>();
settings.headBone = settings.animator.GetBoneTransform(HumanBodyBones.Head);
settings.aiSensor.SetSize(MathF.Max(settings.ViewDistance, settings.HearDistance));
settings.aiSensor.onSensorEnter.AddListener(OnAiTriggerEnter);
settings.aiSensor.onSensorExit.AddListener(OnAiTriggerExit);
// Storing home position so AI can return to it
HomePosition = transform.position;
// Initializing states
_allStates = GetComponents<AiState>();
_currentState = _previousState = _allStates.Where(s => s.enabled && s.GetType() == typeof(IdleState)).FirstOrDefault();
foreach (var state in _allStates)
state.Initialize(this, settings);
// Setting initial state
ChangeState(typeof(IdleState));
}
internal virtual void Update()
{
if (paused)
return;
UpdateAnimator();
if (_currentState != null)
_currentState.UpdateState();
}
/// <summary>
/// Request AI to change state
/// </summary>
/// <param name="newStateType">Target state (or random child type)</param>
public void ChangeState(Type newStateType)
{
// Getting matching states including inherited types
var matchingStates = _allStates.Where(s => newStateType.IsAssignableFrom(s.GetType())).ToList();
var newState = matchingStates.Count > 0 ? matchingStates[UnityEngine.Random.Range(0, matchingStates.Count)] : null;
if (newState == null)
{
Debug.LogWarning($"No state found for type {newStateType}", gameObject);
return;
}
// If new state is the same as the current state, do nothing
if (newState == _currentState)
return;
// Exiting previous state
if (_currentState != null)
_currentState.ExitState();
_currentState = newState;
// Sending current detections to new state
_currentState.OnDetectionChange(_activeTriggers);
// Entering new state
if (_currentState != null)
_currentState.EnterState();
}
/// <summary>
/// Sets AI destination
/// </summary>
/// <param name="destination"></param>
internal void SetDestination(Vector3 destination, float speed = 1f)
{
Speed = speed;
if (settings.agent == null)
return;
this.destination = null;
TargetReached = false;
settings.agent.SetDestination(destination);
}
/// <summary>
/// Sets or reset AI destination
/// </summary>
/// <param name="transform"></param>
internal void SetDestination(Transform transform = null, float speed = 1f)
{
Speed = speed;
if (settings.agent == null)
return;
if (transform != null)
{
destination = transform;
TargetReached = false;
settings.agent.SetDestination(destination.position);
}
else
settings.agent.isStopped = true;
}
// When something enters to the AI detection zone
private void OnAiTriggerEnter(AiTrigger arg0)
{
if (_activeTriggers.Contains(arg0))
return;
_activeTriggers.Add(arg0);
_currentState.OnDetectionChange(_activeTriggers);
}
private void OnAiTriggerExit(AiTrigger arg0)
{
_activeTriggers.Remove(arg0);
_currentState.OnDetectionChange(_activeTriggers);
}
private void UpdateAnimator()
{
if (settings.agent.hasPath)
{
Vector3 velocity = settings.agent.desiredVelocity;
Vector3 localVelocity = transform.InverseTransformDirection(velocity).normalized;
var dir = (settings.agent.steeringTarget - transform.position).normalized;
var facingToMoveDirection = Vector3.Dot(transform.forward, dir) > .5f;
settings.animator.SetFloat(AnimatorID.Horizontal, facingToMoveDirection ? localVelocity.x * Speed : 0f, .5f, Time.deltaTime);
settings.animator.SetFloat(AnimatorID.Vertical, localVelocity.z * Speed, .5f, Time.deltaTime);
if (settings.agent.remainingDistance < settings.agent.radius + settings.agentStopDistance)
{
settings.agent.ResetPath();
TargetReached = true;
TargetReachedEvent?.Invoke();
SetAnimationState(0);
_currentState.OnDestinationReached();
}
else
{
SetAnimationState(1); // Walking/Running
}
}
else
{
settings.animator.SetFloat(AnimatorID.Horizontal, 0f, .25f, Time.deltaTime);
settings.animator.SetFloat(AnimatorID.Vertical, 0f, .25f, Time.deltaTime);
SetAnimationState(0); // Idle
}
}
// Setting Animator state
private void SetAnimationState(int state)
{
if (state == _previousAnimationState)
return;
settings.animator.SetInteger(AnimatorID.LastState, _previousAnimationState);
settings.animator.SetInteger(AnimatorID.State, state);
settings.animator.SetTrigger(AnimatorID.StateOn);
_previousAnimationState = state;
}
// Checking can AI see the target
internal bool IsVisible(AiTrigger target)
{
if (Vector3.Angle(settings.headBone.forward, target.transform.position - settings.headBone.position) > settings.Fov / 2)
return false;
foreach (Collider c in target.Colliders)
{
Ray r = new Ray(settings.headBone.position, (c.gameObject.transform.position - settings.headBone.position));
if (Physics.Raycast(r, out RaycastHit hit, settings.ViewDistance * target.VisualDetectionProbability, settings.aiSensor.layerMask))
{
if (hit.collider.GetComponentInParent<AiTrigger>() == target)
{
Debug.DrawRay(r.origin, r.direction * settings.ViewDistance, Color.green, 1f);
return true;
}
}
else
{
Debug.DrawRay(r.origin, r.direction * settings.ViewDistance, Color.red, 1f);
}
}
return false;
}
// Can AI hear the target
internal bool IsHearing(AiTrigger target)
{
if (Vector3.Distance(settings.headBone.position, target.transform.position) < settings.HearDistance * target.AudioDetectionProbability)
{
Debug.DrawLine(settings.headBone.position, target.transform.position, Color.green, 1f);
return true;
}
return false;
}
// Pausing the AI if game is paused
public void PauseAI(bool pause)
{
paused = pause;
if (settings.agent == null)
return;
settings.agent.isStopped = pause;
}
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
settings.OnDrawGizmos(transform);
if (_currentState != null)
UnityEditor.Handles.Label(settings.headBone.position, _currentState.GetType().Name);
}
private void OnDrawGizmos()
{
if (settings.agent == null)
return;
for (int i = 0; i < settings.agent.path.corners.Length - 1; i++)
{
Debug.DrawLine(settings.agent.path.corners[i], settings.agent.path.corners[i + 1], Color.blue);
}
Gizmos.DrawWireSphere(settings.agent.destination, settings.agentStopDistance);
}
#endif
}
}
Custom build script
For this project I created a custom build script that builds Windows and Linux builds for Steam and creates .vdf file for steam upload to steamline the development and testing process. Following script has been improved later on to be suitable to other project too, but this is the version that we was using on this project.
public class SteamBuildScript
{
private const string steamBuildsPath = "SteamBuilds";
private static string targetBinary;
private static string gitString;
[MenuItem("Mortimer/SteamBuild", priority = 30)]
public static void BuildSteam()
{
CheckDependencies(); // This will throw an exception if dependencies are not met
gitString = GenerateDescription();
string appBuildVdf = Path.Combine(steamBuildsPath, "app_build_3288660.vdf");
CreateAppVdfFile(appBuildVdf);
bool success = true;
if (success)
success &= Build(BuildTarget.StandaloneWindows64);
if (success)
success &= Build(BuildTarget.StandaloneLinux64);
if (!success)
Debug.LogError("Build failed");
EditorUtility.RevealInFinder(Path.Combine(steamBuildsPath, "UploadToSteam.bat"));
// Cleanup
if (File.Exists("Assets/Wwise"))
{
RunGit("restore Assets/Wwise");
}
}
[MenuItem("Mortimer/Open SteamBuild Folder", priority = 31)]
private static void OpenFolder()
{
EditorUtility.RevealInFinder(Path.Combine(steamBuildsPath, ""));
}
private static string GenerateDescription()
{
try
{
return RunGit("describe --dirty --tags");
}
catch (Exception e)
{
Debug.LogError("Failed to generate build description: " + e.Message);
return "Unknown";
}
}
private static void CheckDependencies()
{
// Is git installed?
Debug.Log(RunGit("-v"));
// Is Toolchain Win Linux x64 installed?
ListRequest listRequest = Client.List(true);
while (!listRequest.IsCompleted)
{
var cancelled = EditorUtility.DisplayCancelableProgressBar("Checking dependencies", "Checking if Toolchain Win Linux x64 is installed", 0.5f);
if (cancelled)
break;
}
EditorUtility.ClearProgressBar();
if (listRequest.Status == StatusCode.Success)
{
if (!listRequest.Result.Any(package => package.name == "com.unity.toolchain.win-x86_64-linux-x86_64"))
throw new Exception("Toolchain Win Linux x64 is not installed");
}
else
{
// Log an error if the request failed
throw new Exception("Failed to list packages: " + listRequest.Error?.message);
}
// Is Steamworks SDK installed
if (!File.Exists(Path.Combine(steamBuildsPath, "sdk", "tools", "ContentBuilder", "builder", "steamcmd.exe")))
throw new Exception("Steamworks SDK is not installed");
}
private static void CreateAppVdfFile(string appBuildVdf)
{
if (string.IsNullOrWhiteSpace(gitString))
throw new Exception("Description is empty");
var app = new AppBuildVdf("3288660");
app.Desc = gitString;
app.SetLive = "AlphaTest";
//app.Preview = false;
app.ContentRoot = ".";
app.BuildOutput = ".\\build_output\\";
app.Depots.Add("3288661", "depot_build_3288661_windows.vdf");
app.Depots.Add("3288662", "depot_build_3288662_linux.vdf");
app.Save(appBuildVdf);
}
public static bool Build(BuildTarget targetPlatform)
{
string targetName = Application.productName.Replace(":", "");
if (targetPlatform == BuildTarget.StandaloneWindows64 || targetPlatform == BuildTarget.StandaloneWindows)
targetBinary = targetName + ".exe";
if (targetPlatform == BuildTarget.StandaloneLinux64)
targetBinary = targetName;
// Generate date string
string date = DateTime.Now.ToString("yyyy-MM-dd_HH-mm");
// Define the full path for the build folder
string buildPath = Path.Combine(steamBuildsPath, $"Build-" + targetPlatform.ToString());
// Create the directory if it doesn’t exist
Directory.CreateDirectory(buildPath);
// Creating log folder
string buildLogPath = Path.Combine(steamBuildsPath, "build_output");
Directory.CreateDirectory(buildLogPath);
// Build the project
BuildReport result = BuildPipeline.BuildPlayer(EditorBuildSettings.scenes, Path.Combine(buildPath, targetBinary), targetPlatform, BuildOptions.None);
// Check if the build succeeded and output the result
if (result.summary.result == BuildResult.Succeeded)
{
var sb = new StringBuilder();
sb.AppendLine("Build result : " + result.summary.result);
sb.AppendLine("Build size : " + result.summary.totalSize + " bytes");
sb.AppendLine("Build time : " + result.summary.totalTime);
//sb.AppendLine("Error summary : " + result.SummarizeErrors());
sb.Append(LogBuildReportSteps(result));
sb.AppendLine(LogBuildMessages(result));
// Output log message
File.WriteAllText(Path.Combine(buildLogPath, date + "-" + gitString + "-" + targetPlatform.ToString() + ".log"), sb.ToString());
Debug.Log($"Build completed: {buildPath}");
return true;
}
else
{
var sb = new StringBuilder();
sb.AppendLine("Build result : " + result.summary.result);
sb.AppendLine("Build size : " + result.summary.totalSize + " bytes");
sb.AppendLine("Build time : " + result.summary.totalTime);
//sb.AppendLine("Error summary : " + result.SummarizeErrors());
sb.Append(LogBuildReportSteps(result));
sb.AppendLine(LogBuildMessages(result));
// Output log message
File.WriteAllText(Path.Combine(buildLogPath, date + "-" + gitString + "-" + targetPlatform.ToString() + "-fail.log"), sb.ToString());
Debug.LogError($"Build failed: {result.summary.result}");
return false;
}
}
public static string LogBuildReportSteps(BuildReport buildReport)
{
var sb = new StringBuilder();
sb.AppendLine($"Build steps: {buildReport.steps.Length}");
int maxWidth = buildReport.steps.Max(s => s.name.Length + s.depth) + 3;
foreach (var step in buildReport.steps)
{
string rawStepOutput = new string('-', step.depth + 1) + ' ' + step.name;
sb.AppendLine($"{rawStepOutput.PadRight(maxWidth)}: {step.duration:g}");
}
return sb.ToString();
}
public static string LogBuildMessages(BuildReport buildReport)
{
var sb = new StringBuilder();
foreach (var step in buildReport.steps)
{
foreach (var message in step.messages)
sb.AppendLine($"[{message.type}] {message.content}");
}
string messages = sb.ToString();
if (messages.Length > 0)
return "Messages logged during Build:\n" + messages;
else
return "";
}
private static string RunGit(string args)
{
System.Diagnostics.ProcessStartInfo psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "git",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden
};
using (System.Diagnostics.Process process = new System.Diagnostics.Process())
{
process.StartInfo = psi;
process.Start();
// Capture output
string output = process.StandardOutput.ReadToEnd();
string error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (!string.IsNullOrEmpty(error))
{
throw new Exception($"Git error: {error}");
}
return output.Trim();
}
}
}
Debugging
We used new tools that whole team was unfamiliar with, and documentation was poor, so my job was also find out how things works and whenever there was a code side problems, it was my job to find out what is wrong and fix them.
Implementation
On this project I bult most of the complex prefab setups with logic like NPCs, Conveyer belt, Menus


