Summary

Social Oddity is a point and click adventure game about overcoming social awkwardness.

The game revolves around the introverted Jamie who gets dragged to a nightclub by their well meaning friend Buddy in an attempt to get them to open up a little. Buddy runs off after telling Jamie to meet him in the VIP area later. The problem is, he forgot to tell them the password to get in! Thus, Jamie must overcome their fears and talk to people in a quest for VIP access.

At the start of the game you can only say ”Hello”, but as you hear other people talk you write down phrases that they say in your trusty notebook.
By mimicking other people you must try to have normal conversations with the unique characters that frequent the nightclub.

The game was made in Unity 2018 and took 5 weeks to complete
Our team was composed of:

  • 6 x designers
  • 4 x 2D artists

 


My role

My role in the project was as main scripter.
My contributions were:

  • The player movement
  • The underlying system for conversations and dialogue trees
  • The scrolling text in the speech bubbles
  • Much bug fixing and testing

 

Player Movement

We wanted a basic point and click movement system for the game, so that’s what I made. You click where you want the character to go and they walk there at a constant speed until they arrive. If you click on an NPC a conversation starts as soon as you collide with their trigger box.
You can also walk left and right pressing A and D or left arrow and right arrow.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Spine.Unity;

public class PlayerLogic : MonoBehaviour {
    private float speed = 275f;
    private float horizontalAxis;
    private Rigidbody2D rBody;
    private bool facingRight = true;
    private float accelerationTimer = 0f;
    public bool talkingMode = false;
    private float moveToPositionTimer = 1f;
    private Vector3 npcPosition = Vector2.zero;
    private Vector2 standingPosition = Vector2.zero;
    private CharacterDialog activeCharacter;
    private Vector3 fromPosition;
    [SerializeField]
    private List sound = new List();

    private Vector2 clickedPosition;
    public CharacterDialog clickedCharacter;
    private SkeletonAnimation skeletonAnimation;

    [SerializeField]
    private List expressions = new List();

    [SerializeField]
    private LayerMask layerMask;
    private float moveToNPCSpeed = 1f;
    private bool lookingAtNotebook;
    private bool characterIsLocked;


