JV
DEV PROFILE

Jaxson Vignal

Game Developer / Game Designer / Systems Programmer

I build the systems underneath Unity and Unreal games. My experience includes systems for open world games such as NPC behavior, runtime weapon crafting, quest and dialog systems, custom Unity lifecycle methods, character controllers in 3D and 2D, response systems (wanted system, reputation systems, etc), and vehicle controllers. I have also worked on the front end of games doing level design and UI. Check out my systems and projects below!

Demo reel // 01:33

Systems I've shipped

These are some of the more complex systems I've worked on in recent years. Below you will find a short description of the system. Code snippets for these systems can be found in the code sample section further down.

Shipped

Runtime Weapon Crafting

Players attach and detach attachments from their guns in game in real time. Each attachment type has a corresponding mini game for putting the attachment on and for taking it off. For example when attaching a scope the player must move it into place and screw it to the rail. Runtime attachment registration feeds a central database, so any part can be crafted, equipped, or removed at any time. Weapons then read the modification values from the attachments and apply it to the gun's stats such as recoil, sound/flash, ammo amount, etc.

C#Unity URP ScriptableObjectsCustom minigame
Shipped

Weapon modifiers

One modifier architecture handles every bullet modifier that can be applied to guns at runtime through the weapon crafting system from Module 01. The effects include anti gravity rounds, explosive rounds, teleport rounds, polymorph rounds, incendiary rounds, stuns, tracers, and more. Modifiers can also be stacked to create whatever combo the player wishes. Modifiers are crafted by combining objects with the desired effects in a crafting interface. For example a bouncy ball will yield ricochet rounds.

C#Modular effectsRuntime Crafting
Shipped

Quest and dialog system

I created two tools for Unity that allow other developers and quest designers to easily create quests and dialog without having to open a code editor. I designed it to allow for branching dialog, different dialog trees depending on NPC state, branching quests, failure conditions, different quest step types such as speaking to NPCs, traveling to location, collecting items, and killing enemies. Objective progress lives in a save-friendly structure built to survive a reload without corrupting the source quest data.

C#Save / loadBranching logicTools programming
Stable

Vehicle controller

A scooter you can ride, dismount, and crash. Getting the handling right took four different physics approaches before one of them actually felt good.

Patch history
v1.0Rigidbody physicsDeprecated
v2.0Wheel collidersDeprecated
v3.0Character controllerDeprecated
v4.0Transform + raycast ground checkStable
C#RaycastingCinemachine
Shipped

NPC combat and aggro groups

NPCs share alertness through a group system keyed by tag, so one guard spotting a player can pull in a whole squad. Deaths trigger ragdoll physics, with knockback forces routed straight to the right bone.

C#Static dictionariesRagdoll physics
Shipped

Day-night cycle and NPC schedules

A lighting manager drives ambient color, directional light angle, and post-processing across a full day-night loop. NPCs read the clock and change location, dialog, and behavior depending on the hour.

C#URP lightingScheduling
Shipped

Parkour character controller

I designed a parkour character controller for a third-person game. The controller was able to run, ground dash, air dash, slide, wall run, wall jump, mantle, and pole vault. I also programmed the primary attack for the game which was a dash attack that auto targeted to the closest enemy on your screen (within a certain distance from the crosshair) and dashed through them.

UnityLarge Student ProjectC#Character ControllerAdvanced Movement

Projects

Open-world build, in development

Campaign 01

STRAPT

Small team - design, programming, systems

A first-person open-world crime simulator game set on a cartoony tropical island in which you play as an arms dealer building and running your empire. Built in Unity with a small team and set for release on Steam early next year. My work on this project covered NPCs, quests, dialog, weapons, vehicles, crafting, a day-night cycle, scheduling, level design, narrative design, art concepting, and a full save system.

UnityC# AI NavigationModular Systems NarrativeLevel Design
Metroidvania combat controller

Campaign 02

Everyst - Metroidvania

Programmer - movement and combat

A side-scrolling metroidvania built around tight movement and combat feel. Used in the demo reel to show controller work outside the open-world project above.

Unity2D physicsCombat systems

Code samples

These snippets pulled from the systems above show pieces of the overall mechanics I've shipped. First the polymorph bullet effect from STRAPT, second the editor window for my quest creation tool, and third my barrel removal minigame from STRAPT's weapon crafting system.

