Started in winter of 2018/2019
By Trevor Thacker

Runic Scrollery is a currently work-in-progress game designed and programmed by me, with temporary art also by me.
Currently, the primary part of the game, magic scroll writing, is being worked on, with progress on all of its base systems nearing completion.
Full information about the game can be found on it’s game design document here:


Drawing

Drawing is done by placing down vertices of a line renderer every X distance traveled while holding down left mouse button. Every time left mouse button is lifted, a line in the current rune is placed.

private void DrawLine()
    {
        Vector3 pos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
        RaycastHit hit;
        Ray ray = new Ray(pos, Vector3.forward);
        if (Physics.Raycast(ray, out hit))
        {
            if ((hit.collider.gameObject.tag == "DrawCircle"))//Check if the input is within the drawing square, if it is, add a vertex of the line
            {
                if (currentLine.GetComponent<LineRenderer>().positionCount == 0 || Vector3.Distance(prev, (Vector3)(Vector2)pos) >= 0.05 * scale)//Check to see if the cursor is far enough away from the vertex for another one to be placed
                {
                    currentLine.GetComponent<LineRenderer>().positionCount++;
                    currentLine.GetComponent<LineRenderer>().SetPosition(currentLine.GetComponent<LineRenderer>().positionCount - 1, (Vector3)(Vector2)pos);
                    prev = (Vector3)(Vector2)pos;
                }
            }
        }
    }

Control Runes