    public static PlayerLogic Instance { get; set; }

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
        }
        else
        {
            print("Warning: multiple " + this + " in scene!");
        }

        DontDestroyOnLoad(Instance);
    }

    private void Start ()
    {
        rBody = GetComponent();
        skeletonAnimation = GetComponentInChildren();
	}
	
	private void Update ()
    {
        // Walk to a position to the left of NPC when starting a conversation
        if (npcPosition != Vector3.zero && moveToPositionTimer < 1f) { moveToPositionTimer += Time.deltaTime * moveToNPCSpeed; MoveToNPC(); if (moveToPositionTimer >= 1f)
            {
                moveToPositionTimer = 1f;
                npcPosition = Vector3.zero;
                activeCharacter.StartConversation();
                if (transform.localScale.x < 0f)
                {
                    Flip();
                }
            }
        }

        if (!talkingMode)
        {
            if (Input.GetKeyDown(KeyCode.Mouse0) && !lookingAtNotebook)
            {
                clickedPosition = Camera.main.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, 10f));

                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                Vector3 tmpVector = clickedPosition;

                RaycastHit2D hitInfo;
                hitInfo = Physics2D.Raycast(clickedPosition, Vector2.zero, 0, layerMask);

                if (hitInfo.collider != null && hitInfo.collider.gameObject.layer == 9)
                {
                    clickedCharacter = hitInfo.collider.gameObject.GetComponent();
                    if (clickedCharacter.GetPlayerIsClose())
                    {
                        SetActiveCharacter(clickedCharacter);
                    }
                }
                else
                {
                    clickedCharacter = null;
                }
            }
            
            if (Input.GetKeyDown(KeyCode.Escape) && npcPosition == Vector3.zero)
            {
                // Check if player overlaps npc
                Collider2D col = Physics2D.OverlapArea(transform.position + new Vector3(-0.5f, -0.7f, 0f), transform.position + new Vector3(0.2f, 1f, 0f), layerMask);
                if (col == null && !lookingAtNotebook)
                {
                    horizontalAxis = 0f;
                    lookingAtNotebook = true;
                    Notebook.Instance.OpenNotebook(true);
                    Notebook.Instance.OpenMissionsPage();
                    
                    CameraBehaviour.Instance.Zoom(this.gameObject);

                    clickedCharacter = null;
                    clickedPosition = Vector2.zero;
                }
                else if (col == null && lookingAtNotebook)
                {
                    lookingAtNotebook = false;
                    Notebook.Instance.OpenNotebook(false);

                    CameraBehaviour.Instance.Zoom();
                }
            }
        }
	}

    private void FixedUpdate()
    {
        if (clickedPosition == Vector2.zero && !lookingAtNotebook && !characterIsLocked && !talkingMode)
        {
            horizontalAxis = Input.GetAxis("Horizontal");
        }
        else if (Input.GetKeyDown(KeyCode.A) || Input.GetKeyDown(KeyCode.D))
        {

            clickedPosition = Vector2.zero;
            clickedCharacter = null;
        }

        HandleMovement(horizontalAxis);
        HandleAnimations(horizontalAxis);
    }

    private void HandleAnimations(float movement)
    {
        if ((movement != 0f || clickedPosition != Vector2.zero || moveToPositionTimer < 1f) && skeletonAnimation.AnimationState.GetCurrent(0).ToString() != "Walking3" ) { skeletonAnimation.AnimationState.SetAnimation(0, "Walking3", true).timeScale = 1.5f; } else if ( movement == 0f && clickedPosition == Vector2.zero && skeletonAnimation.AnimationState.GetCurrent(0).ToString() != "Idle_main" && moveToPositionTimer >= 1f
            )
        {
            skeletonAnimation.AnimationState.SetAnimation(0, "Idle_main", true).timeScale = 1f;
        }
        
    }

    private void HandleMovement(float horizontalAxis)
    {
        if (clickedPosition != Vector2.zero)
        {
            Vector2 tmpPlayerPos = transform.position;

            float distance = tmpPlayerPos.x - clickedPosition.x;
            
            if (distance < 0.1f) { horizontalAxis = 1f; } else if (distance > -0.1f)
            {
                horizontalAxis = -1f;
            }

            if (distance < 0.1f && distance > -0.1f)
            {
                clickedPosition = Vector2.zero;
                return;
            }
        }
        
        rBody.AddForce(new Vector2(horizontalAxis, 0f) * speed, ForceMode2D.Force);

        Vector2 velocity = rBody.velocity;

        if (velocity.x > 5f)
        {
            velocity.x = 5f;
            rBody.velocity = velocity;
        }
        else if (velocity.x < -5f) { velocity.x = -5f; rBody.velocity = velocity; } if ((horizontalAxis > 0 && !facingRight) || (horizontalAxis < 0 && facingRight)) { Flip(); } } private void Flip() { facingRight = !facingRight; transform.localScale = new Vector3(-transform.localScale.x, 1f, 1f); } private void MoveToNPC() { transform.position = Vector3.Lerp(fromPosition, npcPosition, moveToPositionTimer); if ((transform.position.x > npcPosition.x && facingRight) || (transform.position.x < npcPosition.x && !facingRight))
        {
            Flip();
        }
    }

    public void SetActiveCharacter(CharacterDialog character)
    {
        activeCharacter = character;

        Vector3 tmpPos = activeCharacter.transform.position;
        tmpPos.x -= 2.8f;
        tmpPos.y = transform.position.y;
        tmpPos.z = transform.position.z;
        npcPosition = tmpPos;
        standingPosition = new Vector2(npcPosition.x, transform.position.y);

        float distance = Vector2.Distance(standingPosition, transform.position);
        moveToNPCSpeed = 1f / (distance * 0.3f);

        moveToPositionTimer = 0f;
        fromPosition = transform.position;
        clickedPosition = Vector2.zero;

        facingRight = true;
        transform.localScale = new Vector3(1f, 1f, 1f);
    }

    public List GetSound()
    {
        return sound;
    }

    public void ShowExpression(ExpressionEnum playerExpression)
    {
        for (int i = 0; i < expressions.Count; i++)
        {
            if (expressions[i].expression == playerExpression)
            {
                expressions[i].faceSprite.GetComponent().enabled = true;
            }
            else
            {
                expressions[i].faceSprite.GetComponent().enabled = false;
            }
        }
    }

    public float GetHorizontalAxis()
    {
        return horizontalAxis;
    }

    public void SetClickedPosition(Vector2 newPosition)
    {
        clickedPosition = newPosition;
    }

    public bool GetLookingAtNotebook()
    {
        return lookingAtNotebook;
    }

    public void SetLookingAtNoteBook(bool looking)
    {
        lookingAtNotebook = looking;
    }

    public void StopLookingAtNotebook()
    {
        lookingAtNotebook = false;
        Notebook.Instance.OpenNotebook(false);
        CameraBehaviour.Instance.Zoom();
    }

    public void SetCharacterIsLocked(bool isLocked)
    {
        characterIsLocked = isLocked;
    }

    public bool GetCharacterIsLocked()
    {
        return characterIsLocked;
    }

    public void SetHorizontalAxis(float value)
    {
        horizontalAxis = value;
    }

    public LayerMask GetLayerMask()
    {
        return layerMask;
    }

    public CharacterDialog GetActiveCharacter()
    {
        return activeCharacter;
    }

    public void SetOnlyActiveCharacter(CharacterDialog character)
    {
        activeCharacter = character;
    }
}

 


 

