1
\$\begingroup\$

I have a player script that is turning into a bit of a monster. I don't know too much about Unity so I'm just jamming the whole game logic in there which does not seem right. The player is currently handling input, re-stacking stairs, moving the player object, checking successful moves, updating the score, resetting the world on mistakes... I am about to implement pause behavior and it is also going to be listening for esc and pausing the world, resetting the timer so the player has 1 second when they unpause...

I have read through How to architect code as your project scales by Unity. I am just not sure where to apply it. From reading that I think I should have -

  • A game state for keeping score and resetting the world.
  • A player for getting input and moving the player.
  • Something needs to check player move success?
  • Maybe a platform manager for juggling the platforms to create the illusion of infinite stairs.
  • A presentation layer to draw everything?

What do I attach these to? There are also some concepts I haven't used yet, like prefabs and scriptable objects. If there is a good tutorial or course to understand this architecture, please point it out!

Code is below (with gratuitous comments) if anyone can give advice on how to break it up. I am cloning and Overwatch Workshop mode called Bastion Stairs or Infinite Stairs. It can be seen here.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Player : MonoBehaviour
{
    enum Direction
    {
        Left,
        Right,
        NoDir
    }

    // upcoming steps (Left / Right)
    List<Direction> ahead;
    // completed steps
    List<Direction> behind;
    // stair objects
    List<GameObject> stairs;
    
    // player box
    public Rigidbody2D rb;
    // score displays
    public UnityEngine.UI.Text txtScore;
    public UnityEngine.UI.Text txtHighScore;

    // tracker to stop key repeats
    Direction last;
    // player's score
    int score;
    // player's high score
    int highScore;
    // index of the bottom stair
    // bottom stair is moved to top when off screen
    int bottomStair;
    // count of stairs
    int stairCount;
    // how many stairs should be trailing the player
    int trailing;
    // 1 second time out so you don't dawdle 
    float staminaTimeout;

    Direction RandDir() {
        return (Direction)Random.Range(0, 2);
    }

    void Start() {
        // instantiate stuff
        ahead = new List<Direction>();
        behind = new List<Direction>();
        stairs = new List<GameObject>();
        last = Direction.NoDir;
        stairCount = 16;
        trailing = 6;
        highScore = 0;

        // fetch platform (stair) objects
        for (int i = 0; i < 16; i++) {
            GameObject stair = GameObject.Find("blank_" + i);
            stairs.Add(stair);
        }

        reset();
        initStairs();
    }

    void stairStack() {
        // moves the bottom stair object above the current top stair
        GameObject bottom = stairs[bottomStair % stairCount];
        Vector2 bpos = bottom.GetComponent<Rigidbody2D>().position;
        
        int topStair = (bottomStair + (stairCount - 1));
        GameObject top = stairs[topStair % stairCount];
        Vector2 tpos = top.GetComponent<Rigidbody2D>().position;

        bpos.y = tpos.y + 1f;

        // offset left or right depending on top direction
        if (ahead[stairCount-(trailing+1)] == Direction.Left) {
            bpos.x = tpos.x - 0.5f;
        } else {
            bpos.x = tpos.x + 0.5f;
        }

        bottom.GetComponent<Rigidbody2D>().position = bpos;
        bottomStair++;
    }

    // reset game state
    void reset() {
        ahead.Clear();
        behind.Clear();
        score = 0;
        bottomStair = 0;
        staminaTimeout = 0f;

        txtScore.text = "Score: " + score;

        for (int i = 0; i < stairCount; i++) {
            ahead.Add(RandDir());
        }

        // move player to bottom
        Vector2 pos = new Vector2(0f, -4.5f);
        rb.position = pos;
    }

    // sets up stairs at game start
    void initStairs() {
        // first stair position
        float xpos = 0f;
        float ypos = -4f;
        for (int idx = 0; idx < stairCount; idx++) {
            // offset left or right depending on direction
            if (ahead[idx] == Direction.Left) {
                xpos += -0.5f;
            } else {
                xpos += 0.5f;
            }

            // set stair object position
            GameObject stair = stairs[idx];
            Vector2 pos = stair.GetComponent<Rigidbody2D>().position;
            pos.x = xpos;
            pos.y = ypos + idx;
            stair.GetComponent<Rigidbody2D>().position = pos;
        }
    }

    void Update() {
        // get move
        float move = Input.GetAxisRaw("Horizontal");

        // convert move to direction
        Direction inpt = Direction.NoDir;
        if (move < 0) inpt = Direction.Left;
        if (move > 0) inpt = Direction.Right;

        // check stamina timeout
        if (staminaTimeout > 0 && Time.time > staminaTimeout) {
            reset();
            initStairs();
            return;
        }

        // ignore repeats
        if (inpt == last) {
            return;
        }

        last = inpt;

        // ignore no input
        if (inpt == Direction.NoDir) {
            return;
        }

        // set stamina timeout (1s)
        staminaTimeout = Time.time + 1f;

        // get correct direction
        Direction curr = ahead[0];
        // update ahead 
        ahead.RemoveAt(0);
        ahead.Add(RandDir());

        Vector2 pos = rb.position;
        
        // handle going in the correct direction
        if (move < 0 && curr == Direction.Left) {
            // increment score
            score++;
            // track move for no reason in particular
            behind.Insert(0, curr);

            // move player
            GameObject stair = stairs[(behind.Count-1) % stairCount];
            pos.x = stair.GetComponent<Rigidbody2D>().position.x;
            pos.y += 1f;
            
        } else if (move > 0 && curr == Direction.Right) {
            // same same but right
            // and yea, I can consolidate this with the one above
            score++;
            behind.Insert(0, curr);

            GameObject stair = stairs[(behind.Count-1) % stairCount];
            pos.x = stair.GetComponent<Rigidbody2D>().position.x;
            pos.y += 1f;
        } else {
            // wrong move
            reset();
            initStairs();
            return;
        }

        // update player
        rb.MovePosition(pos);

        // move bottom stair to top
        if (behind.Count > trailing) {
            stairStack();
        }

        // update score display
        txtScore.text = "Score: " + score;
        if (score > highScore) {
            highScore = score;
            txtHighScore.text = "High Score: " + highScore;
        }
    }
}