Control Runes are saved scriptable objects containing the information and functions necessary to detect which rune the player’s drawing is and to process the chain of runes as more are added. They are containers referenced by functions outside of their own code, and are the implementation of an individual rune. The Control Rune base class has only information and no base functions, and is extended to child classes that implement functions over the virtual ones.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "Control", menuName = "Runes/ControlRune", order = 0)]
[System.Serializable]
public class ControlRune : ScriptableObject
{
    [SerializeField]
    public List<Vertices> lines;//Control set of lines to compare to
    public float tolerance;
    public Classification classification;
    public bool subruneOnly;
    public bool greaterRuneOnly;
    public GameObject circle;
    public ControlRune()
    {
        lines = new List<Vertices>();
        tolerance = 0.3f;
    }
    [System.Serializable]
    public class Vertices
    {
        public Vector3[] array;
    }
    //These functions can be used to change Rune.type, Rune.amount, or any of the Scroll.info information
    public virtual void Process(List<Rune> inputs, Rune current) { }//Do this once all inputs have been processed
    public virtual void Setup(Rune current) { }//Do this before any inputs are processed
    public virtual void OnCreation(Rune current) { }//Do this right after the rune is detected
    public void PlaceCircle(GameObject r)
    {
        if(circle != null) Instantiate(circle, r.transform);
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
//Evoke creates either an element/type or creates an object via imprint
[CreateAssetMenu(fileName = "Evoke", menuName = "Runes/Command/Evoke")]
public class Evoke : ControlRune
{
    private SpellInfo.SpellEffect.Command command;
    public override void Process(List<Rune> inputs, Rune current)
    {
        command = new SpellInfo.SpellEffect.Command();
        command.command = "Evoke";
        
        if(inputs.Count > 0)
        foreach (Rune input in inputs)
        {
            if(input != null)
            switch (input.type.classification)
            {
                case Classification.Type:
                    {
                        command.type = input.type.name;
                        break;
                    }
                case Classification.Physical:
                    {
                        switch (input.type.name)
                        {
                            case "Intensity":
                                {
                                    command.intensity = input.value;
                                    break;
                                }
                            case "Duration":
                                {
                                    command.duration = input.value;
                                    break;
                                }
                            case "Imprint":
                                {
                                    command.imprints.Add(input.imprint);
                                    break;
                                }
                            default:
                                {
                                    break;
                                }
                        }
                        break;
                    }
                case Classification.Conduit:
                    {
                        break;
                    }
                case Classification.Condition:
                    {
                        break;
                    }
                case Classification.Command:
                    {
                        break;
                    }
                case Classification.Order:
                    {
                        if(input.type.name == "Blank")
                        {
                        }
                        break;
                    }
                default:
                    {
                        break;
                    }
            }
        }
        Debug.Log("command added!");
        Scroll.scroll.scrollInfo.effects[Scroll.scroll.scrollInfo.effects.Count-1].commands.Add(command);
    }
}

Rune Detection

A large challenge in programming the game was detecting symbols drawn by the player and correctly attributing them to the saved control rune data. Each control rune contains an array of lines with arrays of vertices that corresponds to each line of the pattern. Once a rune is drawn, it is compared to each control rune using the Pattern Recognition Function. This function checks each vertex in line in each control rune to see if the squared distance between it and the drawn rune are within a definable range. If a vertex does not match, then that line is marked as not matching. A drawn rune must have the same number of lines (to be changed later), and must contain a close match of each line in a control rune for it to considered a match. Once a match is found, the loop is broken out of.

private void PatternRecognition()
    {
        Vector3[] vertices;
        bool[][] fails;//True means a check has failed;
        float drawnLength = 0;
        bool reverseCheck = false;
        bool success = false;
        int l1;//Count of lines in control rune
        int l2;//Count of lines in drawn rune
        int v;//Count of vertices in the drawn line
        
        foreach (ControlRune rune in RunesData.runeData.masterRuneList)
        {
            if (rune != null && rune.lines.Count != 0)
            {
                //Create a 2D array of bools to check what matches
                fails = new bool[rune.lines.Count][];
                for (int i = 0; i < rune.lines.Count; i++)
                {
                    fails[i] = new bool[lines.Count];
                    for (int j = 0; j < lines.Count; j++)
                        fails[i][j] = false;
                }
                //LINE IS THE LINE OF THE CONTROL RUNE | Check each control line if it matches at least one of the drawn lies, else try the next rune
                l1 = 0;
                foreach (ControlRune.Vertices line in rune.lines)
                {
                    //Sort out any runes that aren't the same number of drawn lines --- CHANGE IN THE FUTURE
                    if (rune.lines.Count != lines.Count)
                    {
                        //print("Line Mismatch");
                        for (int i = 0; i < rune.lines.Count; i++)
                        {
                            for (int j = 0; j < lines.Count; j++)
                                fails[i][j] = true;
                        }
                        break;
                    }
                    //LINE2 IS THE LINE OF THE DRAWN RUNE | Check each drawn line against a control (because order of the drawn lines might not match)
                    l2 = 0;
                    foreach (GameObject line2 in lines)
                    {
                        //Get an array of vertices from the drawn rune
                        vertices = new Vector3[line2.GetComponent<LineRenderer>().positionCount];
                        line2.GetComponent<LineRenderer>().GetPositions(vertices);
                        //Set each vertex to be centered around 0,0
                        for (int i = 0; i < vertices.Length; i++)
                        {
                            vertices[i] = (vertices[i] - transform.position) / transform.localScale.x * 0.32686f;
                        }
                        //Find the total distance of the line
                        drawnLength = 0;
                        for (int i = 1; i < vertices.Length; i++)
                        {
                            drawnLength += Vector3.Distance(vertices[i - 1], vertices[i]);
                        }
                        v = 0;
                                for (float x = 0; x < line.array.Length - 1; x += (line.array.Length * Vector3.Distance(vertices[v - 1], vertices[v]) / drawnLength))
                        {
                            //print("v = " +v+" | x = " + x);
                            if (v == 0 || v == vertices.Length - 1)//Do this if on the first vertex
                            {
                                if (Vector3.Distance(vertices[v], line.array[(int)x]) > rune.tolerance * 2)//Check the start and end points with a larger tolerance that normal
                                {
                                    //print("Vertex outside of tolerance! (Check 1) Distance:" + Vector3.Distance(vertices[v] - transform.position, line.array[(int)x]));
                                    fails[l1][l2] = true;
                                }
                                if (fails[l1][l2])//Try checking from the end of the drawn line
                                {
                                    reverseCheck = true;
                                    if (Vector3.Distance(vertices[vertices.Length - 1 - v], line.array[(int)x]) > rune.tolerance * 2)
                                    {
                                        //print("Vertex outside of tolerance! (Check 2) Distance:" + Vector3.Distance(vertices[vertices.Length - 1 - v] - transform.position, line.array[(int)x]));
                                        fails[l1][l2] = true;
                                    }
                                    else fails[l1][l2] = false;//If it turns out to match, set the failure to false
                                }
                                if (fails[l1][l2]) break;
                            }
                            else
                            {
                                if (!reverseCheck)//If the line is expected to go the same direction, use this check
                                {
                                    if (Vector3.Distance(vertices[v], line.array[(int)x]) > rune.tolerance)
                                    {
                                        //print("Vertex outside of tolerance! (Check 3) Distance:" + Vector3.Distance(vertices[v] - transform.position, line.array[(int)x]));
                                        fails[l1][l2] = true;
                                    }
                                    if (fails[l1][l2]) break;
                                }
                                else//If the line is expected to go the opposite direction, use this check
                                {
                                    if (Vector3.Distance(vertices[vertices.Length - 1 - v], line.array[(int)x]) > rune.tolerance)
                                    {
                                        //print("Vertex outside of tolerance! (Check 4) Distance:" + Vector3.Distance(vertices[vertices.Length - 1 - v] - transform.position, line.array[(int)x]));
                                        fails[l1][l2] = true;
                                        break;
                                    }
                                    if (fails[l1][l2]) break;
                                }
                            }
                            v++;
                        }
                        reverseCheck = false;
                        if (!fails[l1][l2]) break;
                        l2++;
                    }
                    //print("Checked a line! Outcome:"+ (!fails[l1].Any(x => x) ? "Match" : "Mismatch"));
                    if (fails[l1].All(x => x)) break;
                    l1++;
                }
                //Console output of the failure matrix for bugfixing
                /*string po = "---";
                foreach(bool[] s1 in fails)
                {
                    po += "[";
                    foreach (bool s2 in s1)
                        po += s2 + ",";
                    po += "]---";
                }
                print(po);
                print("Checked a Rune! Outcome:" + ((Array.TrueForAll(fails, x => x.Any(y => !y))) ? "Match" : "Mismatch"));
                */
                //If the rune matches do all this stuff
                if ((Array.TrueForAll(fails, x => x.Any(y => !y))))
                {
                    if(rune.subruneOnly && !subrune)
                    {
                        print("This rune must be a subrune!");
                        Destroy(this);
                        break;
                    }
                    else if(rune.greaterRuneOnly && subrune)
                    {
                        print("This rune cannot be a subrune!");
                        Destroy(this);
                        break;
                    }
                    else
                    {
                        //print("Rune is:" + rune.name);
                        type = rune;
                        name = rune.name;
                        success = true;
                        if (output && !rune.subruneOnly) output.inputs.Add(this);
                        else if (output)
                        {
                            output.subrunes.Add(this);
                            inputs.Add(output);
                        }
                        StartCoroutine(DisplayText(rune.name));
                        rune.OnCreation(this);
                        if (subrune) rune.PlaceCircle(output.gameObject);
                        else rune.PlaceCircle(gameObject);
                        break;
                    }
                }
            }
        }
        if (!success)
        {
            //print("Rune Mismatch");
            StartCoroutine(DisplayText("Mismatch"));
            Scroll.scroll.lockon.SetActive(true);
            Destroy(gameObject);
        }
    }

Scroll Processing

After a rune is detected, the object is linked to its parent output object, creating a chain. If it is a smaller subrune, it is linked to its greater rune. Upon each rune being added, it searches for the “start” rune as an initial start point in the processing. To process the current scroll, it loops through all of a runes inputs (each rune linked in the outer circle), then continues to loop through each, until it reaches runes that have no inputs. Once these are reached, they are processed based on what type of rune they are, then after all of these are processed, the rune that takes in them as inputs is processed based on which are connected, and so on.

public void Activate()
    {
        type.Setup(this);
        Debug.Log(name + " setup done!");
        if (inputs.Count > 0)
            foreach (Rune input in inputs)
                if (input != null) input.Activate();
        type.Process(inputs, this);
        Debug.Log(name + " processed!");
    }

The processing of the scroll is difficult to show because it is only used to calculate the statistics of the scroll that will later be used to compare against. The current Scroll Info class has most of the main ideas and logic of how a spell scroll would work, but may be changed in the future.

Final Scroll Info
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[System.Serializable]
public class SpellInfo //All of the scroll's statistics, used to determine if a scroll is what a customer wants
{
    public bool processed;
    public List<SpellEffect> effects = new List<SpellEffect>();//Simultanous spell effects
    //Cast Info
    public int castTime;//Time it takes to cast the spell
    public bool channel;//Do you have to continue "casting" the spell after the initial cast for it to continue?
    public bool verbal;
    public bool somatic;
    public List<string> materials = new List<string>();//Does it need any materials to cast?
    public List<Condition> conditions = new List<Condition>();//What specific nearby conditions must be met for the spell to be cast?
    public SpellInfo()
    {
    }
    [System.Serializable]
    public class Condition
    {
        public string condition;
        public int amount;//How much of the condition must be met?
        public int range;//How close must the condition be?
        public Condition() { }
    }
    [System.Serializable]
    public class SpellEffect//Everything pertaining to what a spell does
    {
        public List<Command> commands = new List<Command>();
        
        public SpellEffect() { }
        [System.Serializable]
        public class Command
        {
            public string command;
            public int triggerOrder;
            public string type;//The name or ID of the type
            public List<SpellTargeter> targeters = new List<SpellTargeter>();
            //public List<SpellEffect> followUpEffects = new List<SpellEffect>();//Spell effects that happen after this effect has finished
            public List<GameObject> imprints = new List<GameObject>();//Used for spells that can conjure, display, or search
            public Vector3 location;//A set location for spatial spells
            public int intensity;//Intensity of the command
            public int duration;//How long the command lasts for
            public int delay;//How much time before the command happens after triggering
        }
        [System.Serializable]
        public class SpellTargeter
        {
            public string targetType;//Single target, area shape, or projectile
            public int radius;//If its a cone or a sphere, what is it's radius?
            public Vector3 edgeLengths;//If its a cube or rectangular prism, what are its dimensions?
            public bool startAtScroll;//Else, it is assumed that the caster can choose the start position
            public int speed;//Projectile speed
            public SpellTargeter() { }
        }
    }
}