Dialogue system

We wanted the narrative designers to be able to change and add dialogue without any coding. So all phrases and pieces of dialogue are scriptable objects that are sorted into folders based on character. The scriptable objects are divided into three categories: Phrases, questions and responses.

 

Phrases

Phrases are things that the player can say when in a conversation. Each phrase can have default responses that are randomized if the active question has no specific response for that context.
A phrase can also be an item, which in practice means that it’s like a phrase that can only be used once.

 

Questions

Every NPC has a list of questions that they will ask, and an index that shows which question is the active one.
A question has a number of valid responses that are activated if the player says specific phrases when the question is active.
A question can also teach the player a new phrase, end the conversation, start a mission, continue to the next question without input from the player or make changes in the world.

 

Responses

Responses are the NPC’s reactions to spoken phrases from the player. If the response is marked as correct, the conversation goes to the next question in the list. A response can end a conversation, start or finish a mission, change something in the world, move the conversation to the secondary list of questions or jump to any index in the conversation.

 

    public void SayPhrase(Phrase phrase)
    {
        if (canSayPhrase)
        {
            TextTyper.Instance.SetSpeechBubbleEnabled(true);

            // If we are at the end of a sideQuestion chain
            if (secondaryQuestionIndex + 1 >= secondaryQuestions.Count)
            {
                secondaryQuestionIsActive = false;
            }

            List tmpQuestions = new List(questions);
            int tmpQuestionIndex = questionIndex;

            if (secondaryQuestionIsActive)
            {
                tmpQuestions = new List(secondaryQuestions);
                tmpQuestionIndex = secondaryQuestionIndex;
            }


            TextQueueStruct newItem = new TextQueueStruct
            {
                text = phrase.text,
                characterTalking = true,
                sound = PlayerLogic.Instance.GetSound()
            };

            if (phrase.isSilence)
            {
                newItem.text = "...";
            }

            TextTyper.Instance.AddToTextQueue(newItem);
            playerHasSpoken = true;

            // Check if the phrase has a response specific to the current question.
            bool hasDefault = false;
            Response tmpResponse = null;
            for (int i = 0; i < tmpQuestions[tmpQuestionIndex].validResponses.Count; i++)
            {
                if (tmpQuestions[tmpQuestionIndex].validResponses[i].phrase == phrase)
                {
                    hasDefault = true;
                    tmpResponse = tmpQuestions[tmpQuestionIndex].validResponses[i];
                    break;
                }
            }

            // Check if the phrase has a response specific to the active npc
            if (!hasDefault)
            {
                for (int i = 0; i < defaultResponses.Count; i++)
                {
                    if (defaultResponses[i].phrase == phrase)
                    {
                        hasDefault = true;
                        tmpResponse = defaultResponses[i];
                        break;
                    }
                }
            }

            // Otherwise try to use one of the default responses for the phrase
            if (!hasDefault)
            {
                int tmpIndex = Random.Range(0, phrase.defaultResponses.Count);
                if (phrase.defaultResponses.Count <= 0 || phrase.defaultResponses[tmpIndex] == null)
                {
                    RepeatQuestion();
                }
                else
                {
                    newItem = new TextQueueStruct
                    {
                        text = phrase.defaultResponses[tmpIndex],
                        characterTalking = false,
                        sound = speechSound
                    };
                    TextTyper.Instance.AddToTextQueue(newItem);

                    RepeatQuestion();
                }
            }
            else if (tmpResponse != null)
            {
                newItem = new TextQueueStruct
                {
                    text = tmpResponse.response,
                    characterTalking = false,
                    sound = speechSound,
                    npcExpression = tmpResponse.npcExpression,
                    playerExpression = tmpResponse.playerExpression,
                    mission = tmpResponse.mission,
                    completeMission = tmpResponse.completeMission
                };
                TextTyper.Instance.AddToTextQueue(newItem);

                // If the answer was correct, increase index for the relevant question list
                if (tmpResponse.isCorrect)
                {
                    if (secondaryQuestionIsActive)
                    {
                        secondaryQuestionIndex++;
                    }
                    else
                    {
                        questionIndex++;
                    }
                    if(tmpResponse.phrase.isItem)
                        PhrasePage.Instance.RemoveItem(tmpResponse.phrase);
                }

                if (tmpResponse.makesProgress)
                {
                    makeProgress = true;
                }

                // If learnedPhrase is not null, add it to players PhrasePage
                if (tmpResponse.learnedPhrase != null)
                {
                    learnedPhrase = tmpResponse.learnedPhrase;
                }

                if (tmpResponse.leadsToSideTopic)
                {
                    secondaryQuestionIsActive = true;
                }

                if (tmpResponse.jumpToIndex != 0)
                {
                    if (secondaryQuestionIsActive)
                    {
                        secondaryQuestionIndex = tmpResponse.jumpToIndex;
                    }
                    else
                    {
                        questionIndex = tmpResponse.jumpToIndex;
                    }
                }

                if (tmpResponse.conversationEnds)
                {
                    TextTyper.Instance.SetEndConversation(true);
                }
                else
                {
                    RepeatQuestion();
                }
            }
        }

        canSayPhrase = false;
        EventManager.TriggerEvent("DisablePhrases");
    }

 


 

Scrolling text

To get a better flow with the dialogue we wanted the text to be typed out in the speech bubbles instead of just appearing instantly.
The TextTyper class is a singleton that can be accessed from anywhere by writing TextTyper.Instance. Since we only needed one text typer in the scene this was easier than dealing with references everywhere.
The typing itself is basically a coroutine that writes one character at a time with a small delay.

using UnityEngine.UI;
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine.EventSystems;

[RequireComponent(typeof(AudioSource))]
public class TextTyper : MonoBehaviour {
    public static TextTyper Instance { get; set; }

    private float defaultTextSpeed = 50f;
    private float textSpeed;
    private string message;
    private TextMeshProUGUI textComp;
    public bool isWritingText;
    public bool isWaitingForClick;
    private List textQueue = new List();
    private CharacterDialog activeCharacter;
    private bool endConversation = false;
    [SerializeField]
    private GameObject playerSpeechBubble;
    [SerializeField]
    private GameObject npcSpeechBubble;
    [SerializeField]
    private GameObject nextButton;
    private Image nextButtonImg;
    private Image playerBubbleImage;
    private Image npcBubbleImage;
    private TextMeshProUGUI playerName;
    private TextMeshProUGUI npcName;

    private List speechSound;

    private bool waitingForCloseTag;
    private string tag;
    private bool autoContinue;
    private bool canContinue = true;
    private bool disabledPhrases;