a box that climbs stairs

\$\endgroup\$
2
  • \$\begingroup\$ when I see a comment, commenting a block, I tend to extract this block to a method with a name composed by the comment. i.e // check stamina timeout to checkStaminaTimeout() or isStaminaTimeout.. \$\endgroup\$
    – VP.
    Dec 30 '21 at 10:03
  • \$\begingroup\$ "SOLID" principles will help you make your code a lot cleaner, and debugging and modification in existing code a lot easier \$\endgroup\$ Dec 31 '21 at 10:46
2
\$\begingroup\$

Single responsibility principle

Every script should do exactly one thing. So when you describe the functionality of a script and you use the word "and" in that description, then that's a sign that it should probably be split up.

For example, this script could be documented as "This script spawns the stairs and moves the player and updates the score display".

Two "and"s means that this should probably be three scripts instead of one.

Every game object should control itself.

When you have an UI element, then the code to control that UI element should be in a script on that UI element. When you have a player, then that code should be on the player game object.

Now you might encounter the problem: "But the UI element needs to know the players score. It can't access that because it's part of the player script". It can. That's where references to other scripts come in handy.

  1. Go to the player script and turn the variable int score into public int score. Now the score of each player can be accessed by other scripts.
  2. Go to the score script and create a variable public Player player.
  3. When you now go to the Unity editor, the script will have a slot "Player" in its inspector. Drag the player into it. Now you told the score display which player to visualize. Yes, that means that when you want to add a second player with a separate score later, then you just need to create a second score display and assign the other player to it.
  4. Now you can refer to the players score in your UI script with player.score.

But sometimes self-controlling game objects are not feasible

Like with the stairs, for example. There are many of them, and it's far easier to control them all together instead of having each stair try to manage itself. So you should create a "StairManager" script which handles the movement of stairs. Common practice is to create a separate game object at the root of the scene just to host such manager scripts.

\$\endgroup\$
2
  • \$\begingroup\$ Does the "and" principle apply within an object? I can't think of a great example, but for a shooter, something like this script moves the player and handles firing? \$\endgroup\$
    – douggard
    Dec 29 '21 at 23:12
  • 1
    \$\begingroup\$ @douggard Yes. This example here was too simple to demonstrate it properly. But in more complex games it's usually a good idea to follow the composition principle. That means you don't have a script "Player" which does everything player-related. You instead break up all the functionalities of the player into separate scripts which can then also be reused for non-player entities which follow the same rules as the player. For example, when both the player and the enemy can shoot bullets, then it makes sense to extract that functionality into a "BulletShooter" component which is used by both. \$\endgroup\$
    – Philipp
    Dec 29 '21 at 23:22
0
\$\begingroup\$

Posting since this might help someone else. Thanks to @Philipp's helpful answer, this is what I ended up with. This organization feels much cleaner!

StairMaster feels a little weird since it does not use Start or Update, and StaminaBar had a bit of a race condition where Player was calling Reset() on it before Start() was called, so localScale was being assigned Vector3(0f, 0f, 0f).

// HighScore.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HighScore : MonoBehaviour
{
    public Player player;
    int highScore = 0;

    void Start() {
        gameObject.GetComponent<UnityEngine.UI.Text>().text = "High Score: 0";
    }

    void Update() {
        if (player.score > highScore) {
            highScore = player.score;
            gameObject.GetComponent<UnityEngine.UI.Text>().text = "High Score: " + player.score;
        }
    }
}

// Score.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Score : MonoBehaviour
{
    public Player player;
    void Start() {
        gameObject.GetComponent<UnityEngine.UI.Text>().text = "Score: 0";
    }

    void Update() {
        gameObject.GetComponent<UnityEngine.UI.Text>().text = "Score: " + player.score;
    }
}