polymorpheffect.cs Compiled
/// <summary>
/// Applied to an NPC when hit by polymorph rounds.
/// Hides the NPC visuals and AI, spawns a random prop in its place,
/// then restores everything after the duration expires.
///
/// The NPC capsule collider stays active the whole time so the player
/// can still shoot the target while it is disguised.
/// </summary>
public class PolymorphEffect : MonoBehaviour
{
    // ------------------------------------------------------------------ //
    //  Runtime state
    // ------------------------------------------------------------------ //

    private float duration;
    private float timeRemaining;
    private GameObject[] propPool;
    private GameObject spawnedProp;
    private bool showDebug;

    // Cached components
    private NavMeshAgent navAgent;
    private Animator animator;
    private float originalAnimatorSpeed;
    private bool hadNavAgent;
    private bool hadAnimator;

    // All renderers on the NPC (excludes the spawned prop)
    private SkinnedMeshRenderer[] npcRenderers;

    // ------------------------------------------------------------------ //
    //  Public API
    // ------------------------------------------------------------------ //

    public void Initialize(float polymorphDuration, GameObject[] pool, bool debug)
    {
        duration = polymorphDuration;
        timeRemaining = duration;
        propPool = pool;
        showDebug = debug;

        ApplyPolymorph();

        Debug.Log("[PolymorphEffect] Started on " + gameObject.name + " for " + duration + "s");
    }

    public void RefreshEffect(float polymorphDuration, GameObject[] pool, bool debug)
    {
        duration = polymorphDuration;
        timeRemaining = duration;
        propPool = pool;
        showDebug = debug;

        // Reroll the prop on refresh for variety
        if (spawnedProp != null)
        {
            Destroy(spawnedProp);
        }
        SpawnProp();

        Debug.Log("[PolymorphEffect] Refreshed on " + gameObject.name);
    }

    // ------------------------------------------------------------------ //
    //  Core logic
    // ------------------------------------------------------------------ //

    private void ApplyPolymorph()
    {
        EnemyHealth enemyHealth = GetComponent<EnemyHealth>();
        if (enemyHealth != null)
        {
            enemyHealth.isPolymorphed = true;
        }

        // --- Disable NavMeshAgent ---
        navAgent = GetComponent<NavMeshAgent>();
        if (navAgent != null)
        {
            hadNavAgent = true;
            navAgent.isStopped = true;
            navAgent.velocity = Vector3.zero;
            Debug.Log("[PolymorphEffect] Stopped NavMeshAgent on " + gameObject.name);
        }

        // --- Freeze Animator ---
        animator = GetComponent<Animator>();
        if (animator != null)
        {
            hadAnimator = true;
            originalAnimatorSpeed = animator.speed;
            animator.speed = 0f;
            Debug.Log("[PolymorphEffect] Froze Animator on " + gameObject.name);
        }

        // --- Hide NPC renderers ---
        // Collect now, before the prop is spawned, so we don't accidentally
        // grab renderers from the prop if it gets parented here later.
        npcRenderers = GetComponentsInChildren<SkinnedMeshRenderer>();
        foreach (Renderer r in npcRenderers)
        {
            if (r != null)
            {
                r.enabled = false;
            }
        }
        Debug.Log("[PolymorphEffect] Hid " + npcRenderers.Length + " renderers on " + gameObject.name);

        // --- Spawn replacement prop ---
        SpawnProp();
    }

    private void SpawnProp()
    {
        if (propPool == null || propPool.Length == 0)
        {
            Debug.LogWarning("[PolymorphEffect] Prop pool is empty! Assign prefabs in ModifierData.");
            return;
        }

        // Pick a random entry from the pool (ignore null slots)
        List<GameObject> validProps = new List<GameObject>();
        foreach (GameObject p in propPool)
        {
            if (p != null)
            {
                validProps.Add(p);
            }
        }

        if (validProps.Count == 0)
        {
            Debug.LogWarning("[PolymorphEffect] All prop pool entries are null!");
            return;
        }

        int index = Random.Range(0, validProps.Count);
        GameObject prefab = validProps[index];

        // Spawn at NPC root position, upright
        spawnedProp = Instantiate(prefab, transform.position, Quaternion.identity);

        // Do NOT parent the prop to the NPC - keeps it independent so NPC
        // movement (if any residual) doesn't drag the prop around oddly.
        // Position is snapped to the NPC's feet each frame in Update instead.

        Debug.Log("[PolymorphEffect] Spawned prop " + prefab.name + " at " + transform.position);

        if (showDebug)
        {
            Debug.DrawLine(transform.position, transform.position + Vector3.up * 2f, Color.green, duration);
        }
    }

    // ------------------------------------------------------------------ //
    //  Update
    // ------------------------------------------------------------------ //

    private void Update()
    {
        // Keep the prop snapped to the NPC's feet in case anything moves it
        if (spawnedProp != null)
        {
            spawnedProp.transform.position = transform.position;
        }

        timeRemaining -= Time.deltaTime;

        if (timeRemaining <= 0f)
        {
            EndEffect();
        }
    }

    // ------------------------------------------------------------------ //
    //  Cleanup
    // ------------------------------------------------------------------ //

    private void EndEffect()
    {
        Debug.Log("[PolymorphEffect] Ended on " + gameObject.name);

        RestoreNPC();
        Destroy(this);
    }

    private void RestoreNPC()
    {
        EnemyHealth enemyHealth = GetComponent<EnemyHealth>();
        if (enemyHealth != null)
        {
            enemyHealth.isPolymorphed = false;
        }

        // Release the NPC state machine
        NPCManager npcManager = GetComponent<NPCManager>();
        if (npcManager != null)
        {
            npcManager.ExitStunnedState();
            Debug.Log("[PolymorphEffect] Exited stunned state on NPCManager for " + gameObject.name);
        }

        // Restore gun if NPC is in combat
        npcManager = GetComponent<NPCManager>();
        if (npcManager != null && npcManager.hasEnteredAggro)
        {
            npcManager.SpawnGun();
        }

        // --- Destroy the prop ---
        if (spawnedProp != null)
        {
            Destroy(spawnedProp);
            spawnedProp = null;
        }

        // --- Restore NPC renderers ---
        if (npcRenderers != null)
        {
            foreach (Renderer r in npcRenderers)
            {
                if (r != null)
                {
                    r.enabled = true;
                }
            }
            Debug.Log("[PolymorphEffect] Restored " + npcRenderers.Length + " renderers on " + gameObject.name);
        }

        // --- Resume NavMeshAgent ---
        if (hadNavAgent && navAgent != null)
        {
            if (navAgent.isOnNavMesh)
            {
                navAgent.isStopped = false;
                Debug.Log("[PolymorphEffect] Resumed NavMeshAgent on " + gameObject.name);
            }
            else
            {
                // Warp to nearest NavMesh point before resuming
                NavMeshHit hit;
                if (NavMesh.SamplePosition(transform.position, out hit, 3f, NavMesh.AllAreas))
                {
                    navAgent.Warp(hit.position);
                    navAgent.isStopped = false;
                    Debug.Log("[PolymorphEffect] Warped and resumed NavMeshAgent on " + gameObject.name);
                }
                else
                {
                    navAgent.isStopped = false;
                    Debug.LogWarning("[PolymorphEffect] No NavMesh near " + gameObject.name + " on restore");
                }
            }
        }

        // --- Restore Animator ---
        if (hadAnimator && animator != null)
        {
            animator.speed = originalAnimatorSpeed;
            Debug.Log("[PolymorphEffect] Restored Animator on " + gameObject.name);
        }
    }

    private void OnDestroy()
    {
        EnemyHealth enemyHealth = GetComponent<EnemyHealth>();
        if (enemyHealth != null)
        {
            enemyHealth.isPolymorphed = false;
        }

        // Safety net: restore everything if this component is destroyed externally
        // (e.g. NPC dies while polymorphed)
        if (spawnedProp != null)
        {
            Destroy(spawnedProp);
        }

        NPCManager npcManager = GetComponent<NPCManager>();
        if (npcManager != null)
        {
            npcManager.ExitStunnedState();
        }

        if (npcRenderers != null)
        {
            foreach (Renderer r in npcRenderers)
            {
                if (r != null)
                {
                    r.enabled = true;
                }
            }
        }

        if (hadNavAgent && navAgent != null)
        {
            navAgent.isStopped = false;
        }

        if (hadAnimator && animator != null)
        {
            animator.speed = originalAnimatorSpeed;
        }
    }
}
questeditorwindow.cs Compiled
#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;

public class QuestEditorWindow : EditorWindow
{
    private QuestData quest;
    private Vector2 scrollPosition;
    private QuestStep selectedStep;
    private Vector2 panOffset = Vector2.zero;

    private const float STEP_WIDTH = 250f;
    private const float STEP_HEIGHT = 120f;

    private GUIStyle stepStyle;
    private GUIStyle selectedStepStyle;

    [MenuItem("Window/Quest Editor")]
    public static void OpenWindow()
    {
        GetWindow<QuestEditorWindow>("Quest Editor");
    }

    public static void OpenWindow(QuestData questData)
    {
        QuestEditorWindow window = GetWindow<QuestEditorWindow>("Quest Editor");
        window.quest = questData;
    }

    private void OnEnable()
    {
        InitializeStyles();
    }

    private void InitializeStyles()
    {
        stepStyle = new GUIStyle();
        stepStyle.normal.background = MakeTex(2, 2, new Color(0.3f, 0.4f, 0.5f, 1f));
        stepStyle.border = new RectOffset(5, 5, 5, 5);
        stepStyle.padding = new RectOffset(10, 10, 10, 10);
        stepStyle.normal.textColor = Color.white;
        stepStyle.fontSize = 12;
        stepStyle.fontStyle = FontStyle.Bold;
        stepStyle.alignment = TextAnchor.UpperLeft;

        selectedStepStyle = new GUIStyle(stepStyle);
        selectedStepStyle.normal.background = MakeTex(2, 2, new Color(0.2f, 0.6f, 0.8f, 1f));
    }

    private Texture2D MakeTex(int width, int height, Color col)
    {
        Color[] pix = new Color[width * height];
        for (int i = 0; i < pix.Length; i++)
            pix[i] = col;

        Texture2D result = new Texture2D(width, height);
        result.SetPixels(pix);
        result.Apply();
        return result;
    }

    private void OnGUI()
    {
        DrawToolbar();

        if (quest == null)
        {
            EditorGUILayout.HelpBox("No Quest selected. Create one or select an existing one.", MessageType.Info);
            return;
        }

        DrawGrid(20, 0.2f, Color.gray);
        DrawGrid(100, 0.4f, Color.gray);

        DrawConnections();
        DrawSteps();
        DrawStepInspector();

        ProcessEvents(Event.current);

        if (GUI.changed)
        {
            EditorUtility.SetDirty(quest);
        }
    }

    private void DrawToolbar()
    {
        EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);

        quest = (QuestData)EditorGUILayout.ObjectField(quest, typeof(QuestData), false, GUILayout.Width(200));

        if (GUILayout.Button("Add Step", EditorStyles.toolbarButton, GUILayout.Width(80)))
        {
            AddStep();
        }

        if (GUILayout.Button("Delete Step", EditorStyles.toolbarButton, GUILayout.Width(80)))
        {
            DeleteSelectedStep();
        }

        GUILayout.FlexibleSpace();

        EditorGUILayout.EndHorizontal();
    }

    private void DrawGrid(float gridSpacing, float gridOpacity, Color gridColor)
    {
        int widthDivs = Mathf.CeilToInt(position.width / gridSpacing);
        int heightDivs = Mathf.CeilToInt(position.height / gridSpacing);

        Handles.BeginGUI();
        Handles.color = new Color(gridColor.r, gridColor.g, gridColor.b, gridOpacity);

        Vector3 newOffset = new Vector3(panOffset.x % gridSpacing, panOffset.y % gridSpacing, 0);

        for (int i = 0; i < widthDivs; i++)
        {
            Handles.DrawLine(
                new Vector3(gridSpacing * i, -gridSpacing, 0) + newOffset,
                new Vector3(gridSpacing * i, position.height, 0f) + newOffset
            );
        }

        for (int j = 0; j < heightDivs; j++)
        {
            Handles.DrawLine(
                new Vector3(-gridSpacing, gridSpacing * j, 0) + newOffset,
                new Vector3(position.width, gridSpacing * j, 0f) + newOffset
            );
        }

        Handles.color = Color.white;
        Handles.EndGUI();
    }

    private void DrawSteps()
    {
        if (quest == null || quest.steps == null) return;

        for (int i = 0; i < quest.steps.Count; i++)
        {
            DrawStep(quest.steps[i], i);
        }
    }

    private void DrawStep(QuestStep step, int index)
    {
        Rect stepRect = new Rect(
            step.editorPosition.x + panOffset.x,
            step.editorPosition.y + panOffset.y,
            STEP_WIDTH,
            STEP_HEIGHT
        );

        GUIStyle style = step == selectedStep ? selectedStepStyle : stepStyle;

        GUI.Box(stepRect, "", style);

        GUILayout.BeginArea(stepRect);

        EditorGUILayout.LabelField($"Step {index + 1}: {step.stepID}", EditorStyles.boldLabel);

        string preview = step.stepDescription.Length > 60
            ? step.stepDescription.Substring(0, 60) + "..."
            : step.stepDescription;
        EditorGUILayout.LabelField(preview, EditorStyles.wordWrappedMiniLabel);

        EditorGUILayout.Space(5);
        EditorGUILayout.LabelField($"Objectives: {step.objectives.Count}", EditorStyles.miniLabel);

        GUILayout.EndArea();
    }

    private void DrawConnections()
    {
        if (quest == null || quest.steps == null || quest.steps.Count < 2) return;

        Handles.BeginGUI();

        for (int i = 0; i < quest.steps.Count - 1; i++)
        {
            QuestStep currentStep = quest.steps[i];
            QuestStep nextStep = quest.steps[i + 1];

            Vector3 startPos = new Vector3(
                currentStep.editorPosition.x + STEP_WIDTH + panOffset.x,
                currentStep.editorPosition.y + STEP_HEIGHT / 2 + panOffset.y,
                0
            );

            Vector3 endPos = new Vector3(
                nextStep.editorPosition.x + panOffset.x,
                nextStep.editorPosition.y + STEP_HEIGHT / 2 + panOffset.y,
                0
            );

            Handles.DrawBezier(
                startPos,
                endPos,
                startPos + Vector3.right * 50f,
                endPos + Vector3.left * 50f,
                Color.cyan,
                null,
                2f
            );
        }

        Handles.EndGUI();
    }

    private void DrawStepInspector()
    {
        if (selectedStep == null) return;

        float inspectorWidth = 350f;
        Rect inspectorRect = new Rect(position.width - inspectorWidth - 10, 40, inspectorWidth, position.height - 50);

        GUILayout.BeginArea(inspectorRect, EditorStyles.helpBox);
        scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);

        EditorGUILayout.LabelField("Step Inspector", EditorStyles.boldLabel);
        EditorGUILayout.Space();

        selectedStep.stepID = EditorGUILayout.TextField("Step ID", selectedStep.stepID);

        EditorGUILayout.LabelField("Description:");
        selectedStep.stepDescription = EditorGUILayout.TextArea(selectedStep.stepDescription, GUILayout.Height(60));

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("Objectives:", EditorStyles.boldLabel);

        for (int i = 0; i < selectedStep.objectives.Count; i++)
        {
            EditorGUILayout.BeginVertical(EditorStyles.helpBox);

            EditorGUILayout.LabelField($"Objective {i + 1}", EditorStyles.miniBoldLabel);

            selectedStep.objectives[i].type = (QuestObjective.ObjectiveType)EditorGUILayout.EnumPopup(
                "Type",
                selectedStep.objectives[i].type
            );

            selectedStep.objectives[i].description = EditorGUILayout.TextField(
                "Description",
                selectedStep.objectives[i].description
            );

            selectedStep.objectives[i].targetID = EditorGUILayout.TextField(
                "Target ID",
                selectedStep.objectives[i].targetID
            );

            selectedStep.objectives[i].requiredAmount = EditorGUILayout.IntField(
                "Required Amount",
                selectedStep.objectives[i].requiredAmount
            );

            if (GUILayout.Button("Remove Objective", GUILayout.Height(20)))
            {
                selectedStep.objectives.RemoveAt(i);
                break;
            }

            EditorGUILayout.EndVertical();
            EditorGUILayout.Space(5);
        }

        if (GUILayout.Button("Add Objective"))
        {
            selectedStep.objectives.Add(new QuestObjective());
        }

        EditorGUILayout.EndScrollView();
        GUILayout.EndArea();
    }

    private void ProcessEvents(Event e)
    {
        switch (e.type)
        {
            case EventType.MouseDown:
                if (e.button == 0)
                {
                    SelectStep(e.mousePosition);
                }
                else if (e.button == 2)
                {
                    panOffset += e.delta;
                }
                break;

            case EventType.MouseDrag:
                if (e.button == 0 && selectedStep != null)
                {
                    selectedStep.editorPosition += e.delta;
                    GUI.changed = true;
                }
                else if (e.button == 2)
                {
                    panOffset += e.delta;
                    GUI.changed = true;
                }
                Repaint();
                break;
        }
    }

    private void SelectStep(Vector2 mousePosition)
    {
        selectedStep = null;

        if (quest == null || quest.steps == null) return;

        for (int i = quest.steps.Count - 1; i >= 0; i--)
        {
            Rect stepRect = new Rect(
                quest.steps[i].editorPosition.x + panOffset.x,
                quest.steps[i].editorPosition.y + panOffset.y,
                STEP_WIDTH,
                STEP_HEIGHT
            );

            if (stepRect.Contains(mousePosition))
            {
                selectedStep = quest.steps[i];
                GUI.changed = true;
                break;
            }
        }
    }

    private void AddStep()
    {
        if (quest == null) return;

        QuestStep newStep = new QuestStep();
        newStep.stepID = "step_" + quest.steps.Count;
        newStep.stepDescription = "New step description";
        newStep.editorPosition = new Vector2(100 + (quest.steps.Count * 300), 100) - panOffset;

        quest.steps.Add(newStep);
        selectedStep = newStep;

        EditorUtility.SetDirty(quest);
    }

    private void DeleteSelectedStep()
    {
        if (selectedStep == null || quest == null) return;

        quest.steps.Remove(selectedStep);
        selectedStep = null;

        EditorUtility.SetDirty(quest);
    }
}

[CustomEditor(typeof(QuestData))]
public class QuestDataEditor : Editor
{
    public override void OnInspectorGUI()
    {
        DrawDefaultInspector();

        EditorGUILayout.Space(10);

        if (GUILayout.Button("Open Quest Editor", GUILayout.Height(30)))
        {
            QuestEditorWindow.OpenWindow((QuestData)target);
        }
    }
}
#endif
barrelremovalminigame.cs Compiled
using UnityEngine;
using System.Collections.Generic;

/// <summary>
/// Minigame for REMOVING barrel attachments - unscrew and drag away
/// </summary>
public class BarrelRemovalMinigame : AttachmentMinigameBase
{
    [Header("Removal Settings")]
    [SerializeField] private float unscrewDistance = 2f;
    [SerializeField] private float maxUnscrewPerPull = 0.25f;
    [SerializeField] private float unscrewRotationSpeed = 180f;
    [SerializeField] private float unscrewMoveDistance = 0.05f;
    [SerializeField] private Vector3 unscrewMoveDirection = Vector3.forward;
    [SerializeField] private float minDistanceToMoveAway = 0.1f;

    [Header("Camera Zoom Settings")]
    [SerializeField] private float zoomAmount = 0.7f;
    [SerializeField] private float zoomSpeed = 2f;

    private Camera mainCamera;
    private Vector3 originalCameraPosition;
    private bool isZooming = false;
    private bool isZoomingOut = false;
    private Vector3 targetCameraPosition;

    private enum RemovalState { Unscrewing, MovingAway, Complete }
    private RemovalState currentState = RemovalState.Unscrewing;

    private float unscrewProgress = 0f;
    private float totalUnscrewDistance = 0f;
    private float currentUnscrewPullDistance = 0f;
    private Vector3 lastMousePosition;

    private List<GameObject> weaponPartsToReEnable = new List<GameObject>();
    private Vector3 socketWorldPosition;
    private bool isDraggingPart = false;
    private Vector3 grabbedPartDragStart;
    private Vector3 grabbedPartStartPos;

    protected override void Awake()
    {
        base.Awake();

        // Add collider if needed
        Collider col = GetComponent<Collider>();
        if (col == null)
        {
            col = gameObject.AddComponent<BoxCollider>();
        }
    }

    public override void StartMinigame()
    {
        base.StartMinigame();

        mainCamera = minigameCamera != null ? minigameCamera : Camera.main;

        if (mainCamera == null)
        {
            Debug.LogError("BarrelRemovalMinigame: No camera found!");
            return;
        }

        originalCameraPosition = mainCamera.transform.position;

        if (targetSocket != null)
        {
            socketWorldPosition = targetSocket.position;
        }

        currentState = RemovalState.Unscrewing;

        // Zoom camera to barrel
        ZoomToBarrel();

        Debug.Log("Barrel removal started. Drag UP to unscrew the barrel.");
    }

    /// <summary>
    /// Set weapon parts that will be re-enabled when barrel is removed
    /// </summary>
    public void SetWeaponPartsToReEnable(Transform weaponTransform, List<string> partPaths)
    {
        weaponPartsToReEnable.Clear();

        if (partPaths == null || partPaths.Count == 0 || weaponTransform == null)
            return;

        foreach (var partPath in partPaths)
        {
            if (string.IsNullOrEmpty(partPath))
                continue;

            Transform partTransform = weaponTransform.Find(partPath);
            if (partTransform == null)
                partTransform = FindChildByName(weaponTransform, partPath);

            if (partTransform != null)
            {
                weaponPartsToReEnable.Add(partTransform.gameObject);
                Debug.Log($"Will re-enable: {partTransform.name}");
            }
        }
    }

    private Transform FindChildByName(Transform parent, string name)
    {
        foreach (Transform child in parent)
        {
            if (child.name == name)
                return child;
            Transform result = FindChildByName(child, name);
            if (result != null)
                return result;
        }
        return null;
    }

    private void ZoomToBarrel()
    {
        if (mainCamera == null || targetSocket == null) return;

        Vector3 directionToBarrel = (targetSocket.position - mainCamera.transform.position).normalized;
        float distanceToBarrel = Vector3.Distance(mainCamera.transform.position, targetSocket.position);
        targetCameraPosition = mainCamera.transform.position + (directionToBarrel * distanceToBarrel * zoomAmount);
        isZooming = true;
    }

    protected override void Update()
    {
        base.Update();

        if (isComplete) return;

        // Handle camera zoom
        if ((isZooming || isZoomingOut) && mainCamera != null)
        {
            mainCamera.transform.position = Vector3.Lerp(
                mainCamera.transform.position,
                targetCameraPosition,
                Time.deltaTime * zoomSpeed
            );

            if (Vector3.Distance(mainCamera.transform.position, targetCameraPosition) < 0.01f)
            {
                mainCamera.transform.position = targetCameraPosition;
                isZooming = false;
                isZoomingOut = false;
            }
        }

        // Animate barrel moving out as it's unscrewed
        if (currentState == RemovalState.Unscrewing && unscrewProgress > 0f)
        {
            Vector3 moveOffset = transform.TransformDirection(unscrewMoveDirection) * unscrewMoveDistance * unscrewProgress;
            transform.position = Vector3.Lerp(transform.position, targetSocket.position + moveOffset, Time.deltaTime * 5f);
        }

        switch (currentState)
        {
            case RemovalState.Unscrewing:
                HandleUnscrewing();
                break;
            case RemovalState.MovingAway:
                HandleMovingAway();
                break;
        }
    }

    void HandleUnscrewing()
    {
        // Start a new pull
        if (Input.GetMouseButtonDown(0))
        {
            lastMousePosition = Input.mousePosition;
            currentUnscrewPullDistance = 0f;
            Debug.Log("Started new unscrew pull");
        }

        // Continue pulling UP
        if (Input.GetMouseButton(0))
        {
            Vector3 currentMousePos = Input.mousePosition;
            Vector3 mouseDelta = currentMousePos - lastMousePosition;

            // Only count UPWARD movement
            float upwardMovement = mouseDelta.y / Screen.height * 10f;

            if (upwardMovement > 0)
            {
                float maxDistanceThisPull = unscrewDistance * maxUnscrewPerPull;

                if (currentUnscrewPullDistance < maxDistanceThisPull)
                {
                    float distanceToAdd = Mathf.Min(upwardMovement, maxDistanceThisPull - currentUnscrewPullDistance);
                    currentUnscrewPullDistance += distanceToAdd;
                    totalUnscrewDistance += distanceToAdd;

                    unscrewProgress = Mathf.Clamp01(totalUnscrewDistance / unscrewDistance);

                    // Rotate as we unscrew
                    float rotationAmount = distanceToAdd * unscrewRotationSpeed;
                    transform.Rotate(Vector3.forward, -rotationAmount, Space.Self);

                    // Visual feedback
                    Color progressColor = Color.Lerp(Color.red, Color.green, unscrewProgress);
                    SetColor(progressColor);

                    Debug.Log($"Unscrew progress: {unscrewProgress * 100f:F0}%");
                }
                else
                {
                    Debug.Log($"Unscrew pull limit reached! Release and pull again");
                }

                // Check if complete
                if (unscrewProgress >= 1f)
                {
                    Debug.Log("Barrel unscrewed! Now drag it away from the weapon.");
                    currentState = RemovalState.MovingAway;
                    SetColor(Color.yellow);
                }
            }

            lastMousePosition = currentMousePos;
        }

        // Released mouse - reset for next pull
        if (Input.GetMouseButtonUp(0))
        {
            if (currentUnscrewPullDistance > 0)
            {
                Debug.Log($"Unscrew pull complete: {currentUnscrewPullDistance:F2} units. Total progress: {unscrewProgress * 100f:F0}%");
            }
            currentUnscrewPullDistance = 0f;
        }
    }

    void HandleMovingAway()
    {
        // Start dragging
        if (Input.GetMouseButtonDown(0))
        {
            Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
            RaycastHit[] hits = Physics.RaycastAll(ray, 1000f);

            foreach (var hit in hits)
            {
                if (hit.collider.gameObject == gameObject || hit.collider.transform.IsChildOf(transform))
                {
                    isDraggingPart = true;
                    grabbedPartDragStart = Input.mousePosition;
                    grabbedPartStartPos = transform.position;
                    Debug.Log($"Grabbed barrel: {gameObject.name}");
                    break;
                }
            }
        }

        // Continue dragging
        if (isDraggingPart && Input.GetMouseButton(0))
        {
            Vector3 currentMousePos = Input.mousePosition;
            Vector3 mouseDelta = currentMousePos - grabbedPartDragStart;

            Vector3 worldDelta = mainCamera.ScreenToWorldPoint(new Vector3(mouseDelta.x, mouseDelta.y, mainCamera.WorldToScreenPoint(grabbedPartStartPos).z))
                                - mainCamera.ScreenToWorldPoint(new Vector3(0, 0, mainCamera.WorldToScreenPoint(grabbedPartStartPos).z));

            transform.position = grabbedPartStartPos + worldDelta;

            // Check distance from socket
            float distanceFromSocket = Vector3.Distance(transform.position, socketWorldPosition);

            // Visual feedback
            Color feedbackColor = distanceFromSocket >= minDistanceToMoveAway ? Color.green : Color.yellow;
            SetColor(feedbackColor);
        }

        // Release
        if (Input.GetMouseButtonUp(0) && isDraggingPart)
        {
            float distanceFromSocket = Vector3.Distance(transform.position, socketWorldPosition);

            if (distanceFromSocket >= minDistanceToMoveAway)
            {
                Debug.Log($"Barrel moved far enough away. Removal complete!");
                CompleteMinigame();
            }
            else
            {
                Debug.Log($"Not far enough away. Move it further!");
            }

            isDraggingPart = false;
        }
    }

    protected override void CompleteMinigame()
    {
        SetColor(Color.green);

        // Re-enable weapon parts (default barrel)
        if (weaponPartsToReEnable != null && weaponPartsToReEnable.Count > 0)
        {
            Debug.Log($"Re-enabling {weaponPartsToReEnable.Count} weapon part(s)");
            foreach (var part in weaponPartsToReEnable)
            {
                if (part != null)
                {
                    Debug.Log($"  Re-enabling: {part.name}");
                    part.SetActive(true);
                }
            }
        }

        // Zoom camera back
        if (mainCamera != null)
        {
            targetCameraPosition = originalCameraPosition;
            isZoomingOut = true;
        }

        StartCoroutine(CompleteAfterZoomOut());
    }

    public override void CancelMinigame()
    {
        if (mainCamera != null)
        {
            mainCamera.transform.position = originalCameraPosition;
            isZooming = false;
            isZoomingOut = false;
        }

        base.CancelMinigame();
    }

    private System.Collections.IEnumerator CompleteAfterZoomOut()
    {
        while (isZoomingOut)
        {
            yield return null;
        }

        base.CompleteMinigame();
    }

    void OnDrawGizmos()
    {
        if (targetSocket != null)
        {
            Gizmos.color = Color.cyan;
            Gizmos.DrawWireSphere(targetSocket.position, minDistanceToMoveAway);

            Gizmos.color = Color.red;
            Vector3 moveOffset = transform.TransformDirection(unscrewMoveDirection) * unscrewMoveDistance;
            Gizmos.DrawLine(transform.position, transform.position + moveOffset);
        }
    }
}

About

Hi! My name's Jaxson. I've been building games for two years now and am looking for opportunities in the industry! Thus far I've worked on multiple different projects in a variety of genres. I served as technical director on the game Kynetic Flux, an award winning student capstone project. After Kynetic Flux I started work on an open world crime simulator game set for release on Steam in early 2027 called STRAPT. On the side I have also created an in-depth controller for a 2D metroidvania I plan to develop in the future.

Tools and stack
Unity 2022.3 LTSC# URPProBuilder AI NavigationEasyRoads3D CinemachineCustom HLSL FL Studio
Download resume
Jaxson Vignal