    [SerializeField]
    private LayerMask postItLayerMask;


    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
        }
        else
        {
            print("Warning: multiple " + this + " in scene!");
        }
    }

    private void Start ()
    {
        textSpeed = defaultTextSpeed;
        textComp = GetComponent();
        textComp.text = "";

        playerBubbleImage = playerSpeechBubble.GetComponentInChildren();
        npcBubbleImage = npcSpeechBubble.GetComponentInChildren();
        playerName = playerSpeechBubble.GetComponentInChildren();
        npcName = npcSpeechBubble.GetComponentInChildren();
        nextButtonImg = nextButton.GetComponent();

        EventManager.StartListening("DisablePhrases", MarkAsDisabled);
    }

    private void Update()
    {
        if (!isWritingText && textQueue.Count > 0)
        {
            if (isWaitingForClick && (Input.GetKeyDown(KeyCode.Space) || (Input.GetKeyDown(KeyCode.Mouse0) && canContinue))) // TODO: Only allow mouse click if not over button
            {
                if (!CheckIfOverButton())
                {
                    ShowCorrectSpeechBubble();
                    speechSound = textQueue[0].sound;
                    ShowText(textQueue[0].text);
                    CheckMission(textQueue[0].mission, textQueue[0].completeMission);
                    autoContinue = textQueue[0].continueAutomatically;

                    textQueue.RemoveAt(0);
                }
            }
            else if (!isWaitingForClick)
            {
                ShowCorrectSpeechBubble();
                speechSound = textQueue[0].sound;
                ShowText(textQueue[0].text);
                autoContinue = textQueue[0].continueAutomatically;
                CheckMission(textQueue[0].mission, textQueue[0].completeMission);

                textQueue.RemoveAt(0);
            }
        }

        if (autoContinue)
        {
            disabledPhrases = true;
        }

        if (textQueue.Count <= 0 && !isWritingText && activeCharacter != null)
        {
            if (!endConversation)
            {
                if (!activeCharacter.GetCanSayPhrase())
                {
                    activeCharacter.SetCanSayPhrase(true);
                }
                else
                {
                    if (disabledPhrases)
                    {
                        EventManager.TriggerEvent("EnablePhrases");
                        disabledPhrases = false;
                    }
                }
                HideShowButton();
            }            
            else if (Input.GetKeyDown(KeyCode.Space) || Input.GetKeyDown(KeyCode.Mouse0))
            {
                activeCharacter.EndConversation();
                endConversation = false;
                textComp.text = "";
            }
        }
    }

    private bool CheckIfOverButton()
    {
        // Found this snippet online, since my own raycast didn't work
        PointerEventData pe = new PointerEventData(EventSystem.current);
        pe.position = Input.mousePosition;

        List hits = new List();
        EventSystem.current.RaycastAll(pe, hits);
        bool hit = false;
        
        foreach (RaycastResult h in hits)
        {
            GameObject g = h.gameObject;
            hit = (g.name != "BackgroundEventCatcher" && g.GetComponent