// StairMaster.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class StairMaster : MonoBehaviour
{

    public enum Direction
    {
        Left,
        Right,
        NoDir
    }

    List<GameObject> stairs = new List<GameObject>();
    List<Direction> ahead = new List<Direction>();
    List<Direction> behind = new List<Direction>();

    int bottomStair = 0;
    int stairCount = 16;
    int trailing = 6;

    public void Reset() {
        if (stairs.Count == 0) {
            for (int i = 0; i < 16; i++) {
                GameObject stair = GameObject.Find("stair_" + i);
                stairs.Add(stair);
            }
        }

        ahead.Clear();
        behind.Clear();
        bottomStair = 0;

        for (int i = 0; i < stairCount; i++) {
            ahead.Add(randDir());
        }

        initStairs();
    }

    public Direction NextDir() {
        return ahead[0];
    }

    public Vector2 NextStairPos() {
        GameObject next = stairs[behind.Count % stairCount];
        return next.GetComponent<Rigidbody2D>().position;
    }

    public void StairStack() {
        Direction curr = NextDir();
        ahead.RemoveAt(0);
        behind.Insert(0, curr);

        ahead.Add(randDir());

        if (behind.Count <= trailing) {
            return;
        }

        GameObject bottom = stairs[bottomStair % stairCount];
        Vector2 bpos = bottom.GetComponent<Rigidbody2D>().position;

        int topStair = (bottomStair + (stairCount - 1));
        GameObject top = stairs[topStair % stairCount];
        Vector2 tpos = top.GetComponent<Rigidbody2D>().position;

        bpos.y = tpos.y + 1f;

        if (ahead[stairCount - (trailing + 1)] == Direction.Left) {
            bpos.x = tpos.x - 0.5f;
        } else {
            bpos.x = tpos.x + 0.5f;
        }

        bottom.GetComponent<Rigidbody2D>().position = bpos;
        bottomStair++;
    }

    void Start() {
        // intentionally blank
    }

    Direction randDir() {
        return (Direction)Random.Range(0, 2);
    }

    void initStairs() {
        float xpos = 0f;
        float ypos = -4f;
        for (int idx = 0; idx < stairCount; idx++) {
            if (ahead[idx] == Direction.Left) {
                xpos += -0.5f;
            } else {
                xpos += 0.5f;
            }

            GameObject stair = stairs[idx];
            Vector2 pos = stair.GetComponent<Rigidbody2D>().position;
            pos.x = xpos;
            pos.y = ypos + idx;
            stair.GetComponent<Rigidbody2D>().position = pos;
        }
    }

    void Update() {
        // intentionally blank

    }
}

// StaminaBar.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class StaminaBar : MonoBehaviour
{
    Vector3 initialScale;
    Vector3 uninitialized = new Vector3();
    float timeoutDelta = 0f;
    float timeSet = 0f;

    void Start() {
        initialScale = gameObject.GetComponent<Transform>().localScale;
    }

    public void Reset() {
        timeoutDelta = 0f;
        timeSet = 0f;
        if (initialScale != uninitialized) {
            gameObject.GetComponent<Transform>().localScale = initialScale;
        }
    }

    public bool TimedOut() {
        if (timeoutDelta == 0f) {
            return false;
        }
        return Time.time > timeout();
    }

    public void SetTimeout(float delta) {
        timeoutDelta = delta;
        timeSet = Time.time;
    }

    float timeout() {
        return timeSet + timeoutDelta;
    }

    void Update() {
        if (timeoutDelta == 0f) {
            gameObject.GetComponent<Transform>().localScale = initialScale;
            return;
        }

        float diff = timeout() - Time.time;
        if (diff < 0f) {
            diff = 0f;
        }

        Vector3 scale = initialScale;
        scale.y = scale.y * diff;
        gameObject.GetComponent<Transform>().localScale = scale;
    }
}

// Player.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Player : MonoBehaviour
{
    public Rigidbody2D rb;
    public StairMaster stairMaster;
    public StaminaBar staminaBar;
    public int score;
    
    StairMaster.Direction last;

    void Start() {
        last = StairMaster.Direction.NoDir;
        reset();
    }

    void reset() {
        stairMaster.Reset();
        staminaBar.Reset();
        score = 0;

        Vector2 pos = new Vector2(0f, -4.5f);
        rb.position = pos;
    }

    void Update() {
        float move = Input.GetAxisRaw("Horizontal");

        StairMaster.Direction inpt = StairMaster.Direction.NoDir;
        if (move < 0) inpt = StairMaster.Direction.Left;
        if (move > 0) inpt = StairMaster.Direction.Right;

        if (staminaBar.TimedOut()) {
            reset();
            return;
        }

        if (inpt == last) {
            return;
        }

        last = inpt;

        if (inpt == StairMaster.Direction.NoDir) {
            return;
        }

        staminaBar.SetTimeout(1f);
        StairMaster.Direction curr = stairMaster.NextDir();

        Vector2 pos = rb.position;
        
        if (move < 0 && curr == StairMaster.Direction.Left || move > 0 && curr == StairMaster.Direction.Right) {
            score++;
            pos.x = stairMaster.NextStairPos().x;
            pos.y += 1f;
        } else {
            reset();
            return;
        }

        rb.MovePosition(pos);
        stairMaster.StairStack();
    }
}

```
